fix(status): show active provider route instead of legacy provider bucket (#1673)

* 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.

* fix(status): include route status defaults

* fix(status): address route status review findings

* fix(status): cover route secret redaction review

* fix(status): avoid duplicate route resolution

* fix(status): redact base URL query credentials

* fix(status): harden status URL secret redaction

* fix(status): redact route secrets in status text

* test(status): cover fallback URL fragment redaction

* test(status): isolate route status provider imports

* fix(status): redact encoded route secrets

* fix(status): redact encoded query secrets safely

* fix(status): redact nested encoded query secrets

* fix(status): redact encoded secret substrings

* fix(status): redact strict encoded secret variants
This commit is contained in:
Bogdan
2026-06-22 08:17:56 +08:00
committed by GitHub
parent 02ee7c63e9
commit b9a5030b67
8 changed files with 901 additions and 17 deletions
+40
View File
@@ -1588,6 +1588,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 }),
@@ -1599,10 +1600,49 @@ 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('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', () => {
+6 -2
View File
@@ -20,6 +20,7 @@ import {
const FAKE_OPENAI_KEY = 'sk-fake-openai-1234567890abcdef'
const FAKE_GEMINI_KEY = 'AIzaSyFAKEGEMINIkey1234567890abcdefghijklmnopqr'
const FAKE_GITHUB_PAT = 'ghp_FAKEgithubPat0123456789abcdefghij'
const FAKE_GITHUB_USER_TOKEN = 'ghu_1234567890abcdef1234567890abcdef1234'
const FAKE_LONG_OPAQUE = 'live-pr-1234567890abcdefABCDEF1234567890abcdef'
const FAKE_JWT_TOKEN = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'
@@ -179,6 +180,7 @@ describe('redactSecretValueForDisplay', () => {
expect(redactSecretValueForDisplay('sk-ant-fakeAnthropicToken-1234567890')).toBe('sk-...890')
expect(redactSecretValueForDisplay('AIzaSyFAKEGEMINIkey1234567890abcdefghijklmnopqr')).toBe('AIz...pqr')
expect(redactSecretValueForDisplay(FAKE_GITHUB_PAT)).toBe('ghp...hij')
expect(redactSecretValueForDisplay(FAKE_GITHUB_USER_TOKEN)).toBe('ghu...234')
expect(redactSecretValueForDisplay(FAKE_LONG_OPAQUE)).toBe('liv...def')
expect(redactSecretValueForDisplay(FAKE_JWT_TOKEN)).toBe('eyJ...w5c')
})
@@ -242,11 +244,12 @@ describe('redactSecretSubstringsForDisplay', () => {
const leakedKey = 'sk-liveLeakToken1234567890ABCdef'
const redacted = redactSecretSubstringsForDisplay(
`Invalid API key: ${leakedKey}`,
`Invalid API key: ${leakedKey}; GitHub token: ${FAKE_GITHUB_USER_TOKEN}`,
)
expect(redacted).toBe('Invalid API key: sk-...def')
expect(redacted).toBe('Invalid API key: sk-...def; GitHub token: ghu...234')
expect(redacted).not.toContain(leakedKey)
expect(redacted).not.toContain(FAKE_GITHUB_USER_TOKEN)
})
test('redacts JWT-shaped values embedded in longer messages', () => {
@@ -285,6 +288,7 @@ describe('sanitizeProviderConfigValue', () => {
expect(sanitizeProviderConfigValue('sk-ant-looks-like-a-key-1234567890')).toBeUndefined()
expect(sanitizeProviderConfigValue('AIzaSySomeGeminiKey1234567890abcdefghijklmnopqr')).toBeUndefined()
expect(sanitizeProviderConfigValue(FAKE_GITHUB_PAT)).toBeUndefined()
expect(sanitizeProviderConfigValue(FAKE_GITHUB_USER_TOKEN)).toBeUndefined()
expect(sanitizeProviderConfigValue(FAKE_JWT_TOKEN)).toBeUndefined()
})
+26 -3
View File
@@ -90,13 +90,14 @@ const SECRET_PREFIX_PATTERNS = [
/^AIza/,
/^ghp_/,
/^gho_/,
/^ghu_/,
/^ghs_/,
/^ghr_/,
/^github_pat_/,
]
const SECRET_PREFIX_SUBSTRING_PATTERN =
/(?:sk-ant-|sk-|AIza|ghp_|gho_|ghs_|ghr_|github_pat_)[A-Za-z0-9._-]{8,}/g
/(?:sk-ant-|sk-|AIza|ghp_|gho_|ghu_|ghs_|ghr_|github_pat_)[A-Za-z0-9._-]{8,}/g
const JWT_SUBSTRING_PATTERN =
/\b[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g
@@ -152,16 +153,38 @@ function hasLowerUpperDigit(value: string): boolean {
return hasLower && hasUpper && hasDigit
}
// Redaction sources may be full process env objects, so also collect values
// from generic credential-bearing suffixes. The descriptor registry covers
// known providers; this defensive path covers custom routes and cloud/database
// auth variables that can still be surfaced through status/config displays.
function isSecretEnvKey(
key: string,
knownKeys: ReadonlySet<string>,
): boolean {
return (
knownKeys.has(key) ||
key.endsWith('_API_KEY') ||
key.endsWith('_AUTH_HEADER_VALUE') ||
key.endsWith('_PASSWORD') ||
key.endsWith('_SECRET') ||
key.endsWith('_SECRET_ACCESS_KEY') ||
key.endsWith('_SECRET_KEY') ||
key.endsWith('_TOKEN')
)
}
function collectSecretValues(
sources: Array<SecretValueSource | null | undefined>,
): string[] {
const knownKeys = getKnownProviderSecretEnvKeys()
const knownKeys = new Set(getKnownProviderSecretEnvKeys())
const values = new Set<string>()
for (const source of sources) {
if (!source) continue
for (const key of knownKeys) {
for (const key of Object.keys(source)) {
if (!isSecretEnvKey(key, knownKeys)) continue
const value = sanitizeApiKey(source[key])?.trim()
if (value) {
values.add(value)
+424
View File
@@ -0,0 +1,424 @@
import { afterEach, beforeEach, expect, mock, test } from 'bun:test'
import {
acquireSharedMutationLock,
releaseSharedMutationLock,
} from '../test/sharedMutationLock.js'
const ORIGINAL_ENV = { ...process.env }
// 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 {
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 Object.keys(process.env)) {
if (PROVIDER_ENV_PREFIXES.some(prefix => key.startsWith(prefix))) {
delete process.env[key]
}
}
}
async function restoreProviderModule(): Promise<void> {
mock.restore()
const nonce = `${Date.now()}-${Math.random()}`
const actualProviders = await import(`./model/providers.js?restore=${nonce}`)
mock.module('./model/providers.js', () => ({
...actualProviders,
}))
}
async function buildPropertiesWithProvider(
_provider: string,
): Promise<Array<{ label?: string; value: unknown }>> {
return buildPropertiesWithRealProvider()
}
async function buildPropertiesWithRealProvider(): Promise<
Array<{ label?: string; value: unknown }>
> {
await restoreProviderModule()
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')
await restoreProviderModule()
clearProviderEnv()
})
afterEach(async () => {
try {
await restoreProviderModule()
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('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('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('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 =
'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('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('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('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 = ' '
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'
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('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('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'
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'
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'.
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 buildPropertiesWithRealProvider()
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()
})
+54
View File
@@ -153,6 +153,60 @@ 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 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 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 =
+331 -10
View File
@@ -21,8 +21,16 @@ 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 { getKnownProviderSecretEnvKeys, redactSecretValueForDisplay, type SecretValueSource } from './providerSecrets.js';
import { getKnownProviderSecretEnvKeys, redactSecretSubstringsForDisplay, redactSecretValueForDisplay, sanitizeApiKey, type SecretValueSource } from './providerSecrets.js';
import { redactPathForStatus, redactUrlForStatus } from './statusRedaction.js';
import {
getRouteCredentialEnvVars,
getRouteDefaultBaseUrl,
getRouteDefaultModel,
getRouteLabel,
getRouteProviderTypeLabel,
resolveActiveRouteIdFromEnv,
} from '../integrations/routeMetadata.js';
export type Property = {
label?: string;
value: React.ReactNode | Array<string>;
@@ -76,6 +84,9 @@ const OPENAI_COMPATIBLE_STATUS_METADATA: Partial<
},
};
const MIN_CONFIGURED_SECRET_SUBSTRING_LENGTH = 9;
const MAX_CONFIGURED_SECRET_ENCODING_DEPTH = 3;
function formatOpenAICompatibleModelDisplay(
model: string,
resolveModelMetadata = false,
@@ -110,11 +121,189 @@ function pushRedactedProperty(
return;
}
const secretRedacted = redactSecretValueForDisplay(value, secretSource) ?? value;
properties.push({
label,
value: redactSecretValueForDisplay(value, secretSource) ?? value
value: redactStatusTextForDisplay(secretRedacted, secretSource)
});
}
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)),
),
);
}
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 encodeURIComponentStrict(value: string): string {
return encodeURIComponent(value).replace(
/[!'()*]/g,
character =>
`%${character.charCodeAt(0).toString(16).toUpperCase()}`,
);
}
function addPercentEscapeCaseVariants(
variants: Set<string>,
value: string,
): void {
variants.add(value);
variants.add(
value.replace(/%[0-9A-F]{2}/g, match => match.toLowerCase()),
);
}
function addEncodedSecretVariants(
variants: Set<string>,
value: string,
): void {
let encoded = value;
let strictlyEncoded = value;
for (let depth = 0; depth < MAX_CONFIGURED_SECRET_ENCODING_DEPTH; depth++) {
encoded = encodeURIComponent(encoded);
addPercentEscapeCaseVariants(variants, encoded);
strictlyEncoded = encodeURIComponentStrict(strictlyEncoded);
addPercentEscapeCaseVariants(variants, strictlyEncoded);
}
}
function getConfiguredSecretSubstringVariants(secret: string): string[] {
const variants = new Set<string>([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,
): 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) {
for (const variant of getConfiguredSecretSubstringVariants(secret)) {
redacted = redacted.split(variant).join('redacted');
}
}
return redacted;
}
function queryValueMatchesConfiguredSecret(
value: string,
secrets: ReadonlySet<string>,
): boolean {
let decoded = value;
for (let depth = 0; depth < MAX_CONFIGURED_SECRET_ENCODING_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,
): 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 (queryValueMatchesConfiguredSecret(queryValue, secrets)) {
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,
): string {
const configuredSecretRedacted = redactConfiguredSecretSubstrings(
value,
secretSource,
);
return (
redactSecretSubstringsForDisplay(
configuredSecretRedacted,
getConfiguredSecretSubstringSource(secretSource),
) ??
configuredSecretRedacted
);
}
function pushRedactedUrlProperty(
properties: Property[],
label: string,
@@ -125,12 +314,114 @@ function pushRedactedUrlProperty(
return;
}
const redactedUrl = redactUrlForStatus(value);
const queryValueRedacted = redactConfiguredSecretUrlQueryValues(
value,
secretSource,
);
const urlRedacted = redactUrlForStatus(queryValueRedacted);
properties.push({
label,
value: redactSecretValueForDisplay(redactedUrl, secretSource) ?? redactedUrl
value: redactStatusTextForDisplay(urlRedacted, secretSource)
});
}
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",
* "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(buildRouteResolutionEnv());
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(', ');
}
/**
* 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 (
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 (
readTrimmedEnvValue('OPENAI_MODEL') ||
(routeId ? getRouteDefaultModel(routeId) : undefined)
);
}
export function buildSandboxProperties(): Property[] {
if (process.env.USER_TYPE !== 'ant') {
return [];
@@ -353,10 +644,18 @@ export function buildAPIProviderProperties(): Property[] {
secretSource[key] = envValue;
}
}
const routeId =
apiProvider === 'openai' ? resolveDisplayRouteId() : null;
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 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
});
}
@@ -428,13 +727,26 @@ export function buildAPIProviderProperties(): Property[] {
} else if (apiProvider in OPENAI_COMPATIBLE_STATUS_METADATA) {
const metadata =
OPENAI_COMPATIBLE_STATUS_METADATA[apiProvider]!;
const transportLabel = routeId
? getRouteProviderTypeLabel(routeId)
: null;
const redactionSource: SecretValueSource = {
...secretSource,
...buildRouteSecretSource(routeId),
};
if (transportLabel) {
properties.push({
label: 'Transport',
value: transportLabel,
});
}
pushRedactedUrlProperty(
properties,
metadata.baseUrlLabel,
process.env.OPENAI_BASE_URL,
secretSource,
getOpenAICompatibleBaseUrlForStatus(routeId),
redactionSource,
);
const openaiModel = process.env.OPENAI_MODEL;
const openaiModel = getOpenAICompatibleModelForStatus(routeId);
if (openaiModel) {
const modelDisplay = formatOpenAICompatibleModelDisplay(
openaiModel,
@@ -444,9 +756,18 @@ export function buildAPIProviderProperties(): Property[] {
properties,
'Model',
modelDisplay,
secretSource,
redactionSource,
);
}
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;
pushRedactedUrlProperty(properties, 'Gemini base URL', geminiBaseUrl, secretSource);
+17 -1
View File
@@ -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',
@@ -34,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)
@@ -89,4 +105,4 @@ describe('shouldRedactUrlQueryParam', () => {
expect(shouldRedactUrlQueryParam(name)).toBe(false)
}
})
})
})
+3 -1
View File
@@ -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(/#.*$/, '')
}
}
}