diff --git a/config/base-claude.settings.json b/config/base-claude.settings.json index 284681cc..e11c1d3f 100644 --- a/config/base-claude.settings.json +++ b/config/base-claude.settings.json @@ -1,6 +1,6 @@ { "env": { - "ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/claude", + "ANTHROPIC_BASE_URL": "http://127.0.0.1:8317", "ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed" } } diff --git a/src/api/services/__tests__/cliproxy-profile-bridge.test.ts b/src/api/services/__tests__/cliproxy-profile-bridge.test.ts new file mode 100644 index 00000000..7948a85e --- /dev/null +++ b/src/api/services/__tests__/cliproxy-profile-bridge.test.ts @@ -0,0 +1,131 @@ +/** + * Regression tests for the `ccs api create --cliproxy-provider claude` bridge path. + * + * PR #1554 fixed the main CLIProxy env-builder to use the root URL for the built-in + * claude provider. This file locks the same rule on the parallel api-create bridge path + * (resolveCliproxyBridgeProfile / listCliproxyBridgeProviders) so both paths stay + * consistent. + * + * Background: CLIProxyAPI registers /v1/messages at the ROOT. The /api/provider/ + * prefix is a Plus-only route for non-Claude providers. Using /api/provider/claude + * returns 404 on the base CLIProxyAPI installation. + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { + resolveCliproxyBridgeProfile, + listCliproxyBridgeProviders, +} from '../cliproxy-profile-bridge'; +import { resolveCliproxyBridgeMetadata } from '../cliproxy-profile-bridge'; +import { invalidateConfigCache } from '../../../config/config-loader-facade'; +import { clearConfigCache } from '../../../cliproxy/config/base-config-loader'; + +describe('cliproxy-profile-bridge: claude provider uses root URL', () => { + let tempHome: string; + let originalCcsHome: string | undefined; + + beforeEach(() => { + originalCcsHome = process.env.CCS_HOME; + // Empty temp dir → loadOrCreateUnifiedConfig defaults to local target (127.0.0.1:8317). + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-bridge-test-')); + process.env.CCS_HOME = tempHome; + invalidateConfigCache(); + clearConfigCache(); + }); + + afterEach(() => { + process.env.CCS_HOME = originalCcsHome; + invalidateConfigCache(); + clearConfigCache(); + fs.rmSync(tempHome, { recursive: true, force: true }); + }); + + // ── resolveCliproxyBridgeProfile ────────────────────────────────────────── + + it('resolveCliproxyBridgeProfile(claude) produces root base URL', () => { + const profile = resolveCliproxyBridgeProfile('claude'); + // Must be the root URL — NOT /api/provider/claude. + expect(profile.baseUrl).toBe('http://127.0.0.1:8317/'); + expect(profile.baseUrl).not.toContain('/api/provider/claude'); + }); + + it('resolveCliproxyBridgeProfile(claude) reports root routePath', () => { + const profile = resolveCliproxyBridgeProfile('claude'); + expect(profile.routePath).toBe('/'); + }); + + it('resolveCliproxyBridgeProfile(claude) does not leak model pins (model-neutral)', () => { + const profile = resolveCliproxyBridgeProfile('claude'); + expect(profile.models.default).toBe(''); + expect(profile.models.opus).toBe(''); + expect(profile.models.sonnet).toBe(''); + expect(profile.models.haiku).toBe(''); + }); + + it('resolveCliproxyBridgeProfile(gemini) still uses scoped /api/provider path', () => { + const profile = resolveCliproxyBridgeProfile('gemini'); + expect(profile.baseUrl).toContain('/api/provider/gemini'); + expect(profile.routePath).toBe('/api/provider/gemini'); + }); + + it('resolveCliproxyBridgeProfile(codex) still uses scoped /api/provider path', () => { + const profile = resolveCliproxyBridgeProfile('codex'); + expect(profile.baseUrl).toContain('/api/provider/codex'); + expect(profile.routePath).toBe('/api/provider/codex'); + }); + + // ── listCliproxyBridgeProviders ─────────────────────────────────────────── + + it('listCliproxyBridgeProviders shows root routePath for claude', () => { + const providers = listCliproxyBridgeProviders(); + const claudeInfo = providers.find((p) => p.provider === 'claude'); + expect(claudeInfo).toBeDefined(); + expect(claudeInfo?.routePath).toBe('/'); + expect(claudeInfo?.routePath).not.toContain('/api/provider/claude'); + }); + + it('listCliproxyBridgeProviders keeps scoped routePaths for non-claude providers', () => { + const providers = listCliproxyBridgeProviders(); + for (const info of providers) { + if (info.provider === 'claude') continue; + expect(info.routePath).toBe(`/api/provider/${info.provider}`); + } + }); + + // ── resolveCliproxyBridgeMetadata fallback behaviour under root URL ─────── + // + // When a claude profile stores the fixed root URL (http://127.0.0.1:8317/), + // extractProviderFromPathname('/') returns null (no /api/provider/ segment), + // so resolveCliproxyBridgeMetadata returns null for that settings object. + // Dashboard routes fall back to mapExternalProviderName(profile.name) or the + // profile's cliproxyProvider field — both benign paths that still identify the + // provider correctly. This test locks the null-return so a future change to + // extractProviderFromPathname cannot silently introduce a regression. + + it('resolveCliproxyBridgeMetadata returns null for a root-URL claude settings object (benign fallback locked)', () => { + const settings = { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + }, + }; + // extractProviderFromPathname cannot identify the provider from a root path. + // The caller falls back to profile.name / cliproxyBridge from other sources. + expect(resolveCliproxyBridgeMetadata(settings)).toBeNull(); + }); + + it('resolveCliproxyBridgeMetadata still resolves non-claude providers from scoped URL', () => { + const settings = { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/gemini', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + }, + }; + const meta = resolveCliproxyBridgeMetadata(settings); + expect(meta).not.toBeNull(); + expect(meta?.provider).toBe('gemini'); + }); +}); diff --git a/src/api/services/cliproxy-profile-bridge.ts b/src/api/services/cliproxy-profile-bridge.ts index 6c8a5ca7..1e54365d 100644 --- a/src/api/services/cliproxy-profile-bridge.ts +++ b/src/api/services/cliproxy-profile-bridge.ts @@ -2,6 +2,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { buildProxyUrl, getProxyTarget } from '../../cliproxy/proxy/proxy-target-resolver'; +import { buildCliproxyProviderPath } from '../../cliproxy/config/env-builder'; import { getEffectiveApiKey } from '../../cliproxy/auth/auth-token-manager'; import { getModelMappingFromConfig } from '../../cliproxy/config/base-config-loader'; import { @@ -107,13 +108,18 @@ function resolveBridgeModelMapping(provider: CLIProxyProvider): ModelMapping { } export function listCliproxyBridgeProviders(): CliproxyBridgeProviderInfo[] { - return CLIPROXY_PROVIDER_IDS.map((provider) => ({ - provider, - displayName: getProviderDisplayName(provider), - description: getProviderDescription(provider), - defaultProfileName: getDefaultCliproxyBridgeName(provider), - routePath: `/api/provider/${provider}`, - })); + return CLIPROXY_PROVIDER_IDS.map((provider) => { + const providerPath = buildCliproxyProviderPath(provider); + return { + provider, + displayName: getProviderDisplayName(provider), + description: getProviderDescription(provider), + defaultProfileName: getDefaultCliproxyBridgeName(provider), + // claude uses root path (CLIProxyAPI registers /v1/messages at root); + // all other providers use their scoped /api/provider/ route. + routePath: providerPath === '' ? '/' : providerPath, + }; + }); } export function resolveCliproxyBridgeProfile( @@ -125,8 +131,13 @@ export function resolveCliproxyBridgeProfile( ): ResolvedCliproxyBridgeProfile { const target = getProxyTarget(); const profileName = options.name?.trim() || suggestCliproxyBridgeName(provider); - const baseUrl = buildProxyUrl(target, `/api/provider/${provider}`); + // Use the shared path helper so the claude provider always resolves to the + // CLIProxy root URL (same rule as buildLocalProviderBaseUrl in env-builder). + const providerPath = buildCliproxyProviderPath(provider); + const baseUrl = buildProxyUrl(target, providerPath); const apiKey = target.authToken ?? getEffectiveApiKey(); + // Expose the canonical route path: root for claude, scoped path for others. + const routePath = providerPath === '' ? '/' : providerPath; return { name: profileName, @@ -136,7 +147,7 @@ export function resolveCliproxyBridgeProfile( apiKey, models: resolveBridgeModelMapping(provider), target: options.target || 'claude', - routePath: `/api/provider/${provider}`, + routePath, source: target.isRemote ? 'remote' : 'local', }; } diff --git a/src/cliproxy/config/__tests__/claude-model-neutral.test.ts b/src/cliproxy/config/__tests__/claude-model-neutral.test.ts index b9338b9c..54c69a58 100644 --- a/src/cliproxy/config/__tests__/claude-model-neutral.test.ts +++ b/src/cliproxy/config/__tests__/claude-model-neutral.test.ts @@ -10,7 +10,12 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; -import { getClaudeEnvVars, ensureProviderSettings, getRemoteEnvVars } from '../env-builder'; +import { + getClaudeEnvVars, + ensureProviderSettings, + getEffectiveEnvVars, + getRemoteEnvVars, +} from '../env-builder'; import { clearConfigCache } from '../base-config-loader'; const MODEL_KEYS = [ @@ -45,10 +50,10 @@ describe('claude provider model-neutral passthrough (Gap 1)', () => { } }); - it('still sets ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN for claude', () => { + it('sets root ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN for claude', () => { const env = getClaudeEnvVars('claude'); - expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/claude'); + expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317'); expect(env.ANTHROPIC_AUTH_TOKEN).toBeDefined(); }); @@ -83,10 +88,57 @@ describe('claude provider model-neutral passthrough (Gap 1)', () => { } // Transport keys must be present - expect(written.env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/claude'); + expect(written.env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317'); expect(written.env.ANTHROPIC_AUTH_TOKEN).toBeDefined(); }); + it('normalizes stale claude provider-scoped base URL to CLIProxy root at read level', () => { + process.env.CCS_HOME = tempHome; + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + const settingsPath = path.join(ccsDir, 'claude.settings.json'); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + }, + }), + 'utf-8' + ); + + const env = getEffectiveEnvVars('claude', 8317, settingsPath); + + expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317'); + }); + + it('repairs stale claude provider-scoped base URL in stored default settings', () => { + process.env.CCS_HOME = tempHome; + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + const settingsPath = path.join(ccsDir, 'claude.settings.json'); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + }, + }), + 'utf-8' + ); + + ensureProviderSettings('claude'); + + const repaired = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { + env: Record; + }; + expect(repaired.env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317'); + }); + // ── Upgrade-path: existing claude.settings.json with stale default model pins ── it('strips stale default model pins from existing claude.settings.json on ensureProviderSettings', () => { @@ -107,7 +159,7 @@ describe('claude provider model-neutral passthrough (Gap 1)', () => { settingsPath, JSON.stringify({ env: { - ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude', + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317', ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', ...stalePins, }, @@ -128,7 +180,7 @@ describe('claude provider model-neutral passthrough (Gap 1)', () => { } // Transport keys must still be present - expect(repaired.env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/claude'); + expect(repaired.env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317'); expect(repaired.env.ANTHROPIC_AUTH_TOKEN).toBeDefined(); }); @@ -143,7 +195,7 @@ describe('claude provider model-neutral passthrough (Gap 1)', () => { settingsPath, JSON.stringify({ env: { - ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude', + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317', ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', ANTHROPIC_MODEL: 'claude-opus-4-7', // customised — not the stale sonnet default }, @@ -183,7 +235,7 @@ describe('claude provider model-neutral passthrough (Gap 1)', () => { settingsPath, JSON.stringify({ env: { - ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude', + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317', ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', ...stalePins, }, @@ -217,7 +269,7 @@ describe('claude provider model-neutral passthrough (Gap 1)', () => { const originalContent = JSON.stringify( { env: { - ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude', + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317', ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', }, }, @@ -252,7 +304,7 @@ describe('claude provider model-neutral passthrough (Gap 1)', () => { settingsPath, JSON.stringify({ env: { - ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude', + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317', ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', ANTHROPIC_MODEL: 'claude-sonnet-4-5-20250929', ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-5-20251101', @@ -285,7 +337,7 @@ describe('claude provider model-neutral passthrough (Gap 1)', () => { settingsPath, JSON.stringify({ env: { - ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude', + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317', ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', ANTHROPIC_MODEL: 'claude-sonnet-4-6', ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6', @@ -318,7 +370,7 @@ describe('claude provider model-neutral passthrough (Gap 1)', () => { settingsPath, JSON.stringify({ env: { - ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude', + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317', ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', ANTHROPIC_MODEL: 'claude-opus-4-8', }, @@ -400,7 +452,7 @@ describe('claude provider model-neutral passthrough (Gap 1)', () => { settingsPath, JSON.stringify({ env: { - ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude', + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317', ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', ANTHROPIC_MODEL: 'claude-sonnet-4-5-20250929', ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-5-20251101', @@ -464,7 +516,7 @@ describe('claude provider model-neutral passthrough (Gap 1)', () => { const originalContent = JSON.stringify({ env: { - ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude', + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317', ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', ANTHROPIC_MODEL: 'claude-sonnet-4-6', ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-7', @@ -502,7 +554,7 @@ describe('claude provider model-neutral passthrough (Gap 1)', () => { settingsPath, JSON.stringify({ env: { - ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude', + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317', ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', ANTHROPIC_MODEL: 'claude-opus-4-8', // custom — not a historical default }, @@ -533,7 +585,7 @@ describe('claude provider model-neutral passthrough (Gap 1)', () => { customPath, JSON.stringify({ env: { - ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude', + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317', ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', ANTHROPIC_MODEL: 'claude-sonnet-4-6', // equals a stale default, but explicit }, @@ -568,7 +620,7 @@ describe('claude provider model-neutral passthrough (Gap 1)', () => { settingsPath, JSON.stringify({ env: { - ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude', + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317', ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', ANTHROPIC_MODEL: 'claude-sonnet-4-6', // equals a stale default, but post-migration = explicit }, diff --git a/src/cliproxy/config/env-builder.ts b/src/cliproxy/config/env-builder.ts index 0a184622..d99bd8f4 100644 --- a/src/cliproxy/config/env-builder.ts +++ b/src/cliproxy/config/env-builder.ts @@ -205,8 +205,14 @@ export function getModelMapping(provider: CLIProxyProvider): ProviderModelMappin /** * Get environment variables for Claude CLI (bundled defaults) - * Uses provider-specific endpoint (e.g., /api/provider/gemini) for explicit routing. - * This enables concurrent gemini/codex usage - each session routes to its provider via URL path. + * Uses provider-specific endpoints (e.g., /api/provider/gemini) for explicit routing + * except for the built-in claude provider. + * + * Root-URL exception: the claude provider always uses the CLIProxy ROOT endpoint + * (http://127.0.0.1:) instead of /api/provider/claude. CLIProxyAPI's Claude Code + * contract registers /v1/messages at the root; the /api/provider/ prefix is a Plus-only + * feature for non-Claude providers. buildCliproxyProviderPath() encodes this rule and is + * used here and by the api-create bridge path so both remain consistent. * * For the claude built-in provider the model env vars are intentionally omitted so that * the user's own Claude Code /model selection is honored end-to-end (model-neutral passthrough). @@ -231,7 +237,7 @@ export function getClaudeEnvVars( // Core transport env vars set dynamically for all providers const coreEnvVars: NodeJS.ProcessEnv = { - ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}/api/provider/${provider}`, + ANTHROPIC_BASE_URL: buildLocalProviderBaseUrl(provider, port), ANTHROPIC_AUTH_TOKEN: getEffectiveApiKey(), }; @@ -369,6 +375,25 @@ function ensureRequiredEnvVars( /** Localhost hostnames used for local CLIProxy endpoints */ const LOCALHOST_NAMES = new Set(['127.0.0.1', 'localhost', '0.0.0.0']); +/** + * Return the CLIProxy route path for a provider. + * + * - claude uses the root path (empty string → "/" after buildProxyUrl normalises it) + * because CLIProxyAPI's Claude Code contract registers /v1/messages at the root; the + * /api/provider/ prefix is Plus-only and only for non-Claude providers. + * - all other providers use the scoped /api/provider/ path. + * + * Exported so the profile-bridge can reuse the same rule (DRY). + */ +export function buildCliproxyProviderPath(provider: CLIProxyProvider): string { + return provider === 'claude' ? '' : `/api/provider/${provider}`; +} + +function buildLocalProviderBaseUrl(provider: CLIProxyProvider, port: number): string { + const rootUrl = `http://127.0.0.1:${port}`; + return provider === 'claude' ? rootUrl : `${rootUrl}/api/provider/${provider}`; +} + /** * Normalize local CLIProxy endpoint to the expected provider route. * Only rewrites localhost URLs that target the active local port. @@ -390,6 +415,10 @@ function normalizeLocalProviderBaseUrl( : 80; if (!Number.isFinite(effectivePort) || effectivePort !== port) return baseUrl; + if (provider === 'claude') { + return parsed.origin; + } + const expectedPath = `/api/provider/${provider}`; if (parsed.pathname === expectedPath && !parsed.search && !parsed.hash) return baseUrl; @@ -428,7 +457,9 @@ function rewriteLocalhostUrls( // Omit port suffix for standard web ports (80/443) for cleaner URLs const standardWebPort = normalizedProtocol === 'https' ? 443 : 80; const portSuffix = effectivePort === standardWebPort ? '' : `:${effectivePort}`; - const remoteBaseUrl = `${normalizedProtocol}://${remoteConfig.host}${portSuffix}/api/provider/${provider}`; + const remoteRootUrl = `${normalizedProtocol}://${remoteConfig.host}${portSuffix}`; + const remoteBaseUrl = + provider === 'claude' ? remoteRootUrl : `${remoteRootUrl}/api/provider/${provider}`; result.ANTHROPIC_BASE_URL = remoteBaseUrl; @@ -713,6 +744,18 @@ export function ensureProviderSettings(provider: CLIProxyProvider): void { } } + if (provider === 'claude' && typeof mergedEnv.ANTHROPIC_BASE_URL === 'string') { + const normalizedBaseUrl = normalizeLocalProviderBaseUrl( + mergedEnv.ANTHROPIC_BASE_URL, + provider, + CLIPROXY_DEFAULT_PORT + ); + if (normalizedBaseUrl !== mergedEnv.ANTHROPIC_BASE_URL) { + mergedEnv.ANTHROPIC_BASE_URL = normalizedBaseUrl; + mutated = true; + } + } + // Canonicalize provider-specific model aliases (e.g., AGY Sonnet 4.6 thinking legacy IDs). for (const key of MODEL_ENV_VAR_KEYS) { const current = mergedEnv[key]; diff --git a/tests/unit/commands/persist-command-handler.test.ts b/tests/unit/commands/persist-command-handler.test.ts index 5179f505..4324da96 100644 --- a/tests/unit/commands/persist-command-handler.test.ts +++ b/tests/unit/commands/persist-command-handler.test.ts @@ -654,6 +654,31 @@ describe('persist command Claude extension parity', () => { expect(renderedLogs).not.toContain('Native Codex target:'); }); + it('persists claude CLIProxy profile with root CLIProxy base URL', async () => { + await writeUnifiedConfig(); + + const settingsPath = path.join(tempRoot, '.claude', 'settings.json'); + await fs.promises.mkdir(path.dirname(settingsPath), { recursive: true }); + + const originalConsoleLog = console.log; + console.log = () => {}; + + try { + await withScopedHome(() => handlePersistCommand(['claude', '--yes'])); + } finally { + console.log = originalConsoleLog; + } + + const persisted = JSON.parse(await fs.promises.readFile(settingsPath, 'utf8')) as { + env: Record; + }; + + expect(persisted.env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317'); + expect(persisted.env.ANTHROPIC_BASE_URL).not.toContain('/api/provider/claude'); + expect(persisted.env.ANTHROPIC_AUTH_TOKEN).toBeDefined(); + expect(persisted.env.ANTHROPIC_MODEL).toBeUndefined(); + }); + it('blocks Codex CLIProxy profiles from Claude settings persistence', async () => { await writeUnifiedConfig();