From fdc96885a69177499f12ff0860fc85f32bdb4544 Mon Sep 17 00:00:00 2001 From: chioarub Date: Tue, 16 Jun 2026 14:18:08 +0300 Subject: [PATCH 01/15] fix(status): show active provider route instead of legacy bucket The /status command collapsed many concrete providers (OpenRouter, Groq, Ollama, Fireworks AI, etc.) into a single "OpenAI-compatible" label, making multi-provider setups hard to verify and debug. When apiProvider resolves to the generic "openai" bucket, /status now uses route metadata to surface the real active route: Provider route: OpenRouter Transport: OpenAI-compatible API OpenAI base URL: https://openrouter.ai/api/v1 Model: anthropic/claude-sonnet-4.5 Credential: OPENROUTER_API_KEY configured The legacy "OpenAI-compatible" label and fallback are preserved for unknown custom base URLs. Dedicated provider buckets (nvidia-nim, minimax, codex, github, xai, gemini, bedrock, vertex, foundry, firstParty, mistral) already have accurate labels and are left untouched. Credential display uses env-var names only (never values). Transport kind and route label come from the existing descriptor-driven route metadata; no new hardcoded provider maps or network calls are introduced. --- src/utils/status.routes.test.ts | 242 ++++++++++++++++++++++++++++++++ src/utils/status.tsx | 67 ++++++++- 2 files changed, 307 insertions(+), 2 deletions(-) create mode 100644 src/utils/status.routes.test.ts diff --git a/src/utils/status.routes.test.ts b/src/utils/status.routes.test.ts new file mode 100644 index 0000000000..5c6147909e --- /dev/null +++ b/src/utils/status.routes.test.ts @@ -0,0 +1,242 @@ +import { afterEach, beforeEach, expect, mock, test } from 'bun:test' + +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' + +const ORIGINAL_ENV = { ...process.env } + +// Env vars that influence provider/route resolution. Cleared before every test +// so assertions are deterministic regardless of the host shell environment. +const PROVIDER_ENV_VARS = [ + 'CLAUDE_CODE_USE_OPENAI', + 'CLAUDE_CODE_USE_GEMINI', + 'CLAUDE_CODE_USE_GITHUB', + 'CLAUDE_CODE_USE_MISTRAL', + 'CLAUDE_CODE_USE_BEDROCK', + 'CLAUDE_CODE_USE_VERTEX', + 'CLAUDE_CODE_USE_FOUNDRY', + 'CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED', + 'OPENAI_BASE_URL', + 'OPENAI_API_BASE', + 'OPENAI_API_KEY', + 'OPENAI_MODEL', + 'OPENROUTER_API_KEY', + 'GROQ_API_KEY', + 'GEMINI_BASE_URL', + 'GEMINI_MODEL', + 'GEMINI_API_KEY', + 'NVIDIA_NIM', +] + +function restoreEnv(): void { + for (const key of Object.keys(process.env)) { + if (!(key in ORIGINAL_ENV)) { + delete process.env[key] + } + } + + for (const [key, value] of Object.entries(ORIGINAL_ENV)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } +} + +function clearProviderEnv(): void { + for (const key of PROVIDER_ENV_VARS) { + delete process.env[key] + } +} + +async function buildPropertiesWithProvider(provider: string): Promise< + Array<{ label?: string; value: unknown }> +> { + mock.restore() + mock.module('./model/providers.js', () => ({ + getAPIProvider: () => provider, + getAPIProviderForStatsig: () => provider, + isFirstPartyAnthropicBaseUrl: () => true, + isGithubNativeAnthropicMode: () => false, + })) + const nonce = `${Date.now()}-${Math.random()}` + const { buildAPIProviderProperties } = await import(`./status.js?ts=${nonce}`) + return buildAPIProviderProperties() +} + +function findValue( + properties: Array<{ label?: string; value: unknown }>, + label: string, +): unknown { + return properties.find(property => property.label === label)?.value +} + +beforeEach(async () => { + await acquireSharedMutationLock('utils/status.routes.test.ts') + clearProviderEnv() +}) + +afterEach(() => { + try { + mock.restore() + restoreEnv() + } finally { + releaseSharedMutationLock() + } +}) + +test('OpenAI route resolves to the OpenAI route label', async () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1' + process.env.OPENAI_MODEL = 'gpt-4o' + process.env.OPENAI_API_KEY = 'sk-test-openai-key' + + const properties = await buildPropertiesWithProvider('openai') + expect(findValue(properties, 'Provider route')).toBe('OpenAI') + expect(findValue(properties, 'Transport')).toBe('OpenAI-compatible API') + expect(findValue(properties, 'Model')).toBe('gpt-4o') + const credential = findValue(properties, 'Credential') as + | string + | undefined + expect(credential).toContain('OPENAI_API_KEY') + expect(credential).not.toContain('sk-test-openai-key') +}) + +test('Ollama route shows local route details', async () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1' + process.env.OPENAI_MODEL = 'llama3.2' + + const properties = await buildPropertiesWithProvider('openai') + expect(findValue(properties, 'Provider route')).toBe('Ollama') + expect(findValue(properties, 'Transport')).toBe('OpenAI-compatible API') + expect(findValue(properties, 'Model')).toBe('llama3.2') +}) + +test('OpenRouter route shows its route label instead of OpenAI-compatible', async () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://openrouter.ai/api/v1' + process.env.OPENAI_MODEL = 'anthropic/claude-sonnet-4.5' + process.env.OPENROUTER_API_KEY = 'sk-or-test-key' + + const properties = await buildPropertiesWithProvider('openai') + expect(findValue(properties, 'Provider route')).toBe('OpenRouter') + expect(findValue(properties, 'API provider')).toBeUndefined() + const credential = findValue(properties, 'Credential') as + | string + | undefined + expect(credential).toContain('OPENROUTER_API_KEY') + expect(credential).not.toContain('sk-or-test-key') +}) + +test('Groq route shows its route label instead of OpenAI-compatible', async () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://api.groq.com/openai/v1' + process.env.OPENAI_MODEL = 'llama-3.3-70b-versatile' + process.env.GROQ_API_KEY = 'gsk_test-key' + + const properties = await buildPropertiesWithProvider('openai') + expect(findValue(properties, 'Provider route')).toBe('Groq') + expect(findValue(properties, 'API provider')).toBeUndefined() + expect( + (findValue(properties, 'Credential') as string | undefined)?.includes( + 'GROQ_API_KEY', + ), + ).toBe(true) +}) + +test('Gemini route remains clear', async () => { + process.env.CLAUDE_CODE_USE_GEMINI = '1' + process.env.GEMINI_MODEL = 'gemini-2.0-flash' + process.env.GEMINI_API_KEY = 'gem-test-key' + + const properties = await buildPropertiesWithProvider('gemini') + expect(findValue(properties, 'API provider')).toBe('Google Gemini') + // Gemini has a native transport; route-aware block does not override it. + expect(findValue(properties, 'Provider route')).toBeUndefined() + expect(findValue(properties, 'Model')).toBe('gemini-2.0-flash') +}) + +test('GitHub route remains clear', async () => { + process.env.CLAUDE_CODE_USE_GITHUB = '1' + process.env.OPENAI_BASE_URL = 'https://models.inference.ai.azure.com' + process.env.OPENAI_MODEL = 'gpt-4o' + + const properties = await buildPropertiesWithProvider('github') + // GitHub has a dedicated bucket; route-aware block does not override it. + expect(findValue(properties, 'API provider')).toBe('GitHub Models') + expect(findValue(properties, 'Provider route')).toBeUndefined() +}) + +test('unknown custom route falls back gracefully', async () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://my-internal-proxy.example.com/v1' + process.env.OPENAI_MODEL = 'internal-model' + process.env.OPENAI_API_KEY = 'sk-internal-key' + + const properties = await buildPropertiesWithProvider('openai') + // No known route -> legacy "OpenAI-compatible" bucket is preserved. + expect(findValue(properties, 'API provider')).toBe('OpenAI-compatible') + expect(findValue(properties, 'Provider route')).toBeUndefined() + expect(findValue(properties, 'Model')).toBe('internal-model') +}) + +test('secrets are never leaked in status properties', async () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://openrouter.ai/api/v1' + process.env.OPENAI_MODEL = 'anthropic/claude-sonnet-4.5' + process.env.OPENROUTER_API_KEY = 'sk-or-SECRET-VALUE-123' + process.env.OPENAI_API_KEY = 'sk-SECRET-VALUE-456' + + const properties = await buildPropertiesWithProvider('openai') + const serialized = JSON.stringify(properties) + expect(serialized).not.toContain('sk-or-SECRET-VALUE-123') + expect(serialized).not.toContain('sk-SECRET-VALUE-456') +}) + +test('end-to-end: real getAPIProvider resolves OpenRouter without mocking', async () => { + // Do NOT mock getAPIProvider. Verify the full chain: env -> getAPIProvider + // collapses to 'openai' -> route resolution surfaces 'OpenRouter'. + mock.restore() + const nonce = `${Date.now()}-${Math.random()}` + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://openrouter.ai/api/v1' + process.env.OPENAI_MODEL = 'anthropic/claude-sonnet-4.5' + process.env.OPENROUTER_API_KEY = 'sk-or-test-key' + + const { buildAPIProviderProperties } = await import(`./status.js?ts=${nonce}`) + const properties = buildAPIProviderProperties() + expect(findValue(properties, 'Provider route')).toBe('OpenRouter') + expect(findValue(properties, 'API provider')).toBeUndefined() + expect(findValue(properties, 'Transport')).toBe('OpenAI-compatible API') +}) + +test('credential summary lists only configured env vars when multiple are known', async () => { + // OpenRouter knows OPENROUTER_API_KEY and OPENAI_API_KEY. Configure only one. + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://openrouter.ai/api/v1' + process.env.OPENAI_MODEL = 'anthropic/claude-sonnet-4.5' + process.env.OPENROUTER_API_KEY = 'sk-or-test-key' + // OPENAI_API_KEY intentionally left unset. + + const properties = await buildPropertiesWithProvider('openai') + const credential = findValue(properties, 'Credential') as + | string + | undefined + expect(credential).toContain('OPENROUTER_API_KEY') + expect(credential).not.toContain('OPENAI_API_KEY') +}) + +test('route-aware label is omitted when no credential env var is configured', async () => { + // Ollama is a local route with no required credential env var. + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1' + process.env.OPENAI_MODEL = 'llama3.2' + + const properties = await buildPropertiesWithProvider('openai') + expect(findValue(properties, 'Provider route')).toBe('Ollama') + expect(findValue(properties, 'Credential')).toBeUndefined() +}) diff --git a/src/utils/status.tsx b/src/utils/status.tsx index d0fbdc164c..9ccbcb77e9 100644 --- a/src/utils/status.tsx +++ b/src/utils/status.tsx @@ -22,6 +22,12 @@ import { getEnabledSettingSources, getSettingSourceDisplayNameCapitalized } from import { getManagedFileSettingsPresence, getPolicySettingsOrigin, getSettingsForSource } from './settings/settings.js'; import type { ThemeName } from './theme.js'; import { redactSecretValueForDisplay, type SecretValueSource } from './providerSecrets.js'; +import { + getRouteCredentialEnvVars, + getRouteLabel, + getRouteProviderTypeLabel, + resolveActiveRouteIdFromEnv, +} from '../integrations/routeMetadata.js'; export type Property = { label?: string; value: React.ReactNode | Array; @@ -114,6 +120,35 @@ function pushRedactedProperty( value: redactSecretValueForDisplay(value, secretSource) ?? value }); } + +/** + * Resolves the active provider route from the environment. Returns the route id + * when it identifies a concrete gateway/vendor (e.g. "openrouter", "groq", + * "ollama", "openai"), and null for the generic "custom" fallback or when route + * resolution is unavailable. + */ +function resolveDisplayRouteId(): string | null { + const routeId = resolveActiveRouteIdFromEnv(process.env); + if (!routeId || routeId === 'custom' || routeId === 'anthropic') { + return null; + } + return routeId; +} + +/** + * Builds a credential source summary (env var names only, never values) for the + * given route. Returns null when no credential env vars are configured or known. + */ +function buildRouteCredentialSummary(routeId: string): string | null { + const envVars = getRouteCredentialEnvVars(routeId); + const configured = envVars.filter(name => + Boolean(process.env[name]?.trim()), + ); + if (configured.length === 0) { + return null; + } + return configured.map(name => `${name} configured`).join(', '); +} export function buildSandboxProperties(): Property[] { if (process.env.USER_TYPE !== 'ant') { return []; @@ -338,9 +373,17 @@ export function buildAPIProviderProperties(): Property[] { MISTRAL_API_KEY: process.env.MISTRAL_API_KEY }; if (apiProvider !== 'firstParty') { - const providerLabel = API_PROVIDER_LABELS[apiProvider]; + // The legacy "openai" bucket collapses many concrete providers (OpenRouter, + // Groq, Ollama, Fireworks, etc.) into a single "OpenAI-compatible" label. + // When route resolution identifies a concrete provider, surface its real + // label instead. Dedicated buckets (nvidia-nim, minimax, codex, github, + // xai, ...) already have accurate labels and are left untouched. + const routeId = + apiProvider === 'openai' ? resolveDisplayRouteId() : null; + const routeLabel = routeId ? getRouteLabel(routeId) : null; + const providerLabel = routeLabel ?? API_PROVIDER_LABELS[apiProvider]; properties.push({ - label: 'API provider', + label: routeId ? 'Provider route' : 'API provider', value: providerLabel }); } @@ -416,6 +459,17 @@ export function buildAPIProviderProperties(): Property[] { } else if (apiProvider in OPENAI_COMPATIBLE_STATUS_METADATA) { const metadata = OPENAI_COMPATIBLE_STATUS_METADATA[apiProvider]!; + const routeId = + apiProvider === 'openai' ? resolveDisplayRouteId() : null; + const transportLabel = routeId + ? getRouteProviderTypeLabel(routeId) + : null; + if (transportLabel) { + properties.push({ + label: 'Transport', + value: transportLabel, + }); + } pushRedactedProperty( properties, metadata.baseUrlLabel, @@ -435,6 +489,15 @@ export function buildAPIProviderProperties(): Property[] { secretSource, ); } + if (routeId) { + const credentialSummary = buildRouteCredentialSummary(routeId); + if (credentialSummary) { + properties.push({ + label: 'Credential', + value: credentialSummary, + }); + } + } } else if (apiProvider === 'gemini') { const geminiBaseUrl = process.env.GEMINI_BASE_URL; pushRedactedProperty(properties, 'Gemini base URL', geminiBaseUrl, secretSource); From 9c2642eaea61a87b2b8d4fb6aee92d10a38eda92 Mon Sep 17 00:00:00 2001 From: chioarub Date: Tue, 16 Jun 2026 14:59:06 +0300 Subject: [PATCH 02/15] fix(status): include route status defaults --- src/utils/status.routes.test.ts | 44 ++++++++++++++++++++++++++++++--- src/utils/status.tsx | 29 +++++++++++++++++++--- 2 files changed, 65 insertions(+), 8 deletions(-) diff --git a/src/utils/status.routes.test.ts b/src/utils/status.routes.test.ts index 5c6147909e..1fc3a83696 100644 --- a/src/utils/status.routes.test.ts +++ b/src/utils/status.routes.test.ts @@ -24,6 +24,7 @@ const PROVIDER_ENV_VARS = [ 'OPENAI_MODEL', 'OPENROUTER_API_KEY', 'GROQ_API_KEY', + 'FIREWORKS_API_KEY', 'GEMINI_BASE_URL', 'GEMINI_MODEL', 'GEMINI_API_KEY', @@ -67,6 +68,15 @@ async function buildPropertiesWithProvider(provider: string): Promise< return buildAPIProviderProperties() } +async function buildPropertiesWithRealProvider(): Promise< + Array<{ label?: string; value: unknown }> +> { + mock.restore() + const nonce = `${Date.now()}-${Math.random()}` + const { buildAPIProviderProperties } = await import(`./status.js?ts=${nonce}`) + return buildAPIProviderProperties() +} + function findValue( properties: Array<{ label?: string; value: unknown }>, label: string, @@ -132,6 +142,19 @@ test('OpenRouter route shows its route label instead of OpenAI-compatible', asyn expect(credential).not.toContain('sk-or-test-key') }) +test('OpenRouter route displays OPENAI_API_BASE when it selected the route', async () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_API_BASE = 'https://openrouter.ai/api/v1' + process.env.OPENAI_MODEL = 'anthropic/claude-sonnet-4.5' + process.env.OPENROUTER_API_KEY = 'sk-or-test-key' + + const properties = await buildPropertiesWithRealProvider() + expect(findValue(properties, 'Provider route')).toBe('OpenRouter') + expect(findValue(properties, 'OpenAI base URL')).toBe( + 'https://openrouter.ai/api/v1', + ) +}) + test('Groq route shows its route label instead of OpenAI-compatible', async () => { process.env.CLAUDE_CODE_USE_OPENAI = '1' process.env.OPENAI_BASE_URL = 'https://api.groq.com/openai/v1' @@ -148,6 +171,22 @@ test('Groq route shows its route label instead of OpenAI-compatible', async () = ).toBe(true) }) +test('env-only Fireworks route displays descriptor defaults', async () => { + process.env.FIREWORKS_API_KEY = 'fw-test-key' + + const properties = await buildPropertiesWithRealProvider() + expect(findValue(properties, 'Provider route')).toBe('Fireworks AI') + expect(findValue(properties, 'OpenAI base URL')).toBe( + 'https://api.fireworks.ai/inference/v1', + ) + expect(findValue(properties, 'Model')).toBe( + 'accounts/fireworks/models/llama-v3p1-70b-instruct', + ) + expect(findValue(properties, 'Credential')).toBe( + 'FIREWORKS_API_KEY configured', + ) +}) + test('Gemini route remains clear', async () => { process.env.CLAUDE_CODE_USE_GEMINI = '1' process.env.GEMINI_MODEL = 'gemini-2.0-flash' @@ -200,15 +239,12 @@ test('secrets are never leaked in status properties', async () => { test('end-to-end: real getAPIProvider resolves OpenRouter without mocking', async () => { // Do NOT mock getAPIProvider. Verify the full chain: env -> getAPIProvider // collapses to 'openai' -> route resolution surfaces 'OpenRouter'. - mock.restore() - const nonce = `${Date.now()}-${Math.random()}` process.env.CLAUDE_CODE_USE_OPENAI = '1' process.env.OPENAI_BASE_URL = 'https://openrouter.ai/api/v1' process.env.OPENAI_MODEL = 'anthropic/claude-sonnet-4.5' process.env.OPENROUTER_API_KEY = 'sk-or-test-key' - const { buildAPIProviderProperties } = await import(`./status.js?ts=${nonce}`) - const properties = buildAPIProviderProperties() + const properties = await buildPropertiesWithRealProvider() expect(findValue(properties, 'Provider route')).toBe('OpenRouter') expect(findValue(properties, 'API provider')).toBeUndefined() expect(findValue(properties, 'Transport')).toBe('OpenAI-compatible API') diff --git a/src/utils/status.tsx b/src/utils/status.tsx index 9ccbcb77e9..e98605a940 100644 --- a/src/utils/status.tsx +++ b/src/utils/status.tsx @@ -24,6 +24,8 @@ import type { ThemeName } from './theme.js'; import { redactSecretValueForDisplay, type SecretValueSource } from './providerSecrets.js'; import { getRouteCredentialEnvVars, + getRouteDefaultBaseUrl, + getRouteDefaultModel, getRouteLabel, getRouteProviderTypeLabel, resolveActiveRouteIdFromEnv, @@ -124,8 +126,8 @@ function pushRedactedProperty( /** * Resolves the active provider route from the environment. Returns the route id * when it identifies a concrete gateway/vendor (e.g. "openrouter", "groq", - * "ollama", "openai"), and null for the generic "custom" fallback or when route - * resolution is unavailable. + * "ollama", "openai"), and null for the generic "custom" fallback, the + * first-party "anthropic" route, or when route resolution is unavailable. */ function resolveDisplayRouteId(): string | null { const routeId = resolveActiveRouteIdFromEnv(process.env); @@ -149,6 +151,25 @@ function buildRouteCredentialSummary(routeId: string): string | null { } return configured.map(name => `${name} configured`).join(', '); } + +function getOpenAICompatibleBaseUrlForStatus( + routeId: string | null, +): string | undefined { + return ( + process.env.OPENAI_BASE_URL?.trim() || + process.env.OPENAI_API_BASE?.trim() || + (routeId ? getRouteDefaultBaseUrl(routeId) : undefined) + ); +} + +function getOpenAICompatibleModelForStatus( + routeId: string | null, +): string | undefined { + return ( + process.env.OPENAI_MODEL?.trim() || + (routeId ? getRouteDefaultModel(routeId) : undefined) + ); +} export function buildSandboxProperties(): Property[] { if (process.env.USER_TYPE !== 'ant') { return []; @@ -473,10 +494,10 @@ export function buildAPIProviderProperties(): Property[] { pushRedactedProperty( properties, metadata.baseUrlLabel, - process.env.OPENAI_BASE_URL, + getOpenAICompatibleBaseUrlForStatus(routeId), secretSource, ); - const openaiModel = process.env.OPENAI_MODEL; + const openaiModel = getOpenAICompatibleModelForStatus(routeId); if (openaiModel) { const modelDisplay = formatOpenAICompatibleModelDisplay( openaiModel, From ca3420cfbd78f22186aab901ad0f1adb22268627 Mon Sep 17 00:00:00 2001 From: chioarub Date: Tue, 16 Jun 2026 15:40:50 +0300 Subject: [PATCH 03/15] fix(status): address route status review findings --- src/utils/providerProfile.test.ts | 9 +++++ src/utils/providerSecrets.ts | 18 ++++++++- src/utils/status.routes.test.ts | 26 ++++++++++++ src/utils/status.tsx | 66 ++++++++++++++++++++++++++++--- 4 files changed, 111 insertions(+), 8 deletions(-) diff --git a/src/utils/providerProfile.test.ts b/src/utils/providerProfile.test.ts index 507c244b5f..3f5d3fc1af 100644 --- a/src/utils/providerProfile.test.ts +++ b/src/utils/providerProfile.test.ts @@ -1535,6 +1535,7 @@ test('maskSecretForDisplay preserves only a short prefix and suffix', () => { test('redactSecretValueForDisplay masks poisoned display fields that equal configured secrets', () => { const apiKey = 'sk-secret-12345678' const authHeaderValue = 'hicap-header-secret' + const routeApiKey = 'gsk-route-secret-value' assert.equal( redactSecretValueForDisplay(apiKey, { OPENAI_API_KEY: apiKey }), @@ -1546,10 +1547,18 @@ test('redactSecretValueForDisplay masks poisoned display fields that equal confi }), 'hic...ret', ) + assert.equal( + redactSecretValueForDisplay(routeApiKey, { GROQ_API_KEY: routeApiKey }), + 'gsk...lue', + ) assert.equal( redactSecretValueForDisplay('gpt-4o', { OPENAI_API_KEY: apiKey }), 'gpt-4o', ) + assert.equal( + redactSecretValueForDisplay('gpt-4o', { OPENAI_MODEL: 'gpt-4o' }), + 'gpt-4o', + ) }) test('sanitizeProviderConfigValue drops secret-like poisoned values', () => { diff --git a/src/utils/providerSecrets.ts b/src/utils/providerSecrets.ts index e39f33921c..5f6c9180a7 100644 --- a/src/utils/providerSecrets.ts +++ b/src/utils/providerSecrets.ts @@ -10,7 +10,7 @@ const SECRET_ENV_KEYS = [ ] as const export type SecretValueSource = Partial< - Record<(typeof SECRET_ENV_KEYS)[number], string | undefined> + Record > export function sanitizeApiKey( @@ -35,6 +35,18 @@ function looksLikeSecretValue(value: string): boolean { return false } +function isSecretEnvKey(key: string): boolean { + return ( + SECRET_ENV_KEYS.includes(key as (typeof SECRET_ENV_KEYS)[number]) || + key.endsWith('_API_KEY') || + key.endsWith('_AUTH_HEADER_VALUE') || + key.endsWith('_SECRET') || + key.endsWith('_SECRET_ACCESS_KEY') || + key.endsWith('_SECRET_KEY') || + key.endsWith('_TOKEN') + ) +} + function collectSecretValues( sources: Array, ): string[] { @@ -43,7 +55,9 @@ function collectSecretValues( for (const source of sources) { if (!source) continue - for (const key of SECRET_ENV_KEYS) { + for (const key of Object.keys(source)) { + if (!isSecretEnvKey(key)) continue + const value = sanitizeApiKey(source[key]) if (value) { values.add(value) diff --git a/src/utils/status.routes.test.ts b/src/utils/status.routes.test.ts index 1fc3a83696..f570f070d3 100644 --- a/src/utils/status.routes.test.ts +++ b/src/utils/status.routes.test.ts @@ -155,6 +155,20 @@ test('OpenRouter route displays OPENAI_API_BASE when it selected the route', asy ) }) +test('blank OPENAI_BASE_URL falls back to OPENAI_API_BASE for route display', async () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = ' ' + process.env.OPENAI_API_BASE = ' https://openrouter.ai/api/v1 ' + process.env.OPENAI_MODEL = 'anthropic/claude-sonnet-4.5' + process.env.OPENROUTER_API_KEY = 'sk-or-test-key' + + const properties = await buildPropertiesWithRealProvider() + expect(findValue(properties, 'Provider route')).toBe('OpenRouter') + expect(findValue(properties, 'OpenAI base URL')).toBe( + 'https://openrouter.ai/api/v1', + ) +}) + test('Groq route shows its route label instead of OpenAI-compatible', async () => { process.env.CLAUDE_CODE_USE_OPENAI = '1' process.env.OPENAI_BASE_URL = 'https://api.groq.com/openai/v1' @@ -171,6 +185,18 @@ test('Groq route shows its route label instead of OpenAI-compatible', async () = ).toBe(true) }) +test('route-specific credential values are redacted from displayed fields', async () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://api.groq.com/openai/v1' + process.env.OPENAI_MODEL = 'gsk-route-secret-value-123' + process.env.GROQ_API_KEY = 'gsk-route-secret-value-123' + + const properties = await buildPropertiesWithRealProvider() + const serialized = JSON.stringify(properties) + expect(serialized).not.toContain('gsk-route-secret-value-123') + expect(findValue(properties, 'Model')).toBe('gsk...123') +}) + test('env-only Fireworks route displays descriptor defaults', async () => { process.env.FIREWORKS_API_KEY = 'fw-test-key' diff --git a/src/utils/status.tsx b/src/utils/status.tsx index e98605a940..e48091c307 100644 --- a/src/utils/status.tsx +++ b/src/utils/status.tsx @@ -123,6 +123,34 @@ function pushRedactedProperty( }); } +function readTrimmedEnvValue(name: string): string | undefined { + return process.env[name]?.trim() || undefined; +} + +/** + * Builds a process env copy whose OpenAI base URL aliases follow the same + * trimming and fallback rules used by the status display fields. + */ +function buildRouteResolutionEnv(): NodeJS.ProcessEnv { + const env = { ...process.env }; + const openAIBaseUrl = readTrimmedEnvValue('OPENAI_BASE_URL'); + const openAIApiBase = readTrimmedEnvValue('OPENAI_API_BASE'); + + if (openAIBaseUrl) { + env.OPENAI_BASE_URL = openAIBaseUrl; + } else { + delete env.OPENAI_BASE_URL; + } + + if (openAIApiBase) { + env.OPENAI_API_BASE = openAIApiBase; + } else { + delete env.OPENAI_API_BASE; + } + + return env; +} + /** * Resolves the active provider route from the environment. Returns the route id * when it identifies a concrete gateway/vendor (e.g. "openrouter", "groq", @@ -130,7 +158,7 @@ function pushRedactedProperty( * first-party "anthropic" route, or when route resolution is unavailable. */ function resolveDisplayRouteId(): string | null { - const routeId = resolveActiveRouteIdFromEnv(process.env); + const routeId = resolveActiveRouteIdFromEnv(buildRouteResolutionEnv()); if (!routeId || routeId === 'custom' || routeId === 'anthropic') { return null; } @@ -152,21 +180,43 @@ function buildRouteCredentialSummary(routeId: string): string | null { return configured.map(name => `${name} configured`).join(', '); } +/** + * Collects route-specific credential env values so status fields redact secrets + * from descriptor-backed providers, not only legacy provider buckets. + */ +function buildRouteSecretSource(routeId: string | null): SecretValueSource { + if (!routeId) { + return {}; + } + + return Object.fromEntries( + getRouteCredentialEnvVars(routeId).map(name => [name, process.env[name]]), + ); +} + +/** + * Returns the active OpenAI-compatible base URL shown in status, including the + * legacy OPENAI_API_BASE alias and descriptor defaults for env-only routes. + */ function getOpenAICompatibleBaseUrlForStatus( routeId: string | null, ): string | undefined { return ( - process.env.OPENAI_BASE_URL?.trim() || - process.env.OPENAI_API_BASE?.trim() || + readTrimmedEnvValue('OPENAI_BASE_URL') || + readTrimmedEnvValue('OPENAI_API_BASE') || (routeId ? getRouteDefaultBaseUrl(routeId) : undefined) ); } +/** + * Returns the active OpenAI-compatible model shown in status, falling back to + * descriptor defaults for routes selected only by credential env vars. + */ function getOpenAICompatibleModelForStatus( routeId: string | null, ): string | undefined { return ( - process.env.OPENAI_MODEL?.trim() || + readTrimmedEnvValue('OPENAI_MODEL') || (routeId ? getRouteDefaultModel(routeId) : undefined) ); } @@ -485,6 +535,10 @@ export function buildAPIProviderProperties(): Property[] { const transportLabel = routeId ? getRouteProviderTypeLabel(routeId) : null; + const redactionSource: SecretValueSource = { + ...secretSource, + ...buildRouteSecretSource(routeId), + }; if (transportLabel) { properties.push({ label: 'Transport', @@ -495,7 +549,7 @@ export function buildAPIProviderProperties(): Property[] { properties, metadata.baseUrlLabel, getOpenAICompatibleBaseUrlForStatus(routeId), - secretSource, + redactionSource, ); const openaiModel = getOpenAICompatibleModelForStatus(routeId); if (openaiModel) { @@ -507,7 +561,7 @@ export function buildAPIProviderProperties(): Property[] { properties, 'Model', modelDisplay, - secretSource, + redactionSource, ); } if (routeId) { From c814c5a2f05e079cd2c05c83e4b009e7c8565828 Mon Sep 17 00:00:00 2001 From: chioarub Date: Tue, 16 Jun 2026 15:52:56 +0300 Subject: [PATCH 04/15] fix(status): cover route secret redaction review --- src/utils/providerProfile.test.ts | 31 +++++++++++++++++++++++++++++++ src/utils/providerSecrets.ts | 5 +++++ 2 files changed, 36 insertions(+) diff --git a/src/utils/providerProfile.test.ts b/src/utils/providerProfile.test.ts index 3f5d3fc1af..3d4395ba9d 100644 --- a/src/utils/providerProfile.test.ts +++ b/src/utils/providerProfile.test.ts @@ -1561,6 +1561,37 @@ test('redactSecretValueForDisplay masks poisoned display fields that equal confi ) }) +test('redactSecretValueForDisplay collects common secret env suffixes', () => { + const secretEnvCases = [ + ['ROUTE_API_KEY', 'route-api-secret-value'], + ['ROUTE_AUTH_HEADER_VALUE', 'route-auth-header-secret'], + ['SERVICE_PASSWORD', 'database-password-secret'], + ['SERVICE_SECRET', 'service-secret-value'], + ['AWS_SECRET_ACCESS_KEY', 'aws-secret-access-value'], + ['OAUTH_SECRET_KEY', 'oauth-secret-key-value'], + ['GITHUB_TOKEN', 'github-token-secret'], + ] as const + + for (const [key, value] of secretEnvCases) { + const source = { [key]: value } + + assert.equal( + redactSecretValueForDisplay(value, source), + maskSecretForDisplay(value), + ) + assert.equal(sanitizeProviderConfigValue(value, source), undefined) + } + + assert.equal( + redactSecretValueForDisplay('gpt-4o', { OPENAI_MODEL: 'gpt-4o' }), + 'gpt-4o', + ) + assert.equal( + sanitizeProviderConfigValue('gpt-4o', { OPENAI_MODEL: 'gpt-4o' }), + 'gpt-4o', + ) +}) + test('sanitizeProviderConfigValue drops secret-like poisoned values', () => { const apiKey = 'sk-secret-12345678' diff --git a/src/utils/providerSecrets.ts b/src/utils/providerSecrets.ts index 5f6c9180a7..a559e53e40 100644 --- a/src/utils/providerSecrets.ts +++ b/src/utils/providerSecrets.ts @@ -35,11 +35,16 @@ function looksLikeSecretValue(value: string): boolean { return false } +// Redaction sources may be full process env objects, so only collect values +// from keys that look credential-bearing. The suffix list intentionally covers +// route API keys plus common auth tokens and cloud/database secrets; masking too +// much is safer than leaking a credential in status or provider displays. function isSecretEnvKey(key: string): boolean { return ( SECRET_ENV_KEYS.includes(key as (typeof SECRET_ENV_KEYS)[number]) || key.endsWith('_API_KEY') || key.endsWith('_AUTH_HEADER_VALUE') || + key.endsWith('_PASSWORD') || key.endsWith('_SECRET') || key.endsWith('_SECRET_ACCESS_KEY') || key.endsWith('_SECRET_KEY') || From 0031b31b218bff6126f29035240064a605301aa8 Mon Sep 17 00:00:00 2001 From: chioarub Date: Tue, 16 Jun 2026 16:01:54 +0300 Subject: [PATCH 05/15] fix(status): avoid duplicate route resolution --- src/utils/status.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/utils/status.tsx b/src/utils/status.tsx index e48091c307..8c97519716 100644 --- a/src/utils/status.tsx +++ b/src/utils/status.tsx @@ -443,14 +443,14 @@ export function buildAPIProviderProperties(): Property[] { BNKR_API_KEY: process.env.BNKR_API_KEY, MISTRAL_API_KEY: process.env.MISTRAL_API_KEY }; + const routeId = + apiProvider === 'openai' ? resolveDisplayRouteId() : null; if (apiProvider !== 'firstParty') { // The legacy "openai" bucket collapses many concrete providers (OpenRouter, // Groq, Ollama, Fireworks, etc.) into a single "OpenAI-compatible" label. // When route resolution identifies a concrete provider, surface its real // label instead. Dedicated buckets (nvidia-nim, minimax, codex, github, // xai, ...) already have accurate labels and are left untouched. - const routeId = - apiProvider === 'openai' ? resolveDisplayRouteId() : null; const routeLabel = routeId ? getRouteLabel(routeId) : null; const providerLabel = routeLabel ?? API_PROVIDER_LABELS[apiProvider]; properties.push({ @@ -530,8 +530,6 @@ export function buildAPIProviderProperties(): Property[] { } else if (apiProvider in OPENAI_COMPATIBLE_STATUS_METADATA) { const metadata = OPENAI_COMPATIBLE_STATUS_METADATA[apiProvider]!; - const routeId = - apiProvider === 'openai' ? resolveDisplayRouteId() : null; const transportLabel = routeId ? getRouteProviderTypeLabel(routeId) : null; From dff893a34812aa4edfd65e03f006f4c2736079c1 Mon Sep 17 00:00:00 2001 From: chioarub Date: Tue, 16 Jun 2026 22:36:06 +0300 Subject: [PATCH 06/15] fix(status): redact base URL query credentials --- src/utils/status.routes.test.ts | 14 ++++++++ src/utils/status.tsx | 64 +++++++++++++++++++++++---------- 2 files changed, 59 insertions(+), 19 deletions(-) diff --git a/src/utils/status.routes.test.ts b/src/utils/status.routes.test.ts index f570f070d3..7c6ff66567 100644 --- a/src/utils/status.routes.test.ts +++ b/src/utils/status.routes.test.ts @@ -155,6 +155,20 @@ test('OpenRouter route displays OPENAI_API_BASE when it selected the route', asy ) }) +test('OPENAI_API_BASE query credentials are redacted from status display', async () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_API_BASE = + 'https://openrouter.ai/api/v1?api_key=sk-or-query-secret&timeout=30' + process.env.OPENAI_MODEL = 'anthropic/claude-sonnet-4.5' + process.env.OPENROUTER_API_KEY = 'sk-or-test-key' + + const properties = await buildPropertiesWithRealProvider() + expect(findValue(properties, 'OpenAI base URL')).toBe( + 'https://openrouter.ai/api/v1?api_key=redacted&timeout=30', + ) + expect(JSON.stringify(properties)).not.toContain('sk-or-query-secret') +}) + test('blank OPENAI_BASE_URL falls back to OPENAI_API_BASE for route display', async () => { process.env.CLAUDE_CODE_USE_OPENAI = '1' process.env.OPENAI_BASE_URL = ' ' diff --git a/src/utils/status.tsx b/src/utils/status.tsx index 8c97519716..3bef3b2204 100644 --- a/src/utils/status.tsx +++ b/src/utils/status.tsx @@ -22,6 +22,7 @@ import { getEnabledSettingSources, getSettingSourceDisplayNameCapitalized } from import { getManagedFileSettingsPresence, getPolicySettingsOrigin, getSettingsForSource } from './settings/settings.js'; import type { ThemeName } from './theme.js'; import { redactSecretValueForDisplay, type SecretValueSource } from './providerSecrets.js'; +import { redactUrlForDisplay } from './urlRedaction.js'; import { getRouteCredentialEnvVars, getRouteDefaultBaseUrl, @@ -123,6 +124,23 @@ function pushRedactedProperty( }); } +function pushRedactedUrlProperty( + properties: Property[], + label: string, + value: string | undefined, + secretSource: SecretValueSource, +): void { + if (!value) { + return; + } + + const secretRedacted = redactSecretValueForDisplay(value, secretSource) ?? value; + properties.push({ + label, + value: redactUrlForDisplay(secretRedacted) + }); +} + function readTrimmedEnvValue(name: string): string | undefined { return process.env[name]?.trim() || undefined; } @@ -461,18 +479,22 @@ export function buildAPIProviderProperties(): Property[] { if (apiProvider === 'firstParty') { const anthropicBaseUrl = process.env.ANTHROPIC_BASE_URL; if (anthropicBaseUrl) { - properties.push({ - label: 'Anthropic base URL', - value: anthropicBaseUrl - }); + pushRedactedUrlProperty( + properties, + 'Anthropic base URL', + anthropicBaseUrl, + secretSource, + ); } } else if (apiProvider === 'bedrock') { const bedrockBaseUrl = process.env.BEDROCK_BASE_URL; if (bedrockBaseUrl) { - properties.push({ - label: 'Bedrock base URL', - value: bedrockBaseUrl - }); + pushRedactedUrlProperty( + properties, + 'Bedrock base URL', + bedrockBaseUrl, + secretSource, + ); } properties.push({ label: 'AWS region', @@ -486,10 +508,12 @@ export function buildAPIProviderProperties(): Property[] { } else if (apiProvider === 'vertex') { const vertexBaseUrl = process.env.VERTEX_BASE_URL; if (vertexBaseUrl) { - properties.push({ - label: 'Vertex base URL', - value: vertexBaseUrl - }); + pushRedactedUrlProperty( + properties, + 'Vertex base URL', + vertexBaseUrl, + secretSource, + ); } const gcpProject = process.env.ANTHROPIC_VERTEX_PROJECT_ID; if (gcpProject) { @@ -510,10 +534,12 @@ export function buildAPIProviderProperties(): Property[] { } else if (apiProvider === 'foundry') { const foundryBaseUrl = process.env.ANTHROPIC_FOUNDRY_BASE_URL; if (foundryBaseUrl) { - properties.push({ - label: 'Microsoft Foundry base URL', - value: foundryBaseUrl - }); + pushRedactedUrlProperty( + properties, + 'Microsoft Foundry base URL', + foundryBaseUrl, + secretSource, + ); } const foundryResource = process.env.ANTHROPIC_FOUNDRY_RESOURCE; if (foundryResource) { @@ -543,7 +569,7 @@ export function buildAPIProviderProperties(): Property[] { value: transportLabel, }); } - pushRedactedProperty( + pushRedactedUrlProperty( properties, metadata.baseUrlLabel, getOpenAICompatibleBaseUrlForStatus(routeId), @@ -573,12 +599,12 @@ export function buildAPIProviderProperties(): Property[] { } } else if (apiProvider === 'gemini') { const geminiBaseUrl = process.env.GEMINI_BASE_URL; - pushRedactedProperty(properties, 'Gemini base URL', geminiBaseUrl, secretSource); + pushRedactedUrlProperty(properties, 'Gemini base URL', geminiBaseUrl, secretSource); const geminiModel = process.env.GEMINI_MODEL; pushRedactedProperty(properties, 'Model', geminiModel, secretSource); } else if (apiProvider === 'mistral') { const mistralBaseUrl = process.env.MISTRAL_BASE_URL; - pushRedactedProperty(properties, 'Mistral base URL', mistralBaseUrl, secretSource); + pushRedactedUrlProperty(properties, 'Mistral base URL', mistralBaseUrl, secretSource); const mistralModel = process.env.MISTRAL_MODEL; pushRedactedProperty(properties, 'Model', mistralModel, secretSource); } From cd12cef5e4fa0bd772c5c5c30c41019965f1f58a Mon Sep 17 00:00:00 2001 From: chioarub Date: Tue, 16 Jun 2026 22:56:27 +0300 Subject: [PATCH 07/15] fix(status): harden status URL secret redaction --- src/utils/status.routes.test.ts | 74 ++++++++++++++++++++++----------- src/utils/status.tsx | 33 +++++++++++++-- 2 files changed, 80 insertions(+), 27 deletions(-) diff --git a/src/utils/status.routes.test.ts b/src/utils/status.routes.test.ts index 7c6ff66567..b81e5d2919 100644 --- a/src/utils/status.routes.test.ts +++ b/src/utils/status.routes.test.ts @@ -7,28 +7,38 @@ import { const ORIGINAL_ENV = { ...process.env } -// Env vars that influence provider/route resolution. Cleared before every test -// so assertions are deterministic regardless of the host shell environment. -const PROVIDER_ENV_VARS = [ - 'CLAUDE_CODE_USE_OPENAI', - 'CLAUDE_CODE_USE_GEMINI', - 'CLAUDE_CODE_USE_GITHUB', - 'CLAUDE_CODE_USE_MISTRAL', - 'CLAUDE_CODE_USE_BEDROCK', - 'CLAUDE_CODE_USE_VERTEX', - 'CLAUDE_CODE_USE_FOUNDRY', - 'CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED', - 'OPENAI_BASE_URL', - 'OPENAI_API_BASE', - 'OPENAI_API_KEY', - 'OPENAI_MODEL', - 'OPENROUTER_API_KEY', - 'GROQ_API_KEY', - 'FIREWORKS_API_KEY', - 'GEMINI_BASE_URL', - 'GEMINI_MODEL', - 'GEMINI_API_KEY', - 'NVIDIA_NIM', +// Provider env vars influence route resolution. Clear by prefix before every +// test so assertions stay deterministic regardless of the developer shell. +const PROVIDER_ENV_PREFIXES = [ + 'ANTHROPIC_', + 'ATLAS_', + 'AWS_', + 'BEDROCK_', + 'BNKR_', + 'CLAUDE_CODE_PROVIDER_PROFILE_', + 'CLAUDE_CODE_USE_', + 'DASHSCOPE_', + 'DEEPSEEK_', + 'FIREWORKS_', + 'GEMINI_', + 'GITHUB_', + 'GOOGLE_', + 'GROQ_', + 'MINIMAX_', + 'MIMO_', + 'MISTRAL_', + 'MOONSHOT_', + 'NEARAI_', + 'NVIDIA_', + 'OLLAMA_', + 'OPENAI_', + 'OPENGATEWAY_', + 'OPENROUTER_', + 'VENICE_', + 'VERTEX_', + 'XAI_', + 'XIAOMI_MIMO_', + 'ZAI_', ] function restoreEnv(): void { @@ -48,8 +58,10 @@ function restoreEnv(): void { } function clearProviderEnv(): void { - for (const key of PROVIDER_ENV_VARS) { - delete process.env[key] + for (const key of Object.keys(process.env)) { + if (PROVIDER_ENV_PREFIXES.some(prefix => key.startsWith(prefix))) { + delete process.env[key] + } } } @@ -169,6 +181,20 @@ test('OPENAI_API_BASE query credentials are redacted from status display', async expect(JSON.stringify(properties)).not.toContain('sk-or-query-secret') }) +test('configured route secrets inside base URL query values are redacted', async () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_API_BASE = + 'https://openrouter.ai/api/v1?credential=sk-or-query-secret&timeout=30' + process.env.OPENAI_MODEL = 'anthropic/claude-sonnet-4.5' + process.env.OPENROUTER_API_KEY = 'sk-or-query-secret' + + const properties = await buildPropertiesWithRealProvider() + expect(findValue(properties, 'OpenAI base URL')).toBe( + 'https://openrouter.ai/api/v1?credential=redacted&timeout=30', + ) + expect(JSON.stringify(properties)).not.toContain('sk-or-query-secret') +}) + test('blank OPENAI_BASE_URL falls back to OPENAI_API_BASE for route display', async () => { process.env.CLAUDE_CODE_USE_OPENAI = '1' process.env.OPENAI_BASE_URL = ' ' diff --git a/src/utils/status.tsx b/src/utils/status.tsx index 3bef3b2204..159323637c 100644 --- a/src/utils/status.tsx +++ b/src/utils/status.tsx @@ -21,7 +21,7 @@ import { getSettingsWithAllErrors } from './settings/allErrors.js'; import { getEnabledSettingSources, getSettingSourceDisplayNameCapitalized } from './settings/constants.js'; import { getManagedFileSettingsPresence, getPolicySettingsOrigin, getSettingsForSource } from './settings/settings.js'; import type { ThemeName } from './theme.js'; -import { redactSecretValueForDisplay, type SecretValueSource } from './providerSecrets.js'; +import { redactSecretValueForDisplay, sanitizeApiKey, type SecretValueSource } from './providerSecrets.js'; import { redactUrlForDisplay } from './urlRedaction.js'; import { getRouteCredentialEnvVars, @@ -124,6 +124,26 @@ function pushRedactedProperty( }); } +function redactConfiguredSecretSubstrings( + value: string, + secretSource: SecretValueSource, +): string { + let redacted = value; + const secrets = Array.from( + new Set( + Object.values(secretSource) + .map(secret => sanitizeApiKey(secret)) + .filter((secret): secret is string => Boolean(secret)), + ), + ).sort((a, b) => b.length - a.length); + + for (const secret of secrets) { + redacted = redacted.split(secret).join('redacted'); + } + + return redacted; +} + function pushRedactedUrlProperty( properties: Property[], label: string, @@ -134,10 +154,17 @@ function pushRedactedUrlProperty( return; } - const secretRedacted = redactSecretValueForDisplay(value, secretSource) ?? value; + const urlRedacted = redactUrlForDisplay(value); + const secretSubstringsRedacted = redactConfiguredSecretSubstrings( + urlRedacted, + secretSource, + ); + const displayValue = + redactSecretValueForDisplay(secretSubstringsRedacted, secretSource) ?? + secretSubstringsRedacted; properties.push({ label, - value: redactUrlForDisplay(secretRedacted) + value: displayValue }); } From b39a0b63b3462947fe43367d3587e7c2a3342601 Mon Sep 17 00:00:00 2001 From: chioarub Date: Wed, 17 Jun 2026 02:16:01 +0300 Subject: [PATCH 08/15] fix(status): redact route secrets in status text --- src/utils/status.routes.test.ts | 26 ++++++++++++++++++++++++++ src/utils/status.tsx | 3 ++- src/utils/urlRedaction.test.ts | 10 +++++++++- src/utils/urlRedaction.ts | 4 +++- 4 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/utils/status.routes.test.ts b/src/utils/status.routes.test.ts index b81e5d2919..7c0d6d4004 100644 --- a/src/utils/status.routes.test.ts +++ b/src/utils/status.routes.test.ts @@ -181,6 +181,20 @@ test('OPENAI_API_BASE query credentials are redacted from status display', async expect(JSON.stringify(properties)).not.toContain('sk-or-query-secret') }) +test('OPENAI_API_BASE fragments are removed from status display', async () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_API_BASE = + 'https://openrouter.ai/api/v1?api_key=querysecret#access_token=fragsecret' + process.env.OPENAI_MODEL = 'anthropic/claude-sonnet-4.5' + process.env.OPENROUTER_API_KEY = 'sk-or-test-key' + + const properties = await buildPropertiesWithRealProvider() + expect(findValue(properties, 'OpenAI base URL')).toBe( + 'https://openrouter.ai/api/v1?api_key=redacted', + ) + expect(JSON.stringify(properties)).not.toContain('fragsecret') +}) + test('configured route secrets inside base URL query values are redacted', async () => { process.env.CLAUDE_CODE_USE_OPENAI = '1' process.env.OPENAI_API_BASE = @@ -237,6 +251,18 @@ test('route-specific credential values are redacted from displayed fields', asyn expect(findValue(properties, 'Model')).toBe('gsk...123') }) +test('route-specific credential substrings are redacted from displayed model fields', async () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://openrouter.ai/api/v1' + process.env.OPENAI_MODEL = 'prefix-sk-or-SECRET-VALUE-123-suffix' + process.env.OPENROUTER_API_KEY = 'sk-or-SECRET-VALUE-123' + + const properties = await buildPropertiesWithRealProvider() + const serialized = JSON.stringify(properties) + expect(serialized).not.toContain('sk-or-SECRET-VALUE-123') + expect(findValue(properties, 'Model')).toBe('prefix-redacted-suffix') +}) + test('env-only Fireworks route displays descriptor defaults', async () => { process.env.FIREWORKS_API_KEY = 'fw-test-key' diff --git a/src/utils/status.tsx b/src/utils/status.tsx index 159323637c..cccf2c607d 100644 --- a/src/utils/status.tsx +++ b/src/utils/status.tsx @@ -118,9 +118,10 @@ function pushRedactedProperty( return; } + const secretRedacted = redactSecretValueForDisplay(value, secretSource) ?? value; properties.push({ label, - value: redactSecretValueForDisplay(value, secretSource) ?? value + value: redactConfiguredSecretSubstrings(secretRedacted, secretSource) }); } diff --git a/src/utils/urlRedaction.test.ts b/src/utils/urlRedaction.test.ts index 785c795568..b0bd0e1878 100644 --- a/src/utils/urlRedaction.test.ts +++ b/src/utils/urlRedaction.test.ts @@ -26,6 +26,14 @@ describe('redactUrlForDisplay', () => { ) }) + test('drops fragments before displaying URLs', () => { + const redacted = redactUrlForDisplay( + 'https://example.com/v1?api_key=secret#access_token=fragment-secret', + ) + + expect(redacted).toBe('https://example.com/v1?api_key=redacted') + }) + test('falls back to regex redaction for malformed URLs', () => { const redacted = redactUrlForDisplay( '//user:pass@localhost:11434?token=abc&mode=test', @@ -89,4 +97,4 @@ describe('shouldRedactUrlQueryParam', () => { expect(shouldRedactUrlQueryParam(name)).toBe(false) } }) -}) \ No newline at end of file +}) diff --git a/src/utils/urlRedaction.ts b/src/utils/urlRedaction.ts index 66d92881e3..50b85abda7 100644 --- a/src/utils/urlRedaction.ts +++ b/src/utils/urlRedaction.ts @@ -42,6 +42,7 @@ export function redactUrlForDisplay(rawUrl: string): string { } } + parsed.hash = '' return parsed.toString() } catch { return rawUrl @@ -50,5 +51,6 @@ export function redactUrlForDisplay(rawUrl: string): string { /([?&](?:token|access_token|refresh_token|api_key|apikey|key|password|passwd|pwd|auth|authorization|signature|sig|secret)=)[^&#]*/gi, '$1redacted', ) + .replace(/#.*$/, '') } -} \ No newline at end of file +} From 5c0e25694bb0b00685ee35834455f580a1e8f9b4 Mon Sep 17 00:00:00 2001 From: chioarub Date: Wed, 17 Jun 2026 02:27:30 +0300 Subject: [PATCH 09/15] test(status): cover fallback URL fragment redaction --- src/utils/urlRedaction.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/utils/urlRedaction.test.ts b/src/utils/urlRedaction.test.ts index b0bd0e1878..045c2767c3 100644 --- a/src/utils/urlRedaction.test.ts +++ b/src/utils/urlRedaction.test.ts @@ -42,6 +42,14 @@ describe('redactUrlForDisplay', () => { expect(redacted).toBe('//redacted@localhost:11434?token=redacted&mode=test') }) + test('fallback redaction also drops fragments for malformed URLs', () => { + const redacted = redactUrlForDisplay( + '//user:pass@localhost:11434?token=abc#access_token=fragment-secret', + ) + + expect(redacted).toBe('//redacted@localhost:11434?token=redacted') + }) + test('keeps non-sensitive URLs unchanged', () => { const url = 'http://localhost:11434/v1?model=llama3.1:8b' expect(redactUrlForDisplay(url)).toBe(url) From 7525527257841c5ece763aba6e148af6f8cf7bda Mon Sep 17 00:00:00 2001 From: chioarub Date: Wed, 17 Jun 2026 08:55:06 +0300 Subject: [PATCH 10/15] test(status): isolate route status provider imports --- src/utils/status.routes.test.ts | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/src/utils/status.routes.test.ts b/src/utils/status.routes.test.ts index 7c0d6d4004..73c87c2972 100644 --- a/src/utils/status.routes.test.ts +++ b/src/utils/status.routes.test.ts @@ -65,25 +65,25 @@ function clearProviderEnv(): void { } } -async function buildPropertiesWithProvider(provider: string): Promise< - Array<{ label?: string; value: unknown }> -> { +async function restoreProviderModule(): Promise { mock.restore() + const nonce = `${Date.now()}-${Math.random()}` + const actualProviders = await import(`./model/providers.js?restore=${nonce}`) mock.module('./model/providers.js', () => ({ - getAPIProvider: () => provider, - getAPIProviderForStatsig: () => provider, - isFirstPartyAnthropicBaseUrl: () => true, - isGithubNativeAnthropicMode: () => false, + ...actualProviders, })) - const nonce = `${Date.now()}-${Math.random()}` - const { buildAPIProviderProperties } = await import(`./status.js?ts=${nonce}`) - return buildAPIProviderProperties() +} + +async function buildPropertiesWithProvider( + _provider: string, +): Promise> { + return buildPropertiesWithRealProvider() } async function buildPropertiesWithRealProvider(): Promise< Array<{ label?: string; value: unknown }> > { - mock.restore() + await restoreProviderModule() const nonce = `${Date.now()}-${Math.random()}` const { buildAPIProviderProperties } = await import(`./status.js?ts=${nonce}`) return buildAPIProviderProperties() @@ -98,12 +98,13 @@ function findValue( beforeEach(async () => { await acquireSharedMutationLock('utils/status.routes.test.ts') + await restoreProviderModule() clearProviderEnv() }) -afterEach(() => { +afterEach(async () => { try { - mock.restore() + await restoreProviderModule() restoreEnv() } finally { releaseSharedMutationLock() From 539d7b0df559622d61063f4c5e0a2a32eb1f8f62 Mon Sep 17 00:00:00 2001 From: chioarub Date: Thu, 18 Jun 2026 20:27:37 +0300 Subject: [PATCH 11/15] fix(status): redact encoded route secrets --- src/utils/status.routes.test.ts | 16 ++++++++++++++++ src/utils/status.tsx | 13 ++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/utils/status.routes.test.ts b/src/utils/status.routes.test.ts index 73c87c2972..597b9b13cd 100644 --- a/src/utils/status.routes.test.ts +++ b/src/utils/status.routes.test.ts @@ -210,6 +210,22 @@ test('configured route secrets inside base URL query values are redacted', async expect(JSON.stringify(properties)).not.toContain('sk-or-query-secret') }) +test('URL-encoded configured route secrets inside base URL query values are redacted', async () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_API_BASE = + 'https://openrouter.ai/api/v1?credential=abc%2Fdef%2Bghi%3D&timeout=30' + process.env.OPENAI_MODEL = 'anthropic/claude-sonnet-4.5' + process.env.OPENROUTER_API_KEY = 'abc/def+ghi=' + + const properties = await buildPropertiesWithRealProvider() + expect(findValue(properties, 'OpenAI base URL')).toBe( + 'https://openrouter.ai/api/v1?credential=redacted&timeout=30', + ) + const serialized = JSON.stringify(properties) + expect(serialized).not.toContain('abc/def+ghi=') + expect(serialized).not.toContain('abc%2Fdef%2Bghi%3D') +}) + test('blank OPENAI_BASE_URL falls back to OPENAI_API_BASE for route display', async () => { process.env.CLAUDE_CODE_USE_OPENAI = '1' process.env.OPENAI_BASE_URL = ' ' diff --git a/src/utils/status.tsx b/src/utils/status.tsx index 44c2d9c893..44ac1abba1 100644 --- a/src/utils/status.tsx +++ b/src/utils/status.tsx @@ -139,7 +139,18 @@ function redactConfiguredSecretSubstrings( ).sort((a, b) => b.length - a.length); for (const secret of secrets) { - redacted = redacted.split(secret).join('redacted'); + const encodedSecret = encodeURIComponent(secret); + const encodedSecretWithLowercaseEscapes = encodedSecret.replace( + /%[0-9A-F]{2}/g, + match => match.toLowerCase(), + ); + for (const variant of new Set([ + secret, + encodedSecret, + encodedSecretWithLowercaseEscapes, + ])) { + redacted = redacted.split(variant).join('redacted'); + } } return redacted; From ce8aaf703ec57a6fbcf3571c1cf612f022c6a7d8 Mon Sep 17 00:00:00 2001 From: chioarub Date: Thu, 18 Jun 2026 22:01:34 +0300 Subject: [PATCH 12/15] fix(status): redact encoded query secrets safely --- src/utils/status.routes.test.ts | 17 +++++++ src/utils/status.test.ts | 16 +++++++ src/utils/status.tsx | 80 +++++++++++++++++++++++++++++---- 3 files changed, 104 insertions(+), 9 deletions(-) diff --git a/src/utils/status.routes.test.ts b/src/utils/status.routes.test.ts index 597b9b13cd..a014ffd738 100644 --- a/src/utils/status.routes.test.ts +++ b/src/utils/status.routes.test.ts @@ -226,6 +226,23 @@ test('URL-encoded configured route secrets inside base URL query values are reda expect(serialized).not.toContain('abc%2Fdef%2Bghi%3D') }) +test('form-encoded configured route secrets inside base URL query values are redacted', async () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_API_BASE = + 'https://openrouter.ai/api/v1?auth=Bearer+abc&credential=abc%27def&timeout=30' + process.env.OPENAI_MODEL = 'anthropic/claude-sonnet-4.5' + process.env.OPENAI_AUTH_HEADER_VALUE = 'Bearer abc' + process.env.OPENROUTER_API_KEY = "abc'def" + + const properties = await buildPropertiesWithRealProvider() + expect(findValue(properties, 'OpenAI base URL')).toBe( + 'https://openrouter.ai/api/v1?auth=redacted&credential=redacted&timeout=30', + ) + const serialized = JSON.stringify(properties) + expect(serialized).not.toContain('Bearer+abc') + expect(serialized).not.toContain('abc%27def') +}) + test('blank OPENAI_BASE_URL falls back to OPENAI_API_BASE for route display', async () => { process.env.CLAUDE_CODE_USE_OPENAI = '1' process.env.OPENAI_BASE_URL = ' ' diff --git a/src/utils/status.test.ts b/src/utils/status.test.ts index ed8b4d2860..b6db5d8551 100644 --- a/src/utils/status.test.ts +++ b/src/utils/status.test.ts @@ -153,6 +153,22 @@ test('buildAPIProviderProperties redacts token-bearing OpenAI-compatible base UR expect(serialized).not.toContain('fragment-leak') }) +test('buildAPIProviderProperties does not substring-redact short configured secrets', async () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1?credential=1' + process.env.OPENAI_MODEL = 'gpt-4.1' + process.env.OPENAI_API_KEY = '1' + + const properties = await readAPIProviderProperties('openai') + + expect(properties.find(property => property.label === 'OpenAI base URL')?.value).toBe( + 'https://api.openai.com/v1?credential=redacted', + ) + expect(properties.find(property => property.label === 'Model')?.value).toBe( + 'gpt-4.1', + ) +}) + test('buildAPIProviderProperties redacts token-bearing Gemini base URLs', async () => { process.env.CLAUDE_CODE_USE_GEMINI = '1' process.env.GEMINI_BASE_URL = diff --git a/src/utils/status.tsx b/src/utils/status.tsx index 44ac1abba1..2f6bfc6ce1 100644 --- a/src/utils/status.tsx +++ b/src/utils/status.tsx @@ -84,6 +84,8 @@ const OPENAI_COMPATIBLE_STATUS_METADATA: Partial< }, }; +const MIN_CONFIGURED_SECRET_SUBSTRING_LENGTH = 9; + function formatOpenAICompatibleModelDisplay( model: string, resolveModelMetadata = false, @@ -125,18 +127,37 @@ function pushRedactedProperty( }); } -function redactConfiguredSecretSubstrings( - value: string, - secretSource: SecretValueSource, -): string { - let redacted = value; - const secrets = Array.from( +function getConfiguredSecretValues(secretSource: SecretValueSource): string[] { + return Array.from( new Set( Object.values(secretSource) .map(secret => sanitizeApiKey(secret)?.trim()) .filter((secret): secret is string => Boolean(secret)), ), - ).sort((a, b) => b.length - a.length); + ); +} + +function getConfiguredSecretSubstringSource( + secretSource: SecretValueSource, +): SecretValueSource { + const substringSource: SecretValueSource = {}; + for (const [key, value] of Object.entries(secretSource)) { + const secret = sanitizeApiKey(value)?.trim(); + if (secret && secret.length >= MIN_CONFIGURED_SECRET_SUBSTRING_LENGTH) { + substringSource[key] = value; + } + } + return substringSource; +} + +function redactConfiguredSecretSubstrings( + value: string, + secretSource: SecretValueSource, +): string { + let redacted = value; + const secrets = getConfiguredSecretValues(secretSource) + .filter(secret => secret.length >= MIN_CONFIGURED_SECRET_SUBSTRING_LENGTH) + .sort((a, b) => b.length - a.length); for (const secret of secrets) { const encodedSecret = encodeURIComponent(secret); @@ -156,6 +177,40 @@ function redactConfiguredSecretSubstrings( return redacted; } +function redactConfiguredSecretUrlQueryValues( + value: string, + secretSource: SecretValueSource, +): string { + const secrets = new Set(getConfiguredSecretValues(secretSource)); + if (secrets.size === 0) { + return value; + } + + try { + const parsed = new URL(value); + const redactedParams = new URLSearchParams(); + let changed = false; + + for (const [key, queryValue] of parsed.searchParams.entries()) { + if (secrets.has(queryValue)) { + redactedParams.append(key, 'redacted'); + changed = true; + } else { + redactedParams.append(key, queryValue); + } + } + + if (!changed) { + return value; + } + + parsed.search = redactedParams.toString(); + return parsed.toString(); + } catch { + return value; + } +} + function redactStatusTextForDisplay( value: string, secretSource: SecretValueSource, @@ -165,7 +220,10 @@ function redactStatusTextForDisplay( secretSource, ); return ( - redactSecretSubstringsForDisplay(configuredSecretRedacted, secretSource) ?? + redactSecretSubstringsForDisplay( + configuredSecretRedacted, + getConfiguredSecretSubstringSource(secretSource), + ) ?? configuredSecretRedacted ); } @@ -180,7 +238,11 @@ function pushRedactedUrlProperty( return; } - const urlRedacted = redactUrlForStatus(value); + const queryValueRedacted = redactConfiguredSecretUrlQueryValues( + value, + secretSource, + ); + const urlRedacted = redactUrlForStatus(queryValueRedacted); properties.push({ label, value: redactStatusTextForDisplay(urlRedacted, secretSource) From ebe8f5fa02ff9b559b7fab25979deeeef6ba4d89 Mon Sep 17 00:00:00 2001 From: chioarub Date: Fri, 19 Jun 2026 00:02:39 +0300 Subject: [PATCH 13/15] fix(status): redact nested encoded query secrets --- src/utils/status.routes.test.ts | 20 +++++++++++++++++ src/utils/status.tsx | 39 ++++++++++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/utils/status.routes.test.ts b/src/utils/status.routes.test.ts index a014ffd738..13c5d54241 100644 --- a/src/utils/status.routes.test.ts +++ b/src/utils/status.routes.test.ts @@ -243,6 +243,26 @@ test('form-encoded configured route secrets inside base URL query values are red expect(serialized).not.toContain('abc%27def') }) +test('double-encoded configured route secrets inside base URL query values are redacted', async () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_API_BASE = + 'https://openrouter.ai/api/v1?debug=abc%252FdefSecret987&header=Bearer%2520abc&form=Bearer%2Babc&plus=longplussecret%252Bvalue&timeout=30' + process.env.OPENAI_MODEL = 'anthropic/claude-sonnet-4.5' + process.env.OPENROUTER_API_KEY = 'abc/defSecret987' + process.env.OPENAI_AUTH_HEADER_VALUE = 'Bearer abc' + process.env.OPENAI_API_KEY = 'longplussecret+value' + + const properties = await buildPropertiesWithRealProvider() + expect(findValue(properties, 'OpenAI base URL')).toBe( + 'https://openrouter.ai/api/v1?debug=redacted&header=redacted&form=redacted&plus=redacted&timeout=30', + ) + const serialized = JSON.stringify(properties) + expect(serialized).not.toContain('abc%252FdefSecret987') + expect(serialized).not.toContain('Bearer%2520abc') + expect(serialized).not.toContain('Bearer%2Babc') + expect(serialized).not.toContain('longplussecret%252Bvalue') +}) + test('blank OPENAI_BASE_URL falls back to OPENAI_API_BASE for route display', async () => { process.env.CLAUDE_CODE_USE_OPENAI = '1' process.env.OPENAI_BASE_URL = ' ' diff --git a/src/utils/status.tsx b/src/utils/status.tsx index 2f6bfc6ce1..fcbca70fe9 100644 --- a/src/utils/status.tsx +++ b/src/utils/status.tsx @@ -85,6 +85,7 @@ const OPENAI_COMPATIBLE_STATUS_METADATA: Partial< }; const MIN_CONFIGURED_SECRET_SUBSTRING_LENGTH = 9; +const MAX_CONFIGURED_SECRET_QUERY_DECODE_DEPTH = 3; function formatOpenAICompatibleModelDisplay( model: string, @@ -177,6 +178,42 @@ function redactConfiguredSecretSubstrings( return redacted; } +function queryValueMatchesConfiguredSecret( + value: string, + secrets: ReadonlySet, +): boolean { + let decoded = value; + for (let depth = 0; depth < MAX_CONFIGURED_SECRET_QUERY_DECODE_DEPTH; depth++) { + if (secrets.has(decoded)) { + return true; + } + + const formDecoded = decoded.includes('+') + ? decoded.replace(/\+/g, ' ') + : decoded; + if (formDecoded !== decoded && secrets.has(formDecoded)) { + return true; + } + + let next: string; + try { + next = decodeURIComponent(decoded); + } catch { + return false; + } + + if (next === decoded) { + return false; + } + if (secrets.has(next)) { + return true; + } + decoded = next; + } + + return false; +} + function redactConfiguredSecretUrlQueryValues( value: string, secretSource: SecretValueSource, @@ -192,7 +229,7 @@ function redactConfiguredSecretUrlQueryValues( let changed = false; for (const [key, queryValue] of parsed.searchParams.entries()) { - if (secrets.has(queryValue)) { + if (queryValueMatchesConfiguredSecret(queryValue, secrets)) { redactedParams.append(key, 'redacted'); changed = true; } else { From 61f1da2060b6a27395fb1dec2d8ce184325b8157 Mon Sep 17 00:00:00 2001 From: chioarub Date: Fri, 19 Jun 2026 08:53:33 +0300 Subject: [PATCH 14/15] fix(status): redact encoded secret substrings --- src/utils/status.test.ts | 19 +++++++++++++++++ src/utils/status.tsx | 44 +++++++++++++++++++++++++++++----------- 2 files changed, 51 insertions(+), 12 deletions(-) diff --git a/src/utils/status.test.ts b/src/utils/status.test.ts index b6db5d8551..9ec08e3ded 100644 --- a/src/utils/status.test.ts +++ b/src/utils/status.test.ts @@ -169,6 +169,25 @@ test('buildAPIProviderProperties does not substring-redact short configured secr ) }) +test('buildAPIProviderProperties redacts double-encoded configured secrets outside URL query values', async () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = + 'https://api.openai.com/abc%252FdefSecret987/v1' + process.env.OPENAI_MODEL = 'gpt-4o abc%252FdefSecret987' + process.env.OPENAI_API_KEY = 'abc/defSecret987' + + const properties = await readAPIProviderProperties('openai') + + expect(properties.find(property => property.label === 'OpenAI base URL')?.value).toBe( + 'https://api.openai.com/redacted/v1', + ) + expect(properties.find(property => property.label === 'Model')?.value).toBe( + 'gpt-4o redacted', + ) + const serialized = JSON.stringify(properties) + expect(serialized).not.toContain('abc%252FdefSecret987') +}) + test('buildAPIProviderProperties redacts token-bearing Gemini base URLs', async () => { process.env.CLAUDE_CODE_USE_GEMINI = '1' process.env.GEMINI_BASE_URL = diff --git a/src/utils/status.tsx b/src/utils/status.tsx index fcbca70fe9..9e46ce18fb 100644 --- a/src/utils/status.tsx +++ b/src/utils/status.tsx @@ -85,7 +85,7 @@ const OPENAI_COMPATIBLE_STATUS_METADATA: Partial< }; const MIN_CONFIGURED_SECRET_SUBSTRING_LENGTH = 9; -const MAX_CONFIGURED_SECRET_QUERY_DECODE_DEPTH = 3; +const MAX_CONFIGURED_SECRET_ENCODING_DEPTH = 3; function formatOpenAICompatibleModelDisplay( model: string, @@ -151,6 +151,35 @@ function getConfiguredSecretSubstringSource( return substringSource; } +function addEncodedSecretVariants( + variants: Set, + value: string, +): void { + let encoded = value; + for (let depth = 0; depth < MAX_CONFIGURED_SECRET_ENCODING_DEPTH; depth++) { + encoded = encodeURIComponent(encoded); + variants.add(encoded); + variants.add( + encoded.replace(/%[0-9A-F]{2}/g, match => match.toLowerCase()), + ); + } +} + +function getConfiguredSecretSubstringVariants(secret: string): string[] { + const variants = new Set([secret]); + addEncodedSecretVariants(variants, secret); + + const formEncoded = secret.includes(' ') + ? secret.replace(/ /g, '+') + : secret; + if (formEncoded !== secret) { + variants.add(formEncoded); + addEncodedSecretVariants(variants, formEncoded); + } + + return [...variants].sort((a, b) => b.length - a.length); +} + function redactConfiguredSecretSubstrings( value: string, secretSource: SecretValueSource, @@ -161,16 +190,7 @@ function redactConfiguredSecretSubstrings( .sort((a, b) => b.length - a.length); for (const secret of secrets) { - const encodedSecret = encodeURIComponent(secret); - const encodedSecretWithLowercaseEscapes = encodedSecret.replace( - /%[0-9A-F]{2}/g, - match => match.toLowerCase(), - ); - for (const variant of new Set([ - secret, - encodedSecret, - encodedSecretWithLowercaseEscapes, - ])) { + for (const variant of getConfiguredSecretSubstringVariants(secret)) { redacted = redacted.split(variant).join('redacted'); } } @@ -183,7 +203,7 @@ function queryValueMatchesConfiguredSecret( secrets: ReadonlySet, ): boolean { let decoded = value; - for (let depth = 0; depth < MAX_CONFIGURED_SECRET_QUERY_DECODE_DEPTH; depth++) { + for (let depth = 0; depth < MAX_CONFIGURED_SECRET_ENCODING_DEPTH; depth++) { if (secrets.has(decoded)) { return true; } From 90bbf361917f0daba40e4fa8d8706c3c8dc3c8ed Mon Sep 17 00:00:00 2001 From: chioarub Date: Sat, 20 Jun 2026 08:52:48 +0300 Subject: [PATCH 15/15] fix(status): redact strict encoded secret variants --- src/utils/status.test.ts | 19 +++++++++++++++++++ src/utils/status.tsx | 27 +++++++++++++++++++++++---- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/utils/status.test.ts b/src/utils/status.test.ts index 9ec08e3ded..56fce2c182 100644 --- a/src/utils/status.test.ts +++ b/src/utils/status.test.ts @@ -188,6 +188,25 @@ test('buildAPIProviderProperties redacts double-encoded configured secrets outsi expect(serialized).not.toContain('abc%252FdefSecret987') }) +test('buildAPIProviderProperties redacts percent-encoded configured secret punctuation outside URL query values', async () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = + 'https://api.openai.com/abc%21defSecret987/v1' + process.env.OPENAI_MODEL = 'gpt-4o abc%21defSecret987' + process.env.OPENAI_API_KEY = 'abc!defSecret987' + + const properties = await readAPIProviderProperties('openai') + + expect(properties.find(property => property.label === 'OpenAI base URL')?.value).toBe( + 'https://api.openai.com/redacted/v1', + ) + expect(properties.find(property => property.label === 'Model')?.value).toBe( + 'gpt-4o redacted', + ) + const serialized = JSON.stringify(properties) + expect(serialized).not.toContain('abc%21defSecret987') +}) + test('buildAPIProviderProperties redacts token-bearing Gemini base URLs', async () => { process.env.CLAUDE_CODE_USE_GEMINI = '1' process.env.GEMINI_BASE_URL = diff --git a/src/utils/status.tsx b/src/utils/status.tsx index 9e46ce18fb..8683fbf384 100644 --- a/src/utils/status.tsx +++ b/src/utils/status.tsx @@ -151,17 +151,36 @@ function getConfiguredSecretSubstringSource( return substringSource; } +function encodeURIComponentStrict(value: string): string { + return encodeURIComponent(value).replace( + /[!'()*]/g, + character => + `%${character.charCodeAt(0).toString(16).toUpperCase()}`, + ); +} + +function addPercentEscapeCaseVariants( + variants: Set, + value: string, +): void { + variants.add(value); + variants.add( + value.replace(/%[0-9A-F]{2}/g, match => match.toLowerCase()), + ); +} + function addEncodedSecretVariants( variants: Set, value: string, ): void { let encoded = value; + let strictlyEncoded = value; for (let depth = 0; depth < MAX_CONFIGURED_SECRET_ENCODING_DEPTH; depth++) { encoded = encodeURIComponent(encoded); - variants.add(encoded); - variants.add( - encoded.replace(/%[0-9A-F]{2}/g, match => match.toLowerCase()), - ); + addPercentEscapeCaseVariants(variants, encoded); + + strictlyEncoded = encodeURIComponentStrict(strictlyEncoded); + addPercentEscapeCaseVariants(variants, strictlyEncoded); } }