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