mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 10:14:19 -05:00
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:
@@ -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', () => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(/#.*$/, '')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user