diff --git a/src/cliproxy/executor/__tests__/launch-settings.test.ts b/src/cliproxy/executor/__tests__/launch-settings.test.ts new file mode 100644 index 00000000..6865cedf --- /dev/null +++ b/src/cliproxy/executor/__tests__/launch-settings.test.ts @@ -0,0 +1,130 @@ +/** + * Unit tests for launch-settings.ts + * + * Verifies that the runtime settings overlay keeps the resolved proxy-chain + * `ANTHROPIC_BASE_URL` (and related routing keys) authoritative when Claude is + * launched with `--settings`, instead of the persisted CLIProxy-direct URL. + * + * The overlay is written to an isolated os.tmpdir() directory, so these tests + * never touch the real ~/.ccs. + */ + +import { describe, expect, it, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { buildLaunchSettingsOverlay, prepareLaunchSettings } from '../launch-settings'; + +let tmpDir: string; +let settingsPath: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-launch-settings-test-')); + settingsPath = path.join(tmpDir, 'codex.settings.json'); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +function writePersisted(settings: unknown): void { + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); +} + +describe('buildLaunchSettingsOverlay', () => { + it('overlays routing env keys from the resolved environment', () => { + writePersisted({ + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex', + ANTHROPIC_MODEL: 'gpt-5.5', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + }, + }); + + const { settings, changed } = buildLaunchSettingsOverlay(settingsPath, { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:50118/api/provider/codex', + ANTHROPIC_MODEL: 'gpt-5.5-high', + } as NodeJS.ProcessEnv); + + expect(changed).toBe(true); + const env = settings.env as Record; + expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:50118/api/provider/codex'); + expect(env.ANTHROPIC_MODEL).toBe('gpt-5.5-high'); + // Untouched keys are preserved. + expect(env.ANTHROPIC_AUTH_TOKEN).toBe('ccs-internal-managed'); + }); + + it('preserves non-env settings (permissions, hooks, etc.)', () => { + writePersisted({ + env: { ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex' }, + permissions: { allow: ['Bash'] }, + statusLine: { type: 'command' }, + }); + + const { settings } = buildLaunchSettingsOverlay(settingsPath, { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:60000/api/provider/codex', + } as NodeJS.ProcessEnv); + + expect(settings.permissions).toEqual({ allow: ['Bash'] }); + expect(settings.statusLine).toEqual({ type: 'command' }); + }); + + it('reports changed=false when resolved env matches persisted values', () => { + writePersisted({ + env: { ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex' }, + }); + + const { changed } = buildLaunchSettingsOverlay(settingsPath, { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex', + } as NodeJS.ProcessEnv); + + expect(changed).toBe(false); + }); + + it('falls back to an env-only overlay when the settings file is missing', () => { + const { settings, changed } = buildLaunchSettingsOverlay(settingsPath, { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:50118/api/provider/codex', + } as NodeJS.ProcessEnv); + + expect(changed).toBe(true); + expect((settings.env as Record).ANTHROPIC_BASE_URL).toBe( + 'http://127.0.0.1:50118/api/provider/codex' + ); + }); +}); + +describe('prepareLaunchSettings', () => { + it('writes a runtime overlay file and cleans it up when routing changes', () => { + writePersisted({ + env: { ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex' }, + }); + + const result = prepareLaunchSettings(settingsPath, { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:50118/api/provider/codex', + } as NodeJS.ProcessEnv); + + expect(result.settingsPath).not.toBe(settingsPath); + expect(fs.existsSync(result.settingsPath)).toBe(true); + + const written = JSON.parse(fs.readFileSync(result.settingsPath, 'utf8')); + expect(written.env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:50118/api/provider/codex'); + + result.cleanup(); + expect(fs.existsSync(result.settingsPath)).toBe(false); + }); + + it('returns the original path and a no-op cleanup when nothing changes', () => { + writePersisted({ + env: { ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex' }, + }); + + const result = prepareLaunchSettings(settingsPath, { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex', + } as NodeJS.ProcessEnv); + + expect(result.settingsPath).toBe(settingsPath); + // Cleanup must not remove the persisted settings file. + result.cleanup(); + expect(fs.existsSync(settingsPath)).toBe(true); + }); +}); diff --git a/src/cliproxy/executor/claude-launcher.ts b/src/cliproxy/executor/claude-launcher.ts index ab74f0a4..ca539971 100644 --- a/src/cliproxy/executor/claude-launcher.ts +++ b/src/cliproxy/executor/claude-launcher.ts @@ -23,6 +23,7 @@ import { CodexReasoningProxy } from '../ai-providers/codex-reasoning-proxy'; import { ToolSanitizationProxy } from '../proxy/tool-sanitization-proxy'; import { HttpsTunnelProxy } from '../proxy/https-tunnel-proxy'; import { setupCleanupHandlers } from './session-bridge'; +import { prepareLaunchSettings } from './launch-settings'; import { resolveRuntimeQuotaMonitorProviders } from './account-resolution'; import { isClaudeSubcommandInvocation, @@ -92,7 +93,7 @@ export async function launchClaude(context: ClaudeLaunchContext): Promise {} } + : prepareLaunchSettings(persistedSettingsPath, env); + // Assemble final args: image analysis tools → browser tools → web search tools → settings const imageAnalysisArgs = imageAnalysisMcpReady ? appendThirdPartyImageAnalysisToolArgs(claudeSessionArgs) @@ -149,6 +160,11 @@ export async function launchClaude(context: ClaudeLaunchContext): Promise`. Recent + * Claude Code releases apply the settings file's `env` block on top of the + * inherited process environment, so the persisted `ANTHROPIC_BASE_URL` (which + * points straight at CLIProxy) overrides the proxy-chain URL CCS injected via + * env. Claude then bypasses the proxy chain entirely — breaking tool-name + * sanitization and Codex system-message folding (the latter surfaces as + * `400 {"detail":"System messages are not allowed"}`). + * + * To keep the proxy chain authoritative regardless of Claude's env precedence, + * we write a runtime copy of the settings file whose `env` routing keys are + * overlaid with the resolved launch environment, and pass that copy to + * `--settings`. + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { ANTHROPIC_MODEL_ENV_KEYS, ANTHROPIC_ROUTING_ENV_KEYS } from '../../utils/shell-executor'; + +/** + * Environment keys that control provider routing/model selection and are read + * by Claude from the settings `env` block. These must reflect the resolved + * proxy-chain environment, not the persisted on-disk values. Reuses the + * canonical routing/model key lists from shell-executor. + */ +const ROUTING_ENV_KEYS = [...ANTHROPIC_ROUTING_ENV_KEYS, ...ANTHROPIC_MODEL_ENV_KEYS]; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Build a settings object based on the persisted settings file, with routing + * `env` keys overlaid from the resolved launch environment. + * + * @returns The merged settings and whether any routing key actually changed. + */ +export function buildLaunchSettingsOverlay( + settingsPath: string, + env: NodeJS.ProcessEnv +): { settings: Record; changed: boolean } { + let base: Record = {}; + try { + if (fs.existsSync(settingsPath)) { + const parsed: unknown = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + if (isRecord(parsed)) { + base = parsed; + } + } + } catch { + // Corrupt/unreadable settings file: fall back to an env-only overlay. + base = {}; + } + + const mergedEnv: Record = isRecord(base.env) ? { ...base.env } : {}; + + let changed = false; + for (const key of ROUTING_ENV_KEYS) { + const resolved = env[key]; + if (typeof resolved === 'string' && mergedEnv[key] !== resolved) { + mergedEnv[key] = resolved; + changed = true; + } + } + + return { settings: { ...base, env: mergedEnv }, changed }; +} + +/** + * Prepare the settings file path to hand to `claude --settings`. + * + * When the resolved launch environment changes any routing key relative to the + * persisted settings file (i.e. a proxy chain is active), a runtime overlay + * file is written and its path returned together with a cleanup callback that + * removes it. Otherwise the original `settingsPath` is returned unchanged and + * cleanup is a no-op. + */ +export function prepareLaunchSettings( + settingsPath: string, + env: NodeJS.ProcessEnv +): { settingsPath: string; cleanup: () => void } { + const noop = { settingsPath, cleanup: () => {} }; + + let overlay: { settings: Record; changed: boolean }; + try { + overlay = buildLaunchSettingsOverlay(settingsPath, env); + } catch { + return noop; + } + + if (!overlay.changed) { + return noop; + } + + try { + // Write to a private temp dir (matches createOpenAICompatLaunchSettings) so + // the overlay never lands in the user's ~/.ccs and is trivially isolated. + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-launch-settings-')); + fs.chmodSync(tempDir, 0o700); + + const runtimePath = path.join(tempDir, path.basename(settingsPath) || 'settings.json'); + fs.writeFileSync(runtimePath, JSON.stringify(overlay.settings, null, 2) + '\n', { + encoding: 'utf8', + mode: 0o600, + }); + + let cleanedUp = false; + const cleanup = (): void => { + if (cleanedUp) { + return; + } + cleanedUp = true; + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } + }; + + return { settingsPath: runtimePath, cleanup }; + } catch { + // If we cannot write the overlay, fall back to the persisted file. Routing + // may bypass the proxy chain, but the session still launches. + return noop; + } +} diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index 69c94916..58d459f6 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -34,13 +34,13 @@ export function stripAnthropicEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { return result; } -const ANTHROPIC_ROUTING_ENV_KEYS = [ +export const ANTHROPIC_ROUTING_ENV_KEYS = [ 'ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY', ]; const ANTHROPIC_ROUTING_ENV_KEY_SET = new Set(ANTHROPIC_ROUTING_ENV_KEYS); -const ANTHROPIC_MODEL_ENV_KEYS = [ +export const ANTHROPIC_MODEL_ENV_KEYS = [ 'ANTHROPIC_MODEL', 'ANTHROPIC_DEFAULT_OPUS_MODEL', 'ANTHROPIC_DEFAULT_SONNET_MODEL',