Files
openclaude/scripts/system-check.test.ts
JATMNandGitHub ff91642364 feat(integrations): add ApiSmart OpenAI-compatible gateway provider (#2109)
* feat(integrations): add ApiSmart OpenAI-compatible gateway provider

Add a hybrid-catalog ApiSmart gateway with dedicated APISMART_API_KEY/APISMART_MODEL
env wiring, route detection, profile persistence, and regression tests so the
provider works via --provider, env-only setup, and saved profiles.

* fix(apismart): protect dedicated credentials

* fix(apismart): enforce route credential boundaries

* fix(apismart): honor explicit competing route models

* fix(apismart): validate credentials and default profiles

* test(apismart): isolate env-only client tests

* fix(apismart): align routing and validation contracts

* fix(apismart): centralize route capability contracts

* Revert "fix(apismart): centralize route capability contracts"

This reverts commit f8cd975e68.

* fix(apismart): reject template credential placeholders at the shared root

Expand the shared credential usability helpers so dotenv sentinels like
null/undefined cannot win ApiSmart env-only precedence or get mirrored into
OPENAI_API_KEY, and restore focused ApiSmart docs after dropping the
shared routing centralization.

* fix(apismart): reject null base-URL and placeholder profile keys

Treat dotenv `null`/`undefined` OPENAI_BASE_URL sentinels as unset so
--provider apismart can apply defaults, and align profile/validation
credential checks with the shared placeholder contract.

* fix(apismart): match AIMLAPI env-only intent and proxy credential withholding

Use the OpenAI-compatible env-only gate so lingering CLAUDE_CODE_USE_OPENAI still keeps ApiSmart identity, retain route id on retargeted profiles, and withhold ambient credentials on non-canonical relaunches.

* fix(apismart): restore AIMLAPI credential and canonical URL parity

Backfill APISMART_API_KEY on relaunch and keyless canonical profiles, and gate credential forwarding on an exact /v1 inference URL so dedicatedCredentialsOnly auth and ambient keys stay aligned with AIMLAPI.

* fix: simplify apismart gateway integration

* fix: reject credential placeholders consistently

* test: remove obsolete apismart exception coverage

* test: remove stale apismart model fixture

* fix(apismart): restore dedicated provider contract

* fix(apismart): enforce credential boundaries

* fix(apismart): clear inherited auth headers

* test(apismart): strengthen credential boundaries
2026-08-11 19:08:38 +08:00

1016 lines
33 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { mkdtempSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import {
buildMemoryGuardChecks,
buildSandboxRuntimeCheck,
checkWebSearchEnv,
checkOpenAIEnv,
checkNodeVersion,
formatReachabilityFailureDetail,
isCliSandboxRuntimeStubbed,
readNodeExecutableVersion,
serializeSafeEnvSummary,
} from './system-check.ts'
import { DEFAULT_MAX_ACTIVE_MESSAGES_HARD_CAP } from '../src/utils/maxActiveMessages.ts'
import { resetSettingsCache } from '../src/utils/settings/settingsCache.ts'
const ENV_KEYS = [
'CLAUDE_CODE_USE_OPENAI',
'CLAUDE_CODE_USE_GITHUB',
'CLAUDE_CODE_USE_GEMINI',
'CLAUDE_CODE_USE_MISTRAL',
'CLAUDE_CODE_USE_BEDROCK',
'CLAUDE_CODE_USE_VERTEX',
'CLAUDE_CODE_USE_FOUNDRY',
'CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED',
'CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID',
'CLAUDE_CODE_PROVIDER_ROUTE_ID',
'CLAUDE_CODE_DEFAULT_STARTUP_PROVIDER',
'CLAUDE_CODE_SIMPLE',
'ANTHROPIC_BASE_URL',
'ANTHROPIC_MODEL',
'ANTHROPIC_API_KEY',
'ANTHROPIC_CUSTOM_HEADERS',
'ANTHROPIC_BEDROCK_BASE_URL',
'ANTHROPIC_VERTEX_BASE_URL',
'ANTHROPIC_DEFAULT_OPUS_MODEL',
'ANTHROPIC_DEFAULT_SONNET_MODEL',
'GEMINI_API_KEY',
'GOOGLE_API_KEY',
'GEMINI_MODEL',
'GEMINI_BASE_URL',
'GEMINI_AUTH_MODE',
'GEMINI_ACCESS_TOKEN',
'MISTRAL_API_KEY',
'MISTRAL_MODEL',
'MISTRAL_BASE_URL',
'OPENAI_MODEL',
'OPENAI_BASE_URL',
'OPENAI_API_BASE',
'OPENAI_API_FORMAT',
'OPENAI_AUTH_HEADER',
'OPENAI_AUTH_SCHEME',
'OPENAI_AUTH_HEADER_VALUE',
'OPENAI_API_KEYS',
'OPENAI_API_KEY',
'OPENGATEWAY_API_KEY',
'GITHUB_TOKEN',
'GH_TOKEN',
'GITHUB_COPILOT_KEY',
'GITHUB_ENTERPRISE_URL',
'CODEX_API_KEY',
'CODEX_CREDENTIAL_SOURCE',
'CODEX_AUTH_JSON_PATH',
'CODEX_HOME',
'CHATGPT_ACCOUNT_ID',
'CODEX_ACCOUNT_ID',
'NVIDIA_NIM',
'NVIDIA_API_KEY',
'NVIDIA_MODEL',
'MINIMAX_API_KEY',
'MINIMAX_BASE_URL',
'MINIMAX_MODEL',
'BANKR_BASE_URL',
'BNKR_API_KEY',
'BANKR_MODEL',
'XAI_API_KEY',
'XAI_CREDENTIAL_SOURCE',
'AIMLAPI_API_KEY',
'VENICE_API_KEY',
'MIMO_API_KEY',
'ATLAS_CLOUD_API_KEY',
'APISMART_API_KEY',
'APISMART_MODEL',
'NEARAI_API_KEY',
'FIREWORKS_API_KEY',
'CLINE_API_KEY',
'OPENCODE_API_KEY',
'DISABLE_COMPACT',
'DISABLE_AUTO_COMPACT',
'OPENCLAUDE_MAX_ACTIVE_MESSAGES',
'OPENCLAUDE_MAX_ACTIVE_MESSAGES_HARD_CAP',
'OPENCLAUDE_MAX_MEMORY_MB',
'OPENCLAUDE_CONFIG_DIR',
'WEB_SEARCH_PROVIDER',
'WEB_SEARCH_TIMEOUT_SEC',
'WEB_SEARCH_API',
'WEB_PROVIDER',
'WEB_URL_TEMPLATE',
'WEB_KEY',
'GOOGLE_CSE_ID',
'FIRECRAWL_API_KEY',
'FIRECRAWL_API_URL',
'TAVILY_API_KEY',
'EXA_API_KEY',
'YOU_API_KEY',
'JINA_API_KEY',
'BRAVE_API_KEY',
'BING_API_KEY',
'MOJEEK_API_KEY',
'LINKUP_API_KEY',
] as const
const originalEnv: Record<string, string | undefined> = {}
let tempConfigDir: string | undefined
beforeEach(() => {
for (const key of ENV_KEYS) {
originalEnv[key] = process.env[key]
delete process.env[key]
}
tempConfigDir = mkdtempSync(join(tmpdir(), 'openclaude-system-check-'))
process.env.OPENCLAUDE_CONFIG_DIR = tempConfigDir
resetSettingsCache()
})
afterEach(() => {
for (const key of ENV_KEYS) {
if (originalEnv[key] === undefined) {
delete process.env[key]
} else {
process.env[key] = originalEnv[key]
}
}
resetSettingsCache()
if (tempConfigDir) {
rmSync(tempConfigDir, { recursive: true, force: true })
tempConfigDir = undefined
}
})
describe('formatReachabilityFailureDetail', () => {
test('returns generic failure detail for non-codex transport', () => {
const detail = formatReachabilityFailureDetail(
'https://api.openai.com/v1/models',
429,
'{"error":"rate_limit"}',
{
transport: 'chat_completions',
requestedModel: 'gpt-4o',
resolvedModel: 'gpt-4o',
},
)
expect(detail).toBe(
'Unexpected status 429 from https://api.openai.com/v1/models. Body: {"error":"rate_limit"}',
)
})
test('redacts credentials and sensitive query parameters in endpoint details', () => {
const detail = formatReachabilityFailureDetail(
'http://user:pass@localhost:11434/v1/models?token=abc123&mode=test',
502,
'bad gateway',
{
transport: 'chat_completions',
requestedModel: 'llama3.1:8b',
resolvedModel: 'llama3.1:8b',
},
)
expect(detail).toBe(
'Unexpected status 502 from http://redacted:redacted@localhost:11434/v1/models?token=redacted&mode=test. Body: bad gateway',
)
})
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',
400,
'{"detail":"The \\"gpt-5.3-codex-spark\\" model is not supported when using Codex with a ChatGPT account."}',
{
transport: 'codex_responses',
requestedModel: 'codexspark',
resolvedModel: 'gpt-5.3-codex-spark',
},
)
expect(detail).toContain(
'model alias "codexspark" resolved to "gpt-5.3-codex-spark"',
)
expect(detail).toContain(
'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_KEYS or OPENAI_API_KEY',
)
expect(credentialResult).toEqual({
ok: false,
label: 'OPENAI_API_KEYS or OPENAI_API_KEY',
detail:
'Missing key for non-local provider URL. Set OPENAI_API_KEYS or OPENAI_API_KEY.',
})
expect(summary.PROVIDER_API_KEY_SET).toBe(false)
})
test('falls back to OPENAI_API_KEY when OPENAI_API_KEYS is delimiter-only', () => {
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1'
process.env.OPENAI_MODEL = 'gpt-4o'
process.env.OPENAI_API_KEYS = ', ,'
process.env.OPENAI_API_KEY = 'sk-openai-single'
const results = checkOpenAIEnv()
const summary = serializeSafeEnvSummary()
const credentialResult = results.find(
result => result.label === 'OPENAI_API_KEYS or OPENAI_API_KEY',
)
expect(credentialResult).toEqual({
ok: true,
label: 'OPENAI_API_KEYS or OPENAI_API_KEY',
detail: 'Configured.',
})
expect(summary.PROVIDER_API_KEY_SET).toBe(true)
})
test('accepts valid OPENAI_API_KEYS before placeholder OPENAI_API_KEY fallback', () => {
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1'
process.env.OPENAI_MODEL = 'gpt-4o'
process.env.OPENAI_API_KEYS = 'sk-openai-a,sk-openai-b'
process.env.OPENAI_API_KEY = 'SUA_CHAVE'
const results = checkOpenAIEnv()
const summary = serializeSafeEnvSummary()
const credentialResult = results.find(
result => result.label === 'OPENAI_API_KEYS or OPENAI_API_KEY',
)
expect(credentialResult).toEqual({
ok: true,
label: 'OPENAI_API_KEYS or OPENAI_API_KEY',
detail: 'Configured.',
})
expect(summary.PROVIDER_API_KEY_SET).toBe(true)
})
test('rejects placeholder values inside OPENAI_API_KEYS pools', () => {
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1'
process.env.OPENAI_MODEL = 'gpt-4o'
process.env.OPENAI_API_KEYS = 'sk-openai-a,SUA_CHAVE'
delete process.env.OPENAI_API_KEY
const results = checkOpenAIEnv()
const credentialResult = results.find(
result => result.label === 'OPENAI_API_KEYS or OPENAI_API_KEY',
)
expect(credentialResult).toEqual({
ok: false,
label: 'OPENAI_API_KEYS or OPENAI_API_KEY',
detail: 'Placeholder value detected: SUA_CHAVE.',
})
})
})
describe('system-check WebSearch diagnostics', () => {
const reliableBackendHint =
'FIRECRAWL_API_KEY, TAVILY_API_KEY, EXA_API_KEY, YOU_API_KEY, JINA_API_KEY, BRAVE_API_KEY, BING_API_KEY, MOJEEK_API_KEY, or LINKUP_API_KEY'
function expectWebSearchBackend(
ok: boolean,
detail: string,
timeoutSeconds: string | false = '15',
) {
expect(checkWebSearchEnv()).toEqual([
{
ok,
label: 'Web search backend',
detail: timeoutSeconds === false
? detail
: `${detail} Built-in provider timeout: ${timeoutSeconds}s.`,
},
])
}
function useOpenAICompatibleProvider() {
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.OPENAI_MODEL = 'gpt-4o'
process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1'
}
function useOpenAICompatibleProviderWithoutModel() {
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1'
}
test('reports auto mode using native first-party search before adapters', () => {
expectWebSearchBackend(
true,
'WEB_SEARCH_PROVIDER=auto; firstParty native web search will be used before adapter providers.',
false,
)
})
test('reports configured API-backed providers when auto mode uses native search first', () => {
process.env.BRAVE_API_KEY = 'brave-secret-value-123'
expectWebSearchBackend(
true,
'WEB_SEARCH_PROVIDER=auto; firstParty native web search will be used before adapter providers. Configured API-backed providers: brave.',
false,
)
})
test('fails auto mode for unsupported Vertex native model instead of claiming DuckDuckGo fallback', () => {
process.env.CLAUDE_CODE_USE_VERTEX = '1'
process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'claude-3-7-sonnet@20250219'
expectWebSearchBackend(
false,
`WEB_SEARCH_PROVIDER=auto selected, but vertex model claude-3-7-sonnet@20250219 does not support native web search and runtime will not use adapter providers in auto mode. Use a Claude 4 Vertex model or set an explicit WEB_SEARCH_PROVIDER adapter mode with ${reliableBackendHint}.`,
false,
)
})
test('fails auto mode for unsupported Vertex native model even when adapter keys are configured', () => {
process.env.CLAUDE_CODE_USE_VERTEX = '1'
process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'claude-3-7-sonnet@20250219'
process.env.BRAVE_API_KEY = 'brave-secret-value-123'
expectWebSearchBackend(
false,
`WEB_SEARCH_PROVIDER=auto selected, but vertex model claude-3-7-sonnet@20250219 does not support native web search and runtime will not use adapter providers in auto mode. Use a Claude 4 Vertex model or set an explicit WEB_SEARCH_PROVIDER adapter mode with ${reliableBackendHint}. Configured API-backed providers: brave.`,
false,
)
})
test('reports auto mode with only DuckDuckGo fallback available', () => {
useOpenAICompatibleProvider()
expectWebSearchBackend(
true,
`WEB_SEARCH_PROVIDER=auto; only DuckDuckGo fallback is available. DuckDuckGo scraping can be rate-limited from datacenter/VPN/repeated-request networks. Configure ${reliableBackendHint} for reliable search.`,
)
})
test('uses the runtime OpenAI default model in auto mode when OPENAI_MODEL is unset', () => {
useOpenAICompatibleProviderWithoutModel()
expectWebSearchBackend(
true,
`WEB_SEARCH_PROVIDER=auto; only DuckDuckGo fallback is available. DuckDuckGo scraping can be rate-limited from datacenter/VPN/repeated-request networks. Configure ${reliableBackendHint} for reliable search.`,
)
})
test('reports the configured built-in provider timeout', () => {
useOpenAICompatibleProvider()
process.env.WEB_SEARCH_TIMEOUT_SEC = '30'
expectWebSearchBackend(
true,
`WEB_SEARCH_PROVIDER=auto; only DuckDuckGo fallback is available. DuckDuckGo scraping can be rate-limited from datacenter/VPN/repeated-request networks. Configure ${reliableBackendHint} for reliable search.`,
'30',
)
})
test('reports Firecrawl cloud URL without an API key in auto mode with DuckDuckGo fallback', () => {
useOpenAICompatibleProvider()
process.env.FIRECRAWL_API_URL = 'https://api.firecrawl.dev'
expectWebSearchBackend(
true,
`WEB_SEARCH_PROVIDER=auto; only DuckDuckGo fallback is available. DuckDuckGo scraping can be rate-limited from datacenter/VPN/repeated-request networks. Configure ${reliableBackendHint} for reliable search. FIRECRAWL_API_URL points to the Firecrawl cloud API but FIRECRAWL_API_KEY is missing; runtime will try firecrawl first and then fall through to the next provider in auto mode.`,
)
})
test('reports Firecrawl cloud URL without an API key in auto mode with a later API fallback', () => {
useOpenAICompatibleProvider()
process.env.FIRECRAWL_API_URL = 'https://api.firecrawl.dev'
process.env.BRAVE_API_KEY = 'brave-secret-value-123'
expectWebSearchBackend(
true,
'WEB_SEARCH_PROVIDER=auto; configured providers: brave; fallback includes duckduckgo. FIRECRAWL_API_URL points to the Firecrawl cloud API but FIRECRAWL_API_KEY is missing; runtime will try firecrawl first and then fall through to the next provider in auto mode.',
)
})
test('reports configured API-backed providers in auto mode', () => {
useOpenAICompatibleProvider()
process.env.BRAVE_API_KEY = 'brave-secret-value-123'
process.env.EXA_API_KEY = 'exa-secret-value-123'
expectWebSearchBackend(
true,
'WEB_SEARCH_PROVIDER=auto; configured providers: exa, brave; fallback includes duckduckgo.',
)
})
test('fails explicit provider mode when required credentials are missing', () => {
process.env.WEB_SEARCH_PROVIDER = 'brave'
expectWebSearchBackend(
false,
'WEB_SEARCH_PROVIDER=brave but BRAVE_API_KEY is missing.',
)
})
test('passes explicit provider mode when required credentials are configured', () => {
process.env.WEB_SEARCH_PROVIDER = 'brave'
process.env.BRAVE_API_KEY = 'brave-secret-value-123'
expectWebSearchBackend(
true,
'WEB_SEARCH_PROVIDER=brave; BRAVE_API_KEY configured.',
)
})
test('reports supported native mode without requiring API-backed provider credentials', () => {
process.env.WEB_SEARCH_PROVIDER = 'native'
expectWebSearchBackend(
true,
'WEB_SEARCH_PROVIDER=native selected; firstParty provider supports native web search.',
false,
)
})
test('reports configured API-backed providers when native mode is selected', () => {
process.env.WEB_SEARCH_PROVIDER = 'native'
process.env.BRAVE_API_KEY = 'brave-secret-value-123'
expectWebSearchBackend(
true,
'WEB_SEARCH_PROVIDER=native selected; firstParty provider supports native web search. Configured API-backed providers: brave.',
false,
)
})
test('fails native mode when the active provider does not support native web search', () => {
process.env.WEB_SEARCH_PROVIDER = 'native'
useOpenAICompatibleProvider()
expectWebSearchBackend(
false,
`WEB_SEARCH_PROVIDER=native selected, but openai provider does not support native web search. Configure ${reliableBackendHint}, or switch to an Anthropic, Vertex, Foundry, or Codex responses provider.`,
false,
)
})
test('uses the runtime OpenAI default model in native mode when OPENAI_MODEL is unset', () => {
process.env.WEB_SEARCH_PROVIDER = 'native'
useOpenAICompatibleProviderWithoutModel()
expectWebSearchBackend(
false,
`WEB_SEARCH_PROVIDER=native selected, but openai provider does not support native web search. Configure ${reliableBackendHint}, or switch to an Anthropic, Vertex, Foundry, or Codex responses provider.`,
false,
)
})
test('fails native mode for Codex aliases when the runtime tool gate rejects that provider', () => {
process.env.WEB_SEARCH_PROVIDER = 'native'
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.OPENAI_MODEL = 'codexspark'
expectWebSearchBackend(
false,
`WEB_SEARCH_PROVIDER=native selected, but codex provider does not support native web search. Configure ${reliableBackendHint}, or switch to an Anthropic, Vertex, Foundry, or Codex responses provider.`,
false,
)
})
test('fails Firecrawl cloud mode when the API key is missing', () => {
process.env.WEB_SEARCH_PROVIDER = 'firecrawl'
process.env.FIRECRAWL_API_URL = 'https://api.firecrawl.dev'
expectWebSearchBackend(
false,
'WEB_SEARCH_PROVIDER=firecrawl but FIRECRAWL_API_KEY is missing for the Firecrawl cloud API.',
)
})
test('passes Firecrawl self-hosted mode without an API key', () => {
process.env.WEB_SEARCH_PROVIDER = 'firecrawl'
process.env.FIRECRAWL_API_URL = 'https://self-hosted.firecrawl.dev'
expectWebSearchBackend(
true,
'WEB_SEARCH_PROVIDER=firecrawl; FIRECRAWL_API_URL configured.',
)
})
test('does not classify Firecrawl proxy URLs as the cloud API', () => {
process.env.WEB_SEARCH_PROVIDER = 'firecrawl'
process.env.FIRECRAWL_API_URL = 'https://proxy.example.com/api.firecrawl.dev'
expectWebSearchBackend(
true,
'WEB_SEARCH_PROVIDER=firecrawl; FIRECRAWL_API_URL configured.',
)
})
test('fails custom Google preset when GOOGLE_CSE_ID is missing', () => {
process.env.WEB_SEARCH_PROVIDER = 'custom'
process.env.WEB_PROVIDER = 'google'
process.env.WEB_KEY = 'google-secret-value-123'
expectWebSearchBackend(
false,
'WEB_SEARCH_PROVIDER=custom with WEB_PROVIDER=google but GOOGLE_CSE_ID is missing.',
false,
)
})
test('fails custom Google preset when WEB_KEY is missing', () => {
process.env.WEB_SEARCH_PROVIDER = 'custom'
process.env.WEB_PROVIDER = 'google'
process.env.GOOGLE_CSE_ID = 'cse-test-id'
expectWebSearchBackend(
false,
'WEB_SEARCH_PROVIDER=custom with WEB_PROVIDER=google but WEB_KEY is missing.',
false,
)
})
test('passes custom Google preset when required preset credentials are configured', () => {
process.env.WEB_SEARCH_PROVIDER = 'custom'
process.env.WEB_PROVIDER = 'google'
process.env.WEB_KEY = 'google-secret-value-123'
process.env.GOOGLE_CSE_ID = 'cse-test-id'
expectWebSearchBackend(
true,
'WEB_SEARCH_PROVIDER=custom; WEB_PROVIDER, WEB_KEY, and GOOGLE_CSE_ID configured.',
false,
)
})
test('fails custom preset when WEB_PROVIDER has surrounding whitespace', () => {
process.env.WEB_SEARCH_PROVIDER = 'custom'
process.env.WEB_PROVIDER = 'brave '
process.env.WEB_KEY = 'brave-secret-value-123'
expectWebSearchBackend(
false,
'WEB_SEARCH_PROVIDER=custom with WEB_PROVIDER=brave but the raw WEB_PROVIDER value has surrounding whitespace and does not match a runtime custom preset. Remove the whitespace or configure WEB_SEARCH_API or WEB_URL_TEMPLATE.',
false,
)
})
test('passes custom provider with surrounding whitespace when a custom endpoint is configured', () => {
process.env.WEB_SEARCH_PROVIDER = 'custom'
process.env.WEB_PROVIDER = 'brave '
process.env.WEB_SEARCH_API = 'https://example.com/search'
expectWebSearchBackend(
true,
'WEB_SEARCH_PROVIDER=custom; WEB_PROVIDER and WEB_SEARCH_API configured.',
false,
)
})
test('does not expose WebSearch secret values in diagnostics', () => {
const secret = 'brave-secret-value-123'
process.env.WEB_SEARCH_PROVIDER = 'brave'
process.env.BRAVE_API_KEY = secret
const results = checkWebSearchEnv()
const serialized = JSON.stringify(results)
expect(serialized).toContain('BRAVE_API_KEY configured')
expect(serialized).not.toContain(secret)
})
})
describe('system-check memory guard diagnostics', () => {
test('reports explicit off without a legacy message-count override', () => {
const results = buildMemoryGuardChecks({
autoCompactEnabled: true,
maxMessagesCompactionThreshold: 'off',
env: {},
})
expect(results).toContainEqual({
ok: true,
label: 'Auto-compact guard',
detail: `Enabled; message-count threshold off; hard cap ${DEFAULT_MAX_ACTIVE_MESSAGES_HARD_CAP}.`,
})
})
test('reports the effective default auto-compact and hard-cap guards', () => {
const results = buildMemoryGuardChecks({
autoCompactEnabled: true,
maxMessagesCompactionThreshold: undefined,
env: {},
})
expect(results).toContainEqual({
ok: true,
label: 'Auto-compact guard',
detail: `Enabled; message-count threshold 200; hard cap ${DEFAULT_MAX_ACTIVE_MESSAGES_HARD_CAP}.`,
})
expect(results).toContainEqual({
ok: true,
label: 'Active-message hard cap',
detail: `Active at ${DEFAULT_MAX_ACTIVE_MESSAGES_HARD_CAP} messages (default; malformed overrides fall back to ${DEFAULT_MAX_ACTIVE_MESSAGES_HARD_CAP}).`,
})
expect(results.find(result => result.label === 'Memory pressure guard'))
.toMatchObject({ ok: true })
})
test('reports the legacy message-count override when the setting is unset or off', () => {
for (const maxMessagesCompactionThreshold of [undefined, 'off']) {
const results = buildMemoryGuardChecks({
autoCompactEnabled: true,
maxMessagesCompactionThreshold,
env: { OPENCLAUDE_MAX_ACTIVE_MESSAGES: '500' },
})
expect(results).toContainEqual({
ok: true,
label: 'Auto-compact guard',
detail: `Enabled; message-count threshold 500; hard cap ${DEFAULT_MAX_ACTIVE_MESSAGES_HARD_CAP}.`,
})
}
})
test('reports an explicit message-count setting ahead of the legacy override', () => {
const results = buildMemoryGuardChecks({
autoCompactEnabled: true,
maxMessagesCompactionThreshold: '100',
env: { OPENCLAUDE_MAX_ACTIVE_MESSAGES: '500' },
})
expect(results).toContainEqual({
ok: true,
label: 'Auto-compact guard',
detail: `Enabled; message-count threshold 100; hard cap ${DEFAULT_MAX_ACTIVE_MESSAGES_HARD_CAP}.`,
})
})
test('falls back to the default hard cap when the override is malformed', () => {
const results = buildMemoryGuardChecks({
autoCompactEnabled: true,
maxMessagesCompactionThreshold: undefined,
env: {
OPENCLAUDE_MAX_ACTIVE_MESSAGES_HARD_CAP: 'not-a-number',
},
})
expect(results).toContainEqual({
ok: true,
label: 'Active-message hard cap',
detail: `Active at ${DEFAULT_MAX_ACTIVE_MESSAGES_HARD_CAP} messages; malformed override fell back to ${DEFAULT_MAX_ACTIVE_MESSAGES_HARD_CAP}.`,
})
})
test('reports valid custom hard-cap overrides without fallback wording', () => {
const results = buildMemoryGuardChecks({
autoCompactEnabled: true,
maxMessagesCompactionThreshold: undefined,
env: {
OPENCLAUDE_MAX_ACTIVE_MESSAGES_HARD_CAP: '500',
},
})
expect(results).toContainEqual({
ok: true,
label: 'Active-message hard cap',
detail: 'Active at 500 messages.',
})
})
test('fails when auto-compact is disabled by settings or env flags', () => {
const results = buildMemoryGuardChecks({
autoCompactEnabled: false,
maxMessagesCompactionThreshold: '500',
env: {
DISABLE_COMPACT: '1',
DISABLE_AUTO_COMPACT: 'true',
},
})
expect(results[0]).toEqual({
ok: false,
label: 'Auto-compact guard',
detail:
'settings disabled; DISABLE_COMPACT is set; DISABLE_AUTO_COMPACT is set; message-count threshold 500 remains active.',
})
})
test('reports an explicit message-count guard when token auto-compact is disabled', () => {
const results = buildMemoryGuardChecks({
autoCompactEnabled: true,
maxMessagesCompactionThreshold: '100',
env: { DISABLE_AUTO_COMPACT: '1' },
})
expect(results[0]).toEqual({
ok: false,
label: 'Auto-compact guard',
detail: 'DISABLE_AUTO_COMPACT is set; message-count threshold 100 remains active.',
})
})
test('does not report the unset default as active when auto-compact is disabled', () => {
const results = buildMemoryGuardChecks({
autoCompactEnabled: true,
maxMessagesCompactionThreshold: undefined,
env: { DISABLE_AUTO_COMPACT: '1' },
})
expect(results[0]).toEqual({
ok: false,
label: 'Auto-compact guard',
detail: 'DISABLE_AUTO_COMPACT is set',
})
})
test('fails when active-message hard cap is explicitly disabled', () => {
const results = buildMemoryGuardChecks({
autoCompactEnabled: true,
maxMessagesCompactionThreshold: '100',
env: {
OPENCLAUDE_MAX_ACTIVE_MESSAGES_HARD_CAP: '0',
OPENCLAUDE_MAX_MEMORY_MB: '4096',
},
})
expect(results).toContainEqual({
ok: false,
label: 'Active-message hard cap',
detail:
'Disabled by OPENCLAUDE_MAX_ACTIVE_MESSAGES_HARD_CAP=0; long sessions can grow without the active-message safety cap.',
})
expect(results).toContainEqual({
ok: true,
label: 'Memory pressure guard',
detail:
'Per-session budget 4096MB; elevated/critical compaction thresholds are derived from this budget at runtime.',
})
})
})
describe('checkNodeVersion', () => {
test('reads the Node.js version from the node executable output', () => {
const probe = readNodeExecutableVersion(() => ({
status: 0,
stdout: 'v22.0.0\n',
stderr: '',
error: undefined,
}))
expect(probe).toEqual({
ok: true,
version: 'v22.0.0',
})
})
test('checks the probed node executable version', () => {
expect(checkNodeVersion({ ok: true, version: 'v20.11.1' })).toEqual({
ok: false,
label: 'Node.js version',
detail:
'Detected 20.11.1. OpenClaude requires Node.js >=22.0.0. Install Node 22 LTS or newer, then reinstall/re-run OpenClaude.',
})
})
test('reports a missing node executable as a Node.js version failure', () => {
const probe = readNodeExecutableVersion(() => ({
status: null,
stdout: '',
stderr: '',
error: new Error('spawn node ENOENT'),
}))
expect(checkNodeVersion(probe)).toEqual({
ok: false,
label: 'Node.js version',
detail:
'Unable to run `node --version`: spawn node ENOENT. OpenClaude requires Node.js >=22.0.0 on PATH.',
})
})
test('uses the shared Node.js minimum in doctor failures', () => {
expect(checkNodeVersion('20.11.1')).toEqual({
ok: false,
label: 'Node.js version',
detail:
'Detected 20.11.1. OpenClaude requires Node.js >=22.0.0. Install Node 22 LTS or newer, then reinstall/re-run OpenClaude.',
})
})
test('passes supported Node.js versions', () => {
expect(checkNodeVersion('22.0.0')).toEqual({
ok: true,
label: 'Node.js version',
detail: '22.0.0',
})
})
})
describe('sandbox runtime diagnostics', () => {
test('fails when sandbox runtime inspection throws an Error', () => {
const result = buildSandboxRuntimeCheck({
inspectionError: new Error('EACCES: permission denied, open dist/cli.mjs'),
})
expect(result).toEqual({
ok: false,
label: 'Sandbox runtime',
detail:
'Unable to inspect CLI sandbox runtime: EACCES: permission denied, open dist/cli.mjs',
})
})
test('fails when sandbox runtime inspection throws a non-Error value', () => {
const result = buildSandboxRuntimeCheck({
inspectionError: 'bundle read failed',
})
expect(result).toEqual({
ok: false,
label: 'Sandbox runtime',
detail: 'Unable to inspect CLI sandbox runtime: bundle read failed',
})
})
test('detects sandbox-runtime native stubs in the CLI bundle', () => {
expect(
isCliSandboxRuntimeStubbed(
'// native-stub:@anthropic-ai/sandbox-runtime\nconst noop = () => null',
),
).toBe(true)
expect(isCliSandboxRuntimeStubbed('bubblewrap (bwrap) not installed')).toBe(
false,
)
})
test('fails when the CLI bundle contains a sandbox runtime stub', () => {
const result = buildSandboxRuntimeCheck({
cliRuntimeStubbed: true,
sandboxEnabled: true,
failIfUnavailable: true,
sandboxingEnabled: false,
unavailableReason: 'sandbox.enabled is set but the runtime is stubbed',
})
expect(result.ok).toBe(false)
expect(result.label).toBe('Sandbox runtime')
expect(result.detail).toContain('CLI bundle: stubbed')
expect(result.detail).toContain('effective behavior: fail-closed')
expect(result.detail).toContain(
'reason: sandbox.enabled is set but the runtime is stubbed',
)
})
test('reports warning-only behavior when sandbox is enabled but unavailable', () => {
const result = buildSandboxRuntimeCheck({
cliRuntimeStubbed: false,
sandboxEnabled: true,
failIfUnavailable: false,
sandboxingEnabled: false,
unavailableReason: 'bubblewrap (bwrap) not installed',
})
expect(result.ok).toBe(true)
expect(result.detail).toContain('CLI bundle: real runtime')
expect(result.detail).toContain('effective behavior: warning-only')
expect(result.detail).toContain('reason: bubblewrap (bwrap) not installed')
})
test('flags fail-closed behavior when sandbox is required but unavailable', () => {
const result = buildSandboxRuntimeCheck({
cliRuntimeStubbed: false,
sandboxEnabled: true,
failIfUnavailable: true,
sandboxingEnabled: false,
unavailableReason: 'bubblewrap (bwrap) not installed',
})
expect(result.ok).toBe(false)
expect(result.detail).toContain('CLI bundle: real runtime')
expect(result.detail).toContain('effective behavior: fail-closed')
expect(result.detail).toContain('reason: bubblewrap (bwrap) not installed')
})
test('reports enforcing behavior when sandboxing is active', () => {
const result = buildSandboxRuntimeCheck({
cliRuntimeStubbed: false,
sandboxEnabled: true,
failIfUnavailable: true,
sandboxingEnabled: true,
})
expect(result.ok).toBe(true)
expect(result.detail).toBe(
'CLI bundle: real runtime; sandbox.enabled: true; failIfUnavailable: true; effective behavior: enforcing',
)
})
test('reports disabled behavior without failing when sandbox is not enabled', () => {
const result = buildSandboxRuntimeCheck({
cliRuntimeStubbed: false,
sandboxEnabled: false,
failIfUnavailable: false,
sandboxingEnabled: false,
})
expect(result.ok).toBe(true)
expect(result.detail).toBe(
'CLI bundle: real runtime; sandbox.enabled: false; failIfUnavailable: false; effective behavior: disabled',
)
})
test('reports disabled behavior without failing when sandbox is off and the CLI runtime is stubbed', () => {
const result = buildSandboxRuntimeCheck({
cliRuntimeStubbed: true,
sandboxEnabled: false,
failIfUnavailable: false,
sandboxingEnabled: false,
})
expect(result.ok).toBe(true)
expect(result.detail).toBe(
'CLI bundle: stubbed; sandbox.enabled: false; failIfUnavailable: false; effective behavior: disabled',
)
})
})