fix(provider): centralize provider secret redaction (#1665)

* fix(provider): centralize provider secret redaction

* fix(system-check): prefer base URL route credentials

* fix(provider): avoid false credential matches

* fix(provider): redact jwt-shaped tokens

* fix(provider): redact embedded diagnostic secrets

* test(system-check): isolate provider env keys
This commit is contained in:
Bogdan
2026-06-17 11:23:15 +08:00
committed by GitHub
parent e733908a91
commit 29aea4969d
12 changed files with 781 additions and 150 deletions
+131 -1
View File
@@ -1,13 +1,56 @@
import { describe, expect, test } from 'bun:test'
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import {
buildSandboxRuntimeCheck,
checkOpenAIEnv,
checkNodeVersion,
formatReachabilityFailureDetail,
isCliSandboxRuntimeStubbed,
readNodeExecutableVersion,
serializeSafeEnvSummary,
} from './system-check.ts'
const ENV_KEYS = [
'CLAUDE_CODE_USE_OPENAI',
'CLAUDE_CODE_USE_GITHUB',
'CLAUDE_CODE_USE_GEMINI',
'CLAUDE_CODE_USE_MISTRAL',
'CLAUDE_CODE_SIMPLE',
'GEMINI_API_KEY',
'GOOGLE_API_KEY',
'GEMINI_MODEL',
'MISTRAL_API_KEY',
'MISTRAL_MODEL',
'OPENAI_MODEL',
'OPENAI_BASE_URL',
'OPENAI_API_KEY',
'OPENGATEWAY_API_KEY',
'GITHUB_TOKEN',
'GH_TOKEN',
'CODEX_API_KEY',
'CODEX_AUTH_JSON_PATH',
'CODEX_HOME',
] as const
const originalEnv: Record<string, string | undefined> = {}
beforeEach(() => {
for (const key of ENV_KEYS) {
originalEnv[key] = process.env[key]
delete process.env[key]
}
})
afterEach(() => {
for (const key of ENV_KEYS) {
if (originalEnv[key] === undefined) {
delete process.env[key]
} else {
process.env[key] = originalEnv[key]
}
}
})
describe('formatReachabilityFailureDetail', () => {
test('returns generic failure detail for non-codex transport', () => {
const detail = formatReachabilityFailureDetail(
@@ -43,6 +86,25 @@ describe('formatReachabilityFailureDetail', () => {
)
})
test('redacts secret-shaped values embedded in response bodies', () => {
const leakedKey = 'sk-liveLeakToken1234567890ABCdef'
const detail = formatReachabilityFailureDetail(
'https://api.openai.com/v1/models',
401,
`{"error":"Invalid API key: ${leakedKey}"}`,
{
transport: 'chat_completions',
requestedModel: 'gpt-4o',
resolvedModel: 'gpt-4o',
},
)
expect(detail).toBe(
'Unexpected status 401 from https://api.openai.com/v1/models. Body: {"error":"Invalid API key: sk-...def"}',
)
expect(detail).not.toContain(leakedKey)
})
test('adds alias/entitlement hint for codex model support 400s', () => {
const detail = formatReachabilityFailureDetail(
'https://chatgpt.com/backend-api/codex/responses',
@@ -62,6 +124,74 @@ describe('formatReachabilityFailureDetail', () => {
'Try "codexplan" or another entitled Codex model.',
)
})
test('redacts descriptor-declared provider secret values in codex model hints', () => {
const providerSecret = 'ogw-provider-secret'
process.env.OPENGATEWAY_API_KEY = providerSecret
const detail = formatReachabilityFailureDetail(
'https://chatgpt.com/backend-api/codex/responses',
400,
'{"detail":"model is not supported with this chatgpt account"}',
{
transport: 'codex_responses',
requestedModel: providerSecret,
resolvedModel: providerSecret,
},
)
expect(detail).toContain('model alias "ogw...ret" resolved to "ogw...ret"')
expect(detail).not.toContain(providerSecret)
})
})
describe('system-check provider diagnostics', () => {
test('redacts descriptor-declared provider secret values in displayed model fields', () => {
const providerSecret = 'ogw-provider-secret'
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.OPENAI_BASE_URL = 'https://opengateway.gitlawb.com/v1'
process.env.OPENAI_MODEL = providerSecret
process.env.OPENGATEWAY_API_KEY = providerSecret
const results = checkOpenAIEnv()
const serialized = JSON.stringify(results)
expect(serialized).toContain('ogw...ret')
expect(serialized).not.toContain(providerSecret)
})
test('summarizes descriptor-declared provider credentials without exposing values', () => {
const providerSecret = 'ogw-provider-secret'
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.OPENAI_BASE_URL = 'https://opengateway.gitlawb.com/v1'
process.env.OPENAI_MODEL = providerSecret
process.env.OPENGATEWAY_API_KEY = providerSecret
const summary = serializeSafeEnvSummary()
expect(summary.OPENAI_MODEL).toBe('ogw...ret')
expect(summary.PROVIDER_API_KEY_SET).toBe(true)
expect(JSON.stringify(summary)).not.toContain(providerSecret)
})
test('does not use active GitHub credentials for a default OpenAI base URL', () => {
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.CLAUDE_CODE_USE_GITHUB = '1'
process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1'
process.env.GITHUB_TOKEN = 'ghp_FAKEgithubToken0123456789'
delete process.env.OPENAI_API_KEY
const results = checkOpenAIEnv()
const summary = serializeSafeEnvSummary()
const credentialResult = results.find(result => result.label === 'OPENAI_API_KEY')
expect(credentialResult).toEqual({
ok: false,
label: 'OPENAI_API_KEY',
detail: 'Missing key for non-local provider URL. Set OPENAI_API_KEY.',
})
expect(summary.PROVIDER_API_KEY_SET).toBe(false)
})
})
describe('checkNodeVersion', () => {
+115 -36
View File
@@ -1,17 +1,28 @@
// @ts-nocheck
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { dirname, resolve } from 'node:path'
import { spawnSync } from 'node:child_process'
import {
resolveCodexApiCredentials,
resolveProviderRequest,
isLocalProviderUrl as isProviderLocalUrl,
} from '../src/services/api/providerConfig.js'
import {
getRouteCredentialEnvVars,
getRouteCredentialValue,
resolveActiveRouteIdFromEnv,
resolveRouteIdFromBaseUrl,
} from '../src/integrations/routeMetadata.js'
import {
getLocalOpenAICompatibleProviderLabel,
probeOllamaGenerationReadiness,
} from '../src/utils/providerDiscovery.js'
import { DEFAULT_GEMINI_MODEL } from '../src/utils/providerProfile.js'
import {
redactSecretValueForDisplay,
redactSecretSubstringsForDisplay,
type SecretValueSource,
} from '../src/utils/providerSecrets.js'
import { redactUrlForDisplay } from '../src/utils/urlRedaction.js'
import {
MIN_NODE_ENGINE_RANGE,
@@ -89,7 +100,10 @@ export function formatReachabilityFailureDetail(
resolvedModel: string
},
): string {
const compactBody = responseBody.trim().replace(/\s+/g, ' ').slice(0, 240)
const compactBody = safeDiagnosticText(
responseBody.trim().replace(/\s+/g, ' ').slice(0, 240),
'',
)
const base = `Unexpected status ${status} from ${redactUrlForDisplay(endpoint)}.`
const bodySuffix = compactBody ? ` Body: ${compactBody}` : ''
@@ -101,7 +115,9 @@ export function formatReachabilityFailureDetail(
return `${base}${bodySuffix}`
}
return `${base}${bodySuffix} Hint: model alias "${request.requestedModel}" resolved to "${request.resolvedModel}", which this ChatGPT account does not currently allow. Try "codexplan" or another entitled Codex model.`
const requestedModel = safeDisplayValue(request.requestedModel, 'the requested model')
const resolvedModel = safeDisplayValue(request.resolvedModel, 'the resolved model')
return `${base}${bodySuffix} Hint: model alias "${requestedModel}" resolved to "${resolvedModel}", which this ChatGPT account does not currently allow. Try "codexplan" or another entitled Codex model.`
}
export function readNodeExecutableVersion(
@@ -268,6 +284,53 @@ const GEMINI_DEFAULT_BASE_URL = 'https://generativelanguage.googleapis.com/v1bet
const MISTRAL_DEFAULT_BASE_URL = 'https://api.mistral.ai/v1'
const GITHUB_COPILOT_BASE = 'https://api.githubcopilot.com'
function currentSecretSource(): SecretValueSource {
return process.env as SecretValueSource
}
function safeDisplayValue(
value: string | null | undefined,
fallback: string,
): string {
return redactSecretValueForDisplay(value, currentSecretSource()) ?? fallback
}
function safeDiagnosticText(
value: string | null | undefined,
fallback: string,
): string {
return redactSecretSubstringsForDisplay(value, currentSecretSource()) ?? fallback
}
function safeBaseUrlDisplay(
value: string | null | undefined,
fallback: string,
): string {
if (!value) return fallback
return safeDisplayValue(redactUrlForDisplay(value), fallback)
}
function getOpenAICompatibleRouteId(baseUrl: string): string {
return (
resolveRouteIdFromBaseUrl(baseUrl) ??
resolveActiveRouteIdFromEnv(process.env) ??
'custom'
)
}
function getOpenAICompatibleCredentialContext(baseUrl: string): {
routeId: string
envVars: string[]
value: string | undefined
} {
const routeId = getOpenAICompatibleRouteId(baseUrl)
return {
routeId,
envVars: getRouteCredentialEnvVars(routeId),
value: getRouteCredentialValue(routeId, process.env),
}
}
function currentBaseUrl(): string {
if (isTruthy(process.env.CLAUDE_CODE_USE_GEMINI)) {
return process.env.GEMINI_BASE_URL ?? GEMINI_DEFAULT_BASE_URL
@@ -292,10 +355,10 @@ function checkGeminiEnv(): CheckResult[] {
if (!model) {
results.push(pass('GEMINI_MODEL', `Not set. Default ${DEFAULT_GEMINI_MODEL} will be used.`))
} else {
results.push(pass('GEMINI_MODEL', model))
results.push(pass('GEMINI_MODEL', safeDisplayValue(model, '')))
}
results.push(pass('GEMINI_BASE_URL', baseUrl))
results.push(pass('GEMINI_BASE_URL', safeBaseUrlDisplay(baseUrl, '')))
if (!key) {
results.push(fail('GEMINI_API_KEY', 'Missing. Set GEMINI_API_KEY or GOOGLE_API_KEY.'))
@@ -317,10 +380,10 @@ function checkMistralEnv(): CheckResult[] {
if (!model) {
results.push(pass('MISTRAL_MODEL', 'Not set. Default will be used at runtime.'))
} else {
results.push(pass('MISTRAL_MODEL', model))
results.push(pass('MISTRAL_MODEL', safeDisplayValue(model, '')))
}
results.push(pass('MISTRAL_BASE_URL', baseUrl))
results.push(pass('MISTRAL_BASE_URL', safeBaseUrlDisplay(baseUrl, '')))
if (!key) {
results.push(fail('MISTRAL_API_KEY', 'Missing. Set MISTRAL_API_KEY.'))
@@ -351,14 +414,14 @@ function checkGithubEnv(): CheckResult[] {
),
)
} else {
results.push(pass('OPENAI_MODEL', process.env.OPENAI_MODEL))
results.push(pass('OPENAI_MODEL', safeDisplayValue(process.env.OPENAI_MODEL, '')))
}
results.push(pass('OPENAI_BASE_URL', baseUrl))
results.push(pass('OPENAI_BASE_URL', safeBaseUrlDisplay(baseUrl, '')))
return results
}
function checkOpenAIEnv(): CheckResult[] {
export function checkOpenAIEnv(): CheckResult[] {
const results: CheckResult[] = []
const useGemini = isTruthy(process.env.CLAUDE_CODE_USE_GEMINI)
const useGithub = isTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
@@ -399,10 +462,10 @@ function checkOpenAIEnv(): CheckResult[] {
if (!process.env.OPENAI_MODEL) {
results.push(pass('OPENAI_MODEL', 'Not set. Runtime fallback model will be used.'))
} else {
results.push(pass('OPENAI_MODEL', process.env.OPENAI_MODEL))
results.push(pass('OPENAI_MODEL', safeDisplayValue(process.env.OPENAI_MODEL, '')))
}
results.push(pass('OPENAI_BASE_URL', redactUrlForDisplay(request.baseUrl)))
results.push(pass('OPENAI_BASE_URL', safeBaseUrlDisplay(request.baseUrl, '')))
if (request.transport === 'codex_responses') {
const credentials = resolveCodexApiCredentials(process.env)
@@ -423,23 +486,31 @@ function checkOpenAIEnv(): CheckResult[] {
}
const key = process.env.OPENAI_API_KEY
const credentialContext = getOpenAICompatibleCredentialContext(request.baseUrl)
const providerCredential = credentialContext.value
const credentialLabel =
credentialContext.envVars.length > 0
? credentialContext.envVars.join(' or ')
: 'OPENAI_API_KEY'
const githubToken = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN
if (key === 'SUA_CHAVE') {
results.push(fail('OPENAI_API_KEY', 'Placeholder value detected: SUA_CHAVE.'))
const hasGithubRouteCredential =
credentialContext.routeId === 'github' && Boolean(githubToken?.trim())
if (key === 'SUA_CHAVE' || providerCredential === 'SUA_CHAVE') {
results.push(fail(credentialLabel, 'Placeholder value detected: SUA_CHAVE.'))
} else if (
!key &&
!providerCredential &&
!isLocalBaseUrl(request.baseUrl) &&
!(useGithub && githubToken?.trim())
!hasGithubRouteCredential
) {
results.push(fail('OPENAI_API_KEY', 'Missing key for non-local provider URL.'))
} else if (!key && useGithub && githubToken?.trim()) {
results.push(fail(credentialLabel, `Missing key for non-local provider URL. Set ${credentialLabel}.`))
} else if (!providerCredential && hasGithubRouteCredential) {
results.push(
pass('OPENAI_API_KEY', 'Not set; GITHUB_TOKEN/GH_TOKEN will be used for GitHub Models.'),
)
} else if (!key) {
results.push(pass('OPENAI_API_KEY', 'Not set (allowed for local providers like Atomic Chat/Ollama/LM Studio).'))
} else if (!providerCredential) {
results.push(pass(credentialLabel, 'Not set (allowed for local providers like Atomic Chat/Ollama/LM Studio).'))
} else {
results.push(pass('OPENAI_API_KEY', 'Configured.'))
results.push(pass(credentialLabel, 'Configured.'))
}
return results
@@ -511,8 +582,11 @@ async function checkBaseUrlReachability(): Promise<CheckResult> {
headers.Authorization = `Bearer ${process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY}`
} else if (useMistral && process.env.MISTRAL_API_KEY) {
headers.Authorization = `Bearer ${process.env.MISTRAL_API_KEY}`
} else if (process.env.OPENAI_API_KEY) {
headers.Authorization = `Bearer ${process.env.OPENAI_API_KEY}`
} else {
const credential = getOpenAICompatibleCredentialContext(request.baseUrl).value
if (credential) {
headers.Authorization = `Bearer ${credential}`
}
}
const response = await fetch(endpoint, {
@@ -611,7 +685,7 @@ async function checkProviderGenerationReadiness(): Promise<CheckResult> {
if (readiness.state === 'ready') {
return pass(
'Provider generation readiness',
`Generated a test response with ${readiness.probeModel ?? request.requestedModel}.`,
`Generated a test response with ${safeDisplayValue(readiness.probeModel ?? request.requestedModel, 'the requested model')}.`,
)
}
@@ -629,10 +703,11 @@ async function checkProviderGenerationReadiness(): Promise<CheckResult> {
)
}
const detailSuffix = readiness.detail ? ` Detail: ${readiness.detail}.` : ''
const detail = safeDiagnosticText(readiness.detail, '')
const detailSuffix = detail ? ` Detail: ${detail}.` : ''
return fail(
'Provider generation readiness',
`Ollama is reachable, but generation failed for ${readiness.probeModel ?? request.requestedModel}.${detailSuffix}`,
`Ollama is reachable, but generation failed for ${safeDisplayValue(readiness.probeModel ?? request.requestedModel, 'the requested model')}.${detailSuffix}`,
)
}
@@ -693,20 +768,20 @@ function checkOllamaProcessorMode(): CheckResult {
return pass('Ollama processor mode', `Detected non-CPU mode: ${modelLine}`)
}
function serializeSafeEnvSummary(): Record<string, string | boolean> {
export function serializeSafeEnvSummary(): Record<string, string | boolean> {
if (isTruthy(process.env.CLAUDE_CODE_USE_GEMINI)) {
return {
CLAUDE_CODE_USE_GEMINI: true,
GEMINI_MODEL: process.env.GEMINI_MODEL ?? `(unset, default: ${DEFAULT_GEMINI_MODEL})`,
GEMINI_BASE_URL: process.env.GEMINI_BASE_URL ?? 'https://generativelanguage.googleapis.com/v1beta/openai',
GEMINI_MODEL: safeDisplayValue(process.env.GEMINI_MODEL, `(unset, default: ${DEFAULT_GEMINI_MODEL})`),
GEMINI_BASE_URL: safeBaseUrlDisplay(process.env.GEMINI_BASE_URL ?? 'https://generativelanguage.googleapis.com/v1beta/openai', ''),
GEMINI_API_KEY_SET: Boolean(process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY),
}
}
if (isTruthy(process.env.CLAUDE_CODE_USE_MISTRAL)) {
return {
CLAUDE_CODE_USE_MISTRAL: true,
MISTRAL_MODEL: process.env.MISTRAL_MODEL ?? '(unset, default: devstral-latest)',
MISTRAL_BASE_URL: process.env.MISTRAL_BASE_URL ?? 'https://api.mistral.ai/v1',
MISTRAL_MODEL: safeDisplayValue(process.env.MISTRAL_MODEL, '(unset, default: devstral-latest)'),
MISTRAL_BASE_URL: safeBaseUrlDisplay(process.env.MISTRAL_BASE_URL ?? 'https://api.mistral.ai/v1', ''),
MISTRAL_API_KEY_SET: Boolean(process.env.MISTRAL_API_KEY),
}
}
@@ -717,10 +792,12 @@ function serializeSafeEnvSummary(): Record<string, string | boolean> {
return {
CLAUDE_CODE_USE_GITHUB: true,
OPENAI_MODEL:
process.env.OPENAI_MODEL ??
'(unset, default: github:copilot → openai/gpt-4.1)',
safeDisplayValue(
process.env.OPENAI_MODEL,
'(unset, default: github:copilot → openai/gpt-4.1)',
),
OPENAI_BASE_URL:
process.env.OPENAI_BASE_URL ?? GITHUB_COPILOT_BASE,
safeBaseUrlDisplay(process.env.OPENAI_BASE_URL ?? GITHUB_COPILOT_BASE, ''),
GITHUB_TOKEN_SET: Boolean(
process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN,
),
@@ -730,11 +807,13 @@ function serializeSafeEnvSummary(): Record<string, string | boolean> {
model: process.env.OPENAI_MODEL,
baseUrl: process.env.OPENAI_BASE_URL,
})
const credentialContext = getOpenAICompatibleCredentialContext(request.baseUrl)
return {
CLAUDE_CODE_USE_OPENAI: isTruthy(process.env.CLAUDE_CODE_USE_OPENAI),
OPENAI_MODEL: process.env.OPENAI_MODEL ?? '(unset)',
OPENAI_BASE_URL: request.baseUrl,
OPENAI_MODEL: safeDisplayValue(process.env.OPENAI_MODEL, '(unset)'),
OPENAI_BASE_URL: safeBaseUrlDisplay(request.baseUrl, ''),
OPENAI_API_KEY_SET: Boolean(process.env.OPENAI_API_KEY),
PROVIDER_API_KEY_SET: Boolean(credentialContext.value),
CODEX_API_KEY_SET: Boolean(resolveCodexApiCredentials(process.env).apiKey),
}
}
+1 -11
View File
@@ -82,7 +82,7 @@ import {
classifyOpenAINetworkFailure,
} from './openaiErrorClassification.js'
import { sanitizeSchemaForOpenAICompat } from '../../utils/schemaSanitizer.js'
import { redactSecretValueForDisplay } from '../../utils/providerProfile.js'
import { redactSecretValueForDisplay, type SecretValueSource } from '../../utils/providerProfile.js'
import { shouldRedactUrlQueryParam } from '../../utils/urlRedaction.js'
import {
normalizeToolArguments,
@@ -96,16 +96,6 @@ import {
} from '../../utils/streamingOptimizer.js'
import { stableStringifyJson } from '../../utils/stableStringify.js'
type SecretValueSource = Partial<{
OPENAI_API_KEY: string
OPENAI_AUTH_HEADER_VALUE: string
CODEX_API_KEY: string
GEMINI_API_KEY: string
GOOGLE_API_KEY: string
GEMINI_ACCESS_TOKEN: string
MISTRAL_API_KEY: string
}>
const GITHUB_429_MAX_RETRIES = 3
const GITHUB_429_BASE_DELAY_SEC = 1
const GITHUB_429_MAX_DELAY_SEC = 32
+7 -8
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from 'bun:test'
import { homedir } from 'node:os'
import { PROVIDER_PRESET_MANIFEST } from '../../integrations/index.js'
import { getKnownProviderSecretEnvKeys } from '../providerSecrets.js'
import {
collectProviderSecretEnvVars,
redactDiagnosticObject,
@@ -10,18 +10,17 @@ import {
} from './redaction.js'
describe('diagnostic redaction', () => {
test('collects every provider preset API key env var from the generated manifest', () => {
const expected = new Set(
PROVIDER_PRESET_MANIFEST.flatMap(preset =>
'apiKeyEnvVars' in preset ? [...preset.apiKeyEnvVars] : [],
),
)
test('collects every known provider secret env var from the centralized registry', () => {
const expected = new Set(getKnownProviderSecretEnvKeys())
expect(new Set(collectProviderSecretEnvVars())).toEqual(expected)
expect(expected.has('GEMINI_ACCESS_TOKEN')).toBe(true)
expect(expected.has('GITHUB_TOKEN')).toBe(true)
expect(expected.has('OPENGATEWAY_API_KEY')).toBe(true)
expect(expected.size).toBeGreaterThan(10)
})
test('represents provider preset secret env vars as presence booleans only', () => {
test('represents provider secret env vars as presence booleans only', () => {
const envVars = collectProviderSecretEnvVars()
const env = Object.fromEntries(
envVars.map((name, index) => [name, `sk-${name}-secret-${index}`]),
+2 -6
View File
@@ -1,5 +1,5 @@
import { homedir } from 'node:os'
import { PROVIDER_PRESET_MANIFEST } from '../../integrations/index.js'
import { getKnownProviderSecretEnvKeys } from '../providerSecrets.js'
import { redactUrlForDisplay } from '../urlRedaction.js'
const SECRET_KEY_PATTERN =
@@ -40,11 +40,7 @@ function escapeRegExp(value: string): string {
}
export function collectProviderSecretEnvVars(): string[] {
return unique(
PROVIDER_PRESET_MANIFEST.flatMap(preset =>
'apiKeyEnvVars' in preset ? [...preset.apiKeyEnvVars] : [],
),
)
return unique(getKnownProviderSecretEnvKeys())
}
export function summarizeSecretEnvPresence(
+5 -40
View File
@@ -30,6 +30,7 @@ import {
redactSecretValueForDisplay,
sanitizeApiKey,
sanitizeProviderConfigValue,
type SecretValueSource,
} from './providerSecrets.js'
export {
@@ -114,25 +115,6 @@ export type CompatibilityProfileMode =
| 'bedrock'
| 'vertex'
const SECRET_ENV_KEYS = [
'OPENAI_API_KEY',
'OPENAI_AUTH_HEADER_VALUE',
'CODEX_API_KEY',
'GEMINI_API_KEY',
'GOOGLE_API_KEY',
'NVIDIA_API_KEY',
'MINIMAX_API_KEY',
'MISTRAL_API_KEY',
'BNKR_API_KEY',
'XAI_API_KEY',
'VENICE_API_KEY',
'MIMO_API_KEY',
'ATLAS_CLOUD_API_KEY',
'NEARAI_API_KEY',
'FIREWORKS_API_KEY',
'OPENCODE_API_KEY',
] as const
export type ProviderProfile =
| 'anthropic'
| 'openai'
@@ -202,27 +184,10 @@ export type ProfileFile = {
createdAt: string
}
type SecretValueSource = Partial<
Record<
| 'OPENAI_API_KEY'
| 'ANTHROPIC_API_KEY'
| 'OPENAI_AUTH_HEADER_VALUE'
| 'CODEX_API_KEY'
| 'GEMINI_API_KEY'
| 'GOOGLE_API_KEY'
| 'NVIDIA_API_KEY'
| 'MINIMAX_API_KEY'
| 'MISTRAL_API_KEY'
| 'BNKR_API_KEY'
| 'XAI_API_KEY'
| 'VENICE_API_KEY'
| 'MIMO_API_KEY'
| 'ATLAS_CLOUD_API_KEY'
| 'NEARAI_API_KEY'
| 'FIREWORKS_API_KEY',
string | undefined
>
>
// SecretValueSource is intentionally open (Partial<Record<string, ...>>) so
// that newly declared provider credential env vars are redactable without a
// matching type update. See providerSecrets.ts for the canonical definition.
export type { SecretValueSource } from './providerSecrets.js'
export type ProfileFileLocation = {
configDir?: string
+296
View File
@@ -0,0 +1,296 @@
import { describe, expect, test } from 'bun:test'
import {
ANTHROPIC_PROXY_DESCRIPTORS,
GATEWAY_DESCRIPTORS,
PROVIDER_PRESET_MANIFEST,
VENDOR_DESCRIPTORS,
} from '../integrations/generated/integrationArtifacts.generated.js'
import type { AnthropicProxyDescriptor, ProviderPresetManifestEntry } from '../integrations/descriptors.js'
import {
getKnownProviderSecretEnvKeys,
maskSecretForDisplay,
redactSecretValueForDisplay,
redactSecretSubstringsForDisplay,
sanitizeApiKey,
sanitizeProviderConfigValue,
} from './providerSecrets.js'
const FAKE_OPENAI_KEY = 'sk-fake-openai-1234567890abcdef'
const FAKE_GEMINI_KEY = 'AIzaSyFAKEGEMINIkey1234567890abcdefghijklmnopqr'
const FAKE_GITHUB_PAT = 'ghp_FAKEgithubPat0123456789abcdefghij'
const FAKE_LONG_OPAQUE = 'live-pr-1234567890abcdefABCDEF1234567890abcdef'
const FAKE_JWT_TOKEN = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'
describe('getKnownProviderSecretEnvKeys', () => {
test('returns a cached readonly array', () => {
const a = getKnownProviderSecretEnvKeys()
const b = getKnownProviderSecretEnvKeys()
expect(a).toBe(b)
expect(Object.isFrozen(a)).toBe(true)
})
test('derives apiKeyEnvVars from every PROVIDER_PRESET_MANIFEST entry', () => {
const known = new Set(getKnownProviderSecretEnvKeys())
const declared = new Set<string>()
const presets: readonly ProviderPresetManifestEntry[] = PROVIDER_PRESET_MANIFEST
for (const entry of presets) {
for (const key of entry.apiKeyEnvVars ?? []) {
declared.add(key)
}
}
for (const key of declared) {
expect(known.has(key), `apiKeyEnvVar ${key} from PROVIDER_PRESET_MANIFEST is not redacted`).toBe(true)
}
})
test('derives setup.credentialEnvVars from every vendor descriptor', () => {
const known = new Set(getKnownProviderSecretEnvKeys())
const declared = new Set<string>()
for (const vendor of VENDOR_DESCRIPTORS) {
for (const key of vendor.setup?.credentialEnvVars ?? []) {
declared.add(key)
}
}
for (const key of declared) {
expect(known.has(key), `credentialEnvVar ${key} from a vendor descriptor is not redacted`).toBe(true)
}
})
test('derives setup.credentialEnvVars from every gateway descriptor', () => {
const known = new Set(getKnownProviderSecretEnvKeys())
const declared = new Set<string>()
for (const gateway of GATEWAY_DESCRIPTORS) {
for (const key of gateway.setup?.credentialEnvVars ?? []) {
declared.add(key)
}
}
for (const key of declared) {
expect(known.has(key), `credentialEnvVar ${key} from a gateway descriptor is not redacted`).toBe(true)
}
})
test('derives setup.credentialEnvVars from every anthropic proxy descriptor', () => {
const known = new Set(getKnownProviderSecretEnvKeys())
const declared = new Set<string>()
const proxies: readonly AnthropicProxyDescriptor[] = ANTHROPIC_PROXY_DESCRIPTORS
for (const proxy of proxies) {
for (const key of proxy.setup?.credentialEnvVars ?? []) {
declared.add(key)
}
}
for (const key of declared) {
expect(known.has(key), `credentialEnvVar ${key} from an anthropic proxy descriptor is not redacted`).toBe(true)
}
})
test('derives validation.credentialEnvVars from every descriptor with validation metadata', () => {
const known = new Set(getKnownProviderSecretEnvKeys())
const declared = new Set<string>()
for (const descriptor of [
...VENDOR_DESCRIPTORS,
...GATEWAY_DESCRIPTORS,
...ANTHROPIC_PROXY_DESCRIPTORS,
]) {
const validation = (descriptor as { validation?: { credentialEnvVars?: readonly string[] } }).validation
for (const key of validation?.credentialEnvVars ?? []) {
declared.add(key)
}
}
for (const key of declared) {
expect(known.has(key), `validation credentialEnvVar ${key} is not redacted`).toBe(true)
}
})
test('includes manually-curated fallback credential keys', () => {
const known = new Set(getKnownProviderSecretEnvKeys())
for (const key of [
'OPENAI_API_KEY',
'OPENAI_AUTH_HEADER_VALUE',
'CODEX_API_KEY',
'GEMINI_API_KEY',
'GOOGLE_API_KEY',
'GEMINI_ACCESS_TOKEN',
'MISTRAL_API_KEY',
'BNKR_API_KEY',
'XAI_API_KEY',
]) {
expect(known.has(key), `fallback key ${key} missing`).toBe(true)
}
})
test('covers representative provider keys declared across the registry', () => {
const known = new Set(getKnownProviderSecretEnvKeys())
const representative = [
'OPENAI_API_KEY',
'ANTHROPIC_API_KEY',
'GEMINI_API_KEY',
'OPENGATEWAY_API_KEY',
'OPENROUTER_API_KEY',
'FIREWORKS_API_KEY',
'GROQ_API_KEY',
'MIMO_API_KEY',
'OPENCODE_API_KEY',
'NEARAI_API_KEY',
'DEEPSEEK_API_KEY',
'DASHSCOPE_API_KEY',
]
const missing = representative.filter((key) => !known.has(key))
expect(missing, `representative provider keys missing from redaction set: ${missing.join(', ')}`).toEqual([])
})
})
describe('sanitizeApiKey', () => {
test('drops empty and the Portuguese placeholder', () => {
expect(sanitizeApiKey(undefined)).toBeUndefined()
expect(sanitizeApiKey('')).toBeUndefined()
expect(sanitizeApiKey('SUA_CHAVE')).toBeUndefined()
})
test('returns real keys unchanged', () => {
expect(sanitizeApiKey(FAKE_OPENAI_KEY)).toBe(FAKE_OPENAI_KEY)
})
})
describe('maskSecretForDisplay', () => {
test('returns undefined for empty values', () => {
expect(maskSecretForDisplay(undefined)).toBeUndefined()
expect(maskSecretForDisplay('')).toBeUndefined()
})
test('masks short secrets as configured', () => {
expect(maskSecretForDisplay('short')).toBe('configured')
})
test('masks long secrets with first/last three chars', () => {
expect(maskSecretForDisplay(FAKE_OPENAI_KEY)).toBe('sk-...def')
})
})
describe('redactSecretValueForDisplay', () => {
test('masks values that equal a configured provider secret', () => {
const sources = [{ OPENAI_API_KEY: FAKE_OPENAI_KEY }]
expect(redactSecretValueForDisplay(FAKE_OPENAI_KEY, ...sources)).toBe('sk-...def')
})
test('masks secret-shaped values even when the env var is unknown', () => {
expect(redactSecretValueForDisplay('sk-unknown-but-secret-shaped-1234567890')).toBe('sk-...890')
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_LONG_OPAQUE)).toBe('liv...def')
expect(redactSecretValueForDisplay(FAKE_JWT_TOKEN)).toBe('eyJ...w5c')
})
test('keeps non-secret values visible', () => {
expect(redactSecretValueForDisplay('gpt-4o', { OPENAI_API_KEY: FAKE_OPENAI_KEY })).toBe('gpt-4o')
expect(redactSecretValueForDisplay('https://api.openai.com/v1')).toBe('https://api.openai.com/v1')
expect(redactSecretValueForDisplay('claude-sonnet-4-6')).toBe('claude-sonnet-4-6')
expect(redactSecretValueForDisplay('claude-sonnet-4-6-preview')).toBe('claude-sonnet-4-6-preview')
expect(redactSecretValueForDisplay('qwen3-coder-480b-a35b-instruct')).toBe('qwen3-coder-480b-a35b-instruct')
expect(redactSecretValueForDisplay('Qwen3-Coder-480B-A35B-Instruct')).toBe('Qwen3-Coder-480B-A35B-Instruct')
})
test('returns undefined for empty input', () => {
expect(redactSecretValueForDisplay(undefined)).toBeUndefined()
expect(redactSecretValueForDisplay('')).toBeUndefined()
expect(redactSecretValueForDisplay(' ')).toBe(' ')
})
test('redacts values supplied via any provider key, not just the original 8', () => {
const sources = [
{
OPENGATEWAY_API_KEY: FAKE_OPENAI_KEY,
OPENROUTER_API_KEY: FAKE_GITHUB_PAT,
GROQ_API_KEY: FAKE_GEMINI_KEY,
},
]
expect(redactSecretValueForDisplay(FAKE_OPENAI_KEY, ...sources)).toBe('sk-...def')
expect(redactSecretValueForDisplay(FAKE_GITHUB_PAT, ...sources)).toBe('ghp...hij')
expect(redactSecretValueForDisplay(FAKE_GEMINI_KEY, ...sources)).toBe('AIz...pqr')
})
test('redacts accepted access-token credential values outside setup descriptors', () => {
const token = 'gemini-access-token'
expect(redactSecretValueForDisplay(token, { GEMINI_ACCESS_TOKEN: token })).toBe('gem...ken')
})
test('redacts configured provider secrets after trimming source env values', () => {
const providerSecret = 'ogw-provider-secret'
expect(
redactSecretValueForDisplay(providerSecret, {
OPENGATEWAY_API_KEY: ` ${providerSecret} `,
}),
).toBe('ogw...ret')
})
})
describe('redactSecretSubstringsForDisplay', () => {
test('redacts configured provider secrets embedded in longer messages', () => {
const message = `Provider rejected API key ${FAKE_OPENAI_KEY} for this request`
const redacted = redactSecretSubstringsForDisplay(message, {
OPENAI_API_KEY: FAKE_OPENAI_KEY,
})
expect(redacted).toBe('Provider rejected API key sk-...def for this request')
expect(redacted).not.toContain(FAKE_OPENAI_KEY)
})
test('redacts secret-shaped values embedded in longer messages', () => {
const leakedKey = 'sk-liveLeakToken1234567890ABCdef'
const redacted = redactSecretSubstringsForDisplay(
`Invalid API key: ${leakedKey}`,
)
expect(redacted).toBe('Invalid API key: sk-...def')
expect(redacted).not.toContain(leakedKey)
})
test('redacts JWT-shaped values embedded in longer messages', () => {
const redacted = redactSecretSubstringsForDisplay(
`Authentication failed: ${FAKE_JWT_TOKEN} is invalid`,
)
expect(redacted).toBe('Authentication failed: eyJ...w5c is invalid')
expect(redacted).not.toContain(FAKE_JWT_TOKEN)
})
})
describe('sanitizeProviderConfigValue', () => {
test('returns undefined for empty input', () => {
expect(sanitizeProviderConfigValue(undefined)).toBeUndefined()
expect(sanitizeProviderConfigValue('')).toBeUndefined()
})
test('returns undefined for known secret values', () => {
expect(
sanitizeProviderConfigValue(FAKE_OPENAI_KEY, { OPENAI_API_KEY: FAKE_OPENAI_KEY }),
).toBeUndefined()
})
test('returns undefined for known secret values after trimming source env values', () => {
const providerSecret = 'ogw-provider-secret'
expect(
sanitizeProviderConfigValue(providerSecret, {
OPENGATEWAY_API_KEY: ` ${providerSecret} `,
}),
).toBeUndefined()
})
test('returns undefined for secret-shaped raw values', () => {
expect(sanitizeProviderConfigValue('sk-looks-like-a-key-1234567890')).toBeUndefined()
expect(sanitizeProviderConfigValue('sk-ant-looks-like-a-key-1234567890')).toBeUndefined()
expect(sanitizeProviderConfigValue('AIzaSySomeGeminiKey1234567890abcdefghijklmnopqr')).toBeUndefined()
expect(sanitizeProviderConfigValue(FAKE_GITHUB_PAT)).toBeUndefined()
expect(sanitizeProviderConfigValue(FAKE_JWT_TOKEN)).toBeUndefined()
})
test('keeps non-secret config values visible', () => {
expect(sanitizeProviderConfigValue('gpt-4o', { OPENAI_API_KEY: FAKE_OPENAI_KEY })).toBe('gpt-4o')
expect(sanitizeProviderConfigValue('https://api.openai.com/v1')).toBe('https://api.openai.com/v1')
expect(sanitizeProviderConfigValue('Qwen3-Coder-480B-A35B-Instruct')).toBe('Qwen3-Coder-480B-A35B-Instruct')
})
})
+157 -12
View File
@@ -1,17 +1,78 @@
const SECRET_ENV_KEYS = [
import type { ProviderPresetManifestEntry } from '../integrations/descriptors.js'
import {
ANTHROPIC_PROXY_DESCRIPTORS,
GATEWAY_DESCRIPTORS,
PROVIDER_PRESET_MANIFEST,
VENDOR_DESCRIPTORS,
} from '../integrations/generated/integrationArtifacts.generated.js'
// Manually-curated fallback. Kept defensive for legacy and OAuth/token
// credential paths that either predate descriptors or are accepted by
// provider-specific auth helpers outside setup.credentialEnvVars.
const FALLBACK_SECRET_ENV_KEYS: readonly string[] = [
'OPENAI_API_KEY',
'OPENAI_AUTH_HEADER_VALUE',
'CODEX_API_KEY',
'GEMINI_API_KEY',
'GOOGLE_API_KEY',
'GEMINI_ACCESS_TOKEN',
'MISTRAL_API_KEY',
'BNKR_API_KEY',
'XAI_API_KEY',
] as const
]
export type SecretValueSource = Partial<
Record<(typeof SECRET_ENV_KEYS)[number], string | undefined>
>
function readDescriptorCredentialEnvKeys(): readonly string[] {
const keys = new Set<string>()
const presets: readonly ProviderPresetManifestEntry[] = PROVIDER_PRESET_MANIFEST
for (const preset of presets) {
for (const key of preset.apiKeyEnvVars ?? []) {
if (key) keys.add(key)
}
}
const descriptorsWithSetup = [
...VENDOR_DESCRIPTORS,
...GATEWAY_DESCRIPTORS,
...(ANTHROPIC_PROXY_DESCRIPTORS as readonly { setup?: { credentialEnvVars?: readonly string[] } }[]),
]
for (const descriptor of descriptorsWithSetup) {
for (const key of descriptor.setup?.credentialEnvVars ?? []) {
if (key) keys.add(key)
}
const validation = (descriptor as { validation?: { credentialEnvVars?: readonly string[] } }).validation
for (const key of validation?.credentialEnvVars ?? []) {
if (key) keys.add(key)
}
}
return [...keys]
}
let cachedKnownSecretKeys: readonly string[] | null = null
/**
* Every environment variable name that the integration registry declares as
* holding a provider credential. Used to decide which display values must be
* redacted. Derived from PROVIDER_PRESET_MANIFEST plus descriptor setup and
* validation metadata so adding a new provider cannot silently create an
* unredacted path.
*/
export function getKnownProviderSecretEnvKeys(): readonly string[] {
if (cachedKnownSecretKeys) return cachedKnownSecretKeys
const merged = new Set<string>(FALLBACK_SECRET_ENV_KEYS)
for (const key of readDescriptorCredentialEnvKeys()) {
merged.add(key)
}
cachedKnownSecretKeys = Object.freeze([...merged])
return cachedKnownSecretKeys
}
// Secret sources are intentionally open: a provider can declare new credential
// env vars at any time, and forcing callers through a closed union would
// re-introduce the drift this module exists to prevent.
export type SecretValueSource = Partial<Record<string, string | undefined>>
export function sanitizeApiKey(
key: string | null | undefined,
@@ -20,31 +81,88 @@ export function sanitizeApiKey(
return key
}
// Heuristic masks for secret-shaped values whose env var name is not known.
// These catch values that slipped into display fields through unexpected paths
// (profile files, custom base URLs with embedded tokens, hand-edited configs).
const SECRET_PREFIX_PATTERNS = [
/^sk-/,
/^sk-ant-/,
/^AIza/,
/^ghp_/,
/^gho_/,
/^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
const JWT_SUBSTRING_PATTERN =
/\b[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g
function looksLikeSecretValue(value: string): boolean {
const trimmed = value.trim()
if (!trimmed) return false
if (trimmed.startsWith('sk-') || trimmed.startsWith('sk-ant-')) {
for (const pattern of SECRET_PREFIX_PATTERNS) {
if (pattern.test(trimmed)) return true
}
return looksLikeOpaqueToken(trimmed)
}
// Opaque provider tokens are typically long, mixed-case alphanumeric payloads,
// sometimes with short prefix segments separated by dashes/underscores.
function looksLikeOpaqueToken(value: string): boolean {
if (value.length < 24) return false
if (value.includes('://')) return false
if (value.includes(' ')) return false
if (value.includes('/')) return false
if (/^[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}$/.test(value)) {
return true
}
if (trimmed.startsWith('AIza')) {
return true
for (const ch of value) {
const isAllowed =
(ch >= 'a' && ch <= 'z') ||
(ch >= 'A' && ch <= 'Z') ||
(ch >= '0' && ch <= '9') ||
ch === '-' ||
ch === '_'
if (!isAllowed) return false
}
return false
return value
.split(/[-_]+/)
.some(segment => segment.length >= 16 && hasLowerUpperDigit(segment))
}
function hasLowerUpperDigit(value: string): boolean {
let hasLower = false
let hasUpper = false
let hasDigit = false
for (const ch of value) {
if (ch >= 'a' && ch <= 'z') hasLower = true
else if (ch >= 'A' && ch <= 'Z') hasUpper = true
else if (ch >= '0' && ch <= '9') hasDigit = true
}
return hasLower && hasUpper && hasDigit
}
function collectSecretValues(
sources: Array<SecretValueSource | null | undefined>,
): string[] {
const knownKeys = getKnownProviderSecretEnvKeys()
const values = new Set<string>()
for (const source of sources) {
if (!source) continue
for (const key of SECRET_ENV_KEYS) {
const value = sanitizeApiKey(source[key])
for (const key of knownKeys) {
const value = sanitizeApiKey(source[key])?.trim()
if (value) {
values.add(value)
}
@@ -74,7 +192,7 @@ export function redactSecretValueForDisplay(
if (!value) return undefined
const trimmed = value.trim()
if (!trimmed) return trimmed
if (!trimmed) return value
const secretValues = collectSecretValues(sources)
if (secretValues.includes(trimmed) || looksLikeSecretValue(trimmed)) {
@@ -84,6 +202,33 @@ export function redactSecretValueForDisplay(
return trimmed
}
export function redactSecretSubstringsForDisplay(
value: string | null | undefined,
...sources: Array<SecretValueSource | null | undefined>
): string | undefined {
if (!value) return undefined
let redacted = value
const secretValues = collectSecretValues(sources).sort(
(a, b) => b.length - a.length,
)
for (const secretValue of secretValues) {
const mask = maskSecretForDisplay(secretValue) ?? 'configured'
redacted = redacted.split(secretValue).join(mask)
}
redacted = redacted.replace(
SECRET_PREFIX_SUBSTRING_PATTERN,
match => maskSecretForDisplay(match) ?? 'configured',
)
redacted = redacted.replace(
JWT_SUBSTRING_PATTERN,
match => maskSecretForDisplay(match) ?? 'configured',
)
return redacted
}
export function sanitizeProviderConfigValue(
value: string | null | undefined,
...sources: Array<SecretValueSource | null | undefined>
+22
View File
@@ -16,6 +16,8 @@ const ENV_KEYS = [
'OPENAI_BASE_URL',
'OPENAI_MODEL',
'CODEX_API_KEY',
'CODEX_AUTH_JSON_PATH',
'CODEX_HOME',
'CHATGPT_ACCOUNT_ID',
'CODEX_ACCOUNT_ID',
'CLAUDE_CODE_USE_GITHUB',
@@ -23,11 +25,13 @@ const ENV_KEYS = [
'GH_TOKEN',
'CLAUDE_CODE_USE_GEMINI',
'CLAUDE_CODE_USE_MISTRAL',
'CLAUDE_CODE_SIMPLE',
'MISTRAL_API_KEY',
'MINIMAX_API_KEY',
'NVIDIA_API_KEY',
'NVIDIA_NIM',
'BNKR_API_KEY',
'OPENGATEWAY_API_KEY',
'OPENROUTER_API_KEY',
'DEEPSEEK_API_KEY',
'MOONSHOT_API_KEY',
@@ -132,6 +136,24 @@ test('openai missing key error includes recovery guidance and config locations',
expect(message!).toContain('Saved startup settings can come from')
})
test('codex auth error redacts descriptor-declared provider secret values used as model text', async () => {
const providerSecret = 'ogw-provider-secret'
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.CLAUDE_CODE_SIMPLE = '1'
process.env.CODEX_AUTH_JSON_PATH = `/tmp/openclaude-provider-validation-missing-auth-${process.pid}.json`
process.env.OPENAI_BASE_URL = 'https://chatgpt.com/backend-api/codex'
process.env.OPENAI_MODEL = providerSecret
process.env.OPENGATEWAY_API_KEY = providerSecret
delete process.env.CODEX_API_KEY
delete process.env.CHATGPT_ACCOUNT_ID
delete process.env.CODEX_ACCOUNT_ID
const message = await getProviderValidationError(process.env)
expect(message).not.toBeNull()
expect(message!).toContain('Codex auth is required for ogw...ret')
expect(message!).not.toContain(providerSecret)
})
test('mistral validation is descriptor-backed and requires MISTRAL_API_KEY', async () => {
process.env.CLAUDE_CODE_USE_MISTRAL = '1'
delete process.env.MISTRAL_API_KEY
+1 -8
View File
@@ -420,14 +420,7 @@ export async function getProviderValidationError(
hasStoredXaiOAuthCredentials?: () => Promise<boolean>
},
): Promise<string | null> {
const secretSource: SecretValueSource = {
OPENAI_API_KEY: env.OPENAI_API_KEY,
CODEX_API_KEY: env.CODEX_API_KEY,
GEMINI_API_KEY: env.GEMINI_API_KEY,
GOOGLE_API_KEY: env.GOOGLE_API_KEY,
MISTRAL_API_KEY: env.MISTRAL_API_KEY,
BNKR_API_KEY: env.BNKR_API_KEY,
}
const secretSource = env as SecretValueSource
const useOpenAI = isEnvTruthy(env.CLAUDE_CODE_USE_OPENAI)
const validationTarget = getRuntimeValidationTarget(env)
+10
View File
@@ -98,3 +98,13 @@ test('buildAPIProviderProperties keeps Codex-specific labels on the shared OpenA
)
expect(await readPropertyValue('Model', 'codex')).toBe('gpt-5.5 (high)')
})
test('buildAPIProviderProperties redacts credentials in OpenAI-compatible base URLs', async () => {
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.OPENAI_BASE_URL =
'https://user:pass@example.com/v1?api_key=sk-statusLeak1234567890ABCdef&model=qwen'
expect(await readPropertyValue('OpenAI base URL', 'openai')).toBe(
'https://redacted:redacted@example.com/v1?api_key=redacted&model=qwen',
)
})
+34 -28
View File
@@ -21,7 +21,8 @@ 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 { getKnownProviderSecretEnvKeys, redactSecretValueForDisplay, type SecretValueSource } from './providerSecrets.js';
import { redactUrlForDisplay } from './urlRedaction.js';
export type Property = {
label?: string;
value: React.ReactNode | Array<string>;
@@ -114,6 +115,24 @@ function pushRedactedProperty(
value: redactSecretValueForDisplay(value, secretSource) ?? value
});
}
function pushRedactedBaseUrlProperty(
properties: Property[],
label: string,
value: string | undefined,
secretSource: SecretValueSource,
): void {
if (!value) {
return;
}
pushRedactedProperty(
properties,
label,
redactUrlForDisplay(value),
secretSource,
);
}
export function buildSandboxProperties(): Property[] {
if (process.env.USER_TYPE !== 'ant') {
return [];
@@ -329,14 +348,13 @@ export function buildAccountProperties(): Property[] {
export function buildAPIProviderProperties(): Property[] {
const apiProvider = getAPIProvider();
const properties: Property[] = [];
const secretSource: SecretValueSource = {
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
CODEX_API_KEY: process.env.CODEX_API_KEY,
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
GOOGLE_API_KEY: process.env.GOOGLE_API_KEY,
BNKR_API_KEY: process.env.BNKR_API_KEY,
MISTRAL_API_KEY: process.env.MISTRAL_API_KEY
};
const secretSource: SecretValueSource = {};
for (const key of getKnownProviderSecretEnvKeys()) {
const envValue = process.env[key];
if (envValue !== undefined) {
secretSource[key] = envValue;
}
}
if (apiProvider !== 'firstParty') {
const providerLabel = API_PROVIDER_LABELS[apiProvider];
properties.push({
@@ -347,18 +365,12 @@ export function buildAPIProviderProperties(): Property[] {
if (apiProvider === 'firstParty') {
const anthropicBaseUrl = process.env.ANTHROPIC_BASE_URL;
if (anthropicBaseUrl) {
properties.push({
label: 'Anthropic base URL',
value: anthropicBaseUrl
});
pushRedactedBaseUrlProperty(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
});
pushRedactedBaseUrlProperty(properties, 'Bedrock base URL', bedrockBaseUrl, secretSource);
}
properties.push({
label: 'AWS region',
@@ -372,10 +384,7 @@ 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
});
pushRedactedBaseUrlProperty(properties, 'Vertex base URL', vertexBaseUrl, secretSource);
}
const gcpProject = process.env.ANTHROPIC_VERTEX_PROJECT_ID;
if (gcpProject) {
@@ -396,10 +405,7 @@ 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
});
pushRedactedBaseUrlProperty(properties, 'Microsoft Foundry base URL', foundryBaseUrl, secretSource);
}
const foundryResource = process.env.ANTHROPIC_FOUNDRY_RESOURCE;
if (foundryResource) {
@@ -416,7 +422,7 @@ export function buildAPIProviderProperties(): Property[] {
} else if (apiProvider in OPENAI_COMPATIBLE_STATUS_METADATA) {
const metadata =
OPENAI_COMPATIBLE_STATUS_METADATA[apiProvider]!;
pushRedactedProperty(
pushRedactedBaseUrlProperty(
properties,
metadata.baseUrlLabel,
process.env.OPENAI_BASE_URL,
@@ -437,12 +443,12 @@ export function buildAPIProviderProperties(): Property[] {
}
} else if (apiProvider === 'gemini') {
const geminiBaseUrl = process.env.GEMINI_BASE_URL;
pushRedactedProperty(properties, 'Gemini base URL', geminiBaseUrl, secretSource);
pushRedactedBaseUrlProperty(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);
pushRedactedBaseUrlProperty(properties, 'Mistral base URL', mistralBaseUrl, secretSource);
const mistralModel = process.env.MISTRAL_MODEL;
pushRedactedProperty(properties, 'Model', mistralModel, secretSource);
}