diff --git a/src/cliproxy/executor/__tests__/proxy-chain-builder.test.ts b/src/cliproxy/executor/__tests__/proxy-chain-builder.test.ts new file mode 100644 index 00000000..a39be32e --- /dev/null +++ b/src/cliproxy/executor/__tests__/proxy-chain-builder.test.ts @@ -0,0 +1,273 @@ +/** + * Unit tests for proxy-chain-builder.ts (Phase 07) + * + * Mocking strategy: proxy constructors are injected via the _ToolSanitizationProxy + * and _CodexReasoningProxy fields on ProxyChainContext (dependency-injection escape + * hatch added for testability). This avoids Bun module-cache limitations with + * mock.module / jest.mock and never spins up real HTTP servers. + * + * Tests cover: + * - Tool sanitization proxy started when ANTHROPIC_BASE_URL is set + * - Tool sanitization proxy skipped when ANTHROPIC_BASE_URL is absent + * - Tool sanitization proxy start failure swallowed (null returned, verbose warn) + * - Codex reasoning proxy started for single-provider codex + * - Codex reasoning proxy skipped for composite codex (cfg.isComposite true) + * - Codex reasoning proxy skipped for non-codex provider + * - Codex reasoning proxy uses post-sanitization URL when tool-san is active + * - Codex reasoning proxy start failure swallowed (null returned) + * - All results null when ANTHROPIC_BASE_URL absent + */ + +import { describe, expect, it, jest, beforeEach } from 'bun:test'; +import { buildProxyChain } from '../proxy-chain-builder'; +import type { ProxyChainContext } from '../proxy-chain-builder'; +import type { ToolSanitizationProxy } from '../../proxy/tool-sanitization-proxy'; +import type { CodexReasoningProxy } from '../../ai-providers/codex-reasoning-proxy'; + +// ── Stub factory ────────────────────────────────────────────────────────────── + +type ProxyStub = { start: jest.Mock; stop: jest.Mock; _port?: number }; + +function makeStubCtor(resolvePort: number | (() => Promise | number)): { + ctor: jest.Mock; + instance: ProxyStub; +} { + const instance: ProxyStub = { + start: jest + .fn() + .mockImplementation( + typeof resolvePort === 'function' ? resolvePort : () => Promise.resolve(resolvePort) + ), + stop: jest.fn(), + }; + const ctor = jest.fn().mockReturnValue(instance); + return { ctor, instance }; +} + +function makeFailCtor(message: string): { ctor: jest.Mock; instance: ProxyStub } { + const instance: ProxyStub = { + start: jest.fn().mockRejectedValue(new Error(message)), + stop: jest.fn(), + }; + const ctor = jest.fn().mockReturnValue(instance); + return { ctor, instance }; +} + +// ── Context helpers ─────────────────────────────────────────────────────────── + +function makeThinkingCfg() { + return { mode: 'auto' as const, override: undefined }; +} + +function makeProxyConfig() { + return { + mode: 'local' as const, + port: 8317, + protocol: 'http' as const, + fallbackEnabled: false, + autoStartLocal: true, + remoteOnly: false, + forceLocal: false, + }; +} + +function makeCfg(overrides: Partial<{ isComposite: boolean }> = {}) { + return { port: 8317, timeout: 5000, verbose: false, pollInterval: 100, ...overrides }; +} + +// Stub ctors shared across tests, reset in beforeEach +let defaultToolSan: ReturnType; +let defaultCodex: ReturnType; + +beforeEach(() => { + defaultToolSan = makeStubCtor(19001); + defaultCodex = makeStubCtor(19002); +}); + +function baseCtx(overrides: Partial = {}): ProxyChainContext { + return { + provider: 'gemini', + useRemoteProxy: false, + proxyConfig: makeProxyConfig(), + cfg: makeCfg(), + initialEnvVars: { ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317' }, + thinkingOverride: undefined, + thinkingCfg: makeThinkingCfg(), + verbose: false, + log: () => {}, + _ToolSanitizationProxy: defaultToolSan.ctor as unknown as new ( + cfg: object + ) => ToolSanitizationProxy, + _CodexReasoningProxy: defaultCodex.ctor as unknown as new (cfg: object) => CodexReasoningProxy, + ...overrides, + }; +} + +// ── Tool sanitization: started when ANTHROPIC_BASE_URL set ─────────────────── + +describe('buildProxyChain — tool sanitization: started when ANTHROPIC_BASE_URL set', () => { + it('starts proxy and returns instance + port', async () => { + const { ctor, instance } = makeStubCtor(13001); + + const result = await buildProxyChain(baseCtx({ _ToolSanitizationProxy: ctor as never })); + + expect(ctor).toHaveBeenCalledTimes(1); + expect(result.toolSanitizationProxy).toBe(instance); + expect(result.toolSanitizationPort).toBe(13001); + }); +}); + +// ── Tool sanitization: skipped when ANTHROPIC_BASE_URL absent ──────────────── + +describe('buildProxyChain — tool sanitization: skipped when no ANTHROPIC_BASE_URL', () => { + it('returns null proxy and port without constructing', async () => { + const { ctor } = makeStubCtor(13001); + + const result = await buildProxyChain( + baseCtx({ initialEnvVars: {}, _ToolSanitizationProxy: ctor as never }) + ); + + expect(ctor).not.toHaveBeenCalled(); + expect(result.toolSanitizationProxy).toBeNull(); + expect(result.toolSanitizationPort).toBeNull(); + }); +}); + +// ── Tool sanitization: start failure swallowed ──────────────────────────────── + +describe('buildProxyChain — tool sanitization: start failure swallowed', () => { + it('returns null and emits verbose warn without throwing', async () => { + const { ctor } = makeFailCtor('port in use'); + const warnSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + const result = await buildProxyChain( + baseCtx({ verbose: true, _ToolSanitizationProxy: ctor as never }) + ); + + expect(result.toolSanitizationProxy).toBeNull(); + expect(result.toolSanitizationPort).toBeNull(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('port in use')); + warnSpy.mockRestore(); + }); +}); + +// ── Codex reasoning: started for single-provider codex ─────────────────────── + +describe('buildProxyChain — codex reasoning: started for single-provider codex', () => { + it('starts codex proxy and returns instance + port', async () => { + const sanCtorPair = makeStubCtor(13010); + const codexCtorPair = makeStubCtor(13011); + + const result = await buildProxyChain( + baseCtx({ + provider: 'codex', + cfg: makeCfg({ isComposite: false }), + _ToolSanitizationProxy: sanCtorPair.ctor as never, + _CodexReasoningProxy: codexCtorPair.ctor as never, + }) + ); + + expect(codexCtorPair.ctor).toHaveBeenCalledTimes(1); + expect(result.codexReasoningProxy).toBe(codexCtorPair.instance); + expect(result.codexReasoningPort).toBe(13011); + }); +}); + +// ── Codex reasoning: skipped for composite codex ───────────────────────────── + +describe('buildProxyChain — codex reasoning: skipped for composite codex', () => { + it('does not construct CodexReasoningProxy when cfg.isComposite is true', async () => { + const { ctor } = makeStubCtor(13020); + + const result = await buildProxyChain( + baseCtx({ + provider: 'codex', + cfg: makeCfg({ isComposite: true }), + _CodexReasoningProxy: ctor as never, + }) + ); + + expect(ctor).not.toHaveBeenCalled(); + expect(result.codexReasoningProxy).toBeNull(); + expect(result.codexReasoningPort).toBeNull(); + }); +}); + +// ── Codex reasoning: skipped for non-codex provider ────────────────────────── + +describe('buildProxyChain — codex reasoning: skipped for non-codex provider', () => { + it('does not construct CodexReasoningProxy for gemini', async () => { + const { ctor } = makeStubCtor(13030); + + await buildProxyChain(baseCtx({ provider: 'gemini', _CodexReasoningProxy: ctor as never })); + + expect(ctor).not.toHaveBeenCalled(); + }); +}); + +// ── Codex reasoning: uses post-sanitization URL ─────────────────────────────── + +describe('buildProxyChain — codex reasoning: uses post-sanitization base URL', () => { + it('passes http://127.0.0.1: as upstreamBaseUrl', async () => { + const sanCtorPair = makeStubCtor(13040); + const codexCtorPair = makeStubCtor(13041); + + await buildProxyChain( + baseCtx({ + provider: 'codex', + cfg: makeCfg({ isComposite: false }), + _ToolSanitizationProxy: sanCtorPair.ctor as never, + _CodexReasoningProxy: codexCtorPair.ctor as never, + }) + ); + + expect(codexCtorPair.ctor).toHaveBeenCalledWith( + expect.objectContaining({ upstreamBaseUrl: 'http://127.0.0.1:13040' }) + ); + }); +}); + +// ── Codex reasoning: start failure swallowed ───────────────────────────────── + +describe('buildProxyChain — codex reasoning: start failure swallowed', () => { + it('returns null codex proxy/port without throwing', async () => { + const sanCtorPair = makeStubCtor(13050); + const { ctor: codexFailCtor } = makeFailCtor('bind failed'); + + const result = await buildProxyChain( + baseCtx({ + provider: 'codex', + cfg: makeCfg({ isComposite: false }), + _ToolSanitizationProxy: sanCtorPair.ctor as never, + _CodexReasoningProxy: codexFailCtor as never, + }) + ); + + expect(result.codexReasoningProxy).toBeNull(); + expect(result.codexReasoningPort).toBeNull(); + }); +}); + +// ── All results null when no ANTHROPIC_BASE_URL ─────────────────────────────── + +describe('buildProxyChain — all results null when no ANTHROPIC_BASE_URL', () => { + it('returns four nulls for gemini without base URL', async () => { + const sanCtor = jest.fn(); + const codexCtor = jest.fn(); + + const result = await buildProxyChain( + baseCtx({ + initialEnvVars: {}, + _ToolSanitizationProxy: sanCtor as never, + _CodexReasoningProxy: codexCtor as never, + }) + ); + + expect(sanCtor).not.toHaveBeenCalled(); + expect(codexCtor).not.toHaveBeenCalled(); + expect(result.toolSanitizationProxy).toBeNull(); + expect(result.toolSanitizationPort).toBeNull(); + expect(result.codexReasoningProxy).toBeNull(); + expect(result.codexReasoningPort).toBeNull(); + }); +}); diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index df8ebdb2..68b8fc39 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -13,9 +13,7 @@ import { spawn, ChildProcess } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; -import * as path from 'path'; import { fail, info, warn } from '../../utils/ui'; -import { getCcsDir } from '../../utils/config-manager'; import { escapeShellArg, getWindowsEscapedCommandShell } from '../../utils/shell-executor'; import { generateConfig, @@ -79,11 +77,11 @@ import { import { buildThinkingStartupStatus, resolveRuntimeThinkingOverride, - shouldDisableCodexReasoning, } from './thinking-override-resolver'; import { shouldStartHttpsTunnel } from './https-tunnel-policy'; import { filterCcsFlags, parseExecutorFlags, validateFlagCombinations } from './arg-parser'; import { resolveExecutorProxy } from './proxy-resolver'; +import { buildProxyChain } from './proxy-chain-builder'; /** Local alias so internal call sites need no change */ const resolveRuntimeQuotaMonitorProviders = _resolveRuntimeQuotaMonitorProviders; @@ -365,7 +363,7 @@ export async function execClaudeWithCLIProxy( } } - // 8. Setup HTTPS tunnel if needed + // 8. Setup HTTPS tunnel if needed (tunnelPort used by imageAnalysisProxyTarget below) let httpsTunnel: HttpsTunnelProxy | null = null; let tunnelPort: number | null = null; @@ -435,9 +433,11 @@ export async function execClaudeWithCLIProxy( ? 'ImageAnalysis MCP provisioning failed. This session will use compatibility fallback when available.' : imageAnalysisResolution.warning; - // 9. Setup tool sanitization proxy + // 9. Resolve config dir + browser runtime (needed before proxy chain) let toolSanitizationProxy: ToolSanitizationProxy | null = null; let toolSanitizationPort: number | null = null; + let codexReasoningProxy: CodexReasoningProxy | null = null; + let codexReasoningPort: number | null = null; let inheritedClaudeConfigDir = cfg.claudeConfigDir; if (!inheritedClaudeConfigDir && cfg.profileName) { @@ -488,74 +488,19 @@ export async function execClaudeWithCLIProxy( imageAnalysisEnv, }); - if (initialEnvVars.ANTHROPIC_BASE_URL) { - try { - toolSanitizationProxy = new ToolSanitizationProxy({ - upstreamBaseUrl: initialEnvVars.ANTHROPIC_BASE_URL, - verbose, - warnOnSanitize: true, - allowSelfSigned: useRemoteProxy ? (proxyConfig.allowSelfSigned ?? false) : false, - }); - toolSanitizationPort = await toolSanitizationProxy.start(); - log(`Tool sanitization proxy active on port ${toolSanitizationPort}`); - } catch (error) { - const err = error as Error; - toolSanitizationProxy = null; - toolSanitizationPort = null; - if (verbose) { - console.error(warn(`Tool sanitization proxy disabled: ${err.message}`)); - } - } - } - - const postSanitizationBaseUrl = toolSanitizationPort - ? `http://127.0.0.1:${toolSanitizationPort}` - : initialEnvVars.ANTHROPIC_BASE_URL; - - // 10. Setup Codex reasoning proxy (single-provider Codex only) - let codexReasoningProxy: CodexReasoningProxy | null = null; - let codexReasoningPort: number | null = null; - - // Composite variants require root model-routed endpoints, never provider-pinned codex endpoints. - if (provider === 'codex' && !cfg.isComposite) { - if (!postSanitizationBaseUrl) { - log('ANTHROPIC_BASE_URL not set for Codex, reasoning proxy disabled'); - } else { - try { - const traceEnabled = - process.env.CCS_CODEX_REASONING_TRACE === '1' || - process.env.CCS_CODEX_REASONING_TRACE === 'true'; - const stripPathPrefix = useRemoteProxy ? '/api/provider/codex' : undefined; - const codexThinkingOff = shouldDisableCodexReasoning(thinkingCfg, thinkingOverride); - codexReasoningProxy = new CodexReasoningProxy({ - upstreamBaseUrl: postSanitizationBaseUrl, - verbose, - defaultEffort: 'medium', - disableEffort: codexThinkingOff, - traceFilePath: traceEnabled ? path.join(getCcsDir(), 'codex-reasoning-proxy.log') : '', - allowSelfSigned: useRemoteProxy ? (proxyConfig.allowSelfSigned ?? false) : false, - modelMap: { - defaultModel: initialEnvVars.ANTHROPIC_MODEL, - opusModel: initialEnvVars.ANTHROPIC_DEFAULT_OPUS_MODEL, - sonnetModel: initialEnvVars.ANTHROPIC_DEFAULT_SONNET_MODEL, - haikuModel: initialEnvVars.ANTHROPIC_DEFAULT_HAIKU_MODEL, - }, - stripPathPrefix, - }); - codexReasoningPort = await codexReasoningProxy.start(); - log( - `Codex reasoning proxy active: http://127.0.0.1:${codexReasoningPort}/api/provider/codex` - ); - } catch (error) { - const err = error as Error; - codexReasoningProxy = null; - codexReasoningPort = null; - if (verbose) { - console.error(warn(`Codex reasoning proxy disabled: ${err.message}`)); - } - } - } - } + // 9b. Build env-dependent proxy chain (tool-sanitization + codex-reasoning) + ({ toolSanitizationProxy, toolSanitizationPort, codexReasoningProxy, codexReasoningPort } = + await buildProxyChain({ + provider, + useRemoteProxy, + proxyConfig, + cfg, + initialEnvVars, + thinkingOverride, + thinkingCfg, + verbose, + log, + })); // 11. Build final environment with all proxy chains const env = buildClaudeEnvironment({ diff --git a/src/cliproxy/executor/proxy-chain-builder.ts b/src/cliproxy/executor/proxy-chain-builder.ts new file mode 100644 index 00000000..98a45b8e --- /dev/null +++ b/src/cliproxy/executor/proxy-chain-builder.ts @@ -0,0 +1,185 @@ +/** + * Proxy Chain Builder (Concern F — tool-sanitization + codex-reasoning layers) + * + * Orchestrates the two env-dependent proxy layers that sit between the + * Claude CLI process and the upstream CLIProxy / remote endpoint: + * + * 1. ToolSanitizationProxy — wraps ANTHROPIC_BASE_URL to sanitize tool names/schemas + * 2. CodexReasoningProxy — only for single-provider codex, wraps tool-san URL + * + * Placement in the execution sequence + * ───────────────────────────────────── + * The HTTPS tunnel (HttpsTunnelProxy) is started BEFORE this function is called, + * because its tunnelPort is needed by both imageAnalysisResolution and the + * first-pass buildClaudeEnvironment. This function receives the result of that + * first-pass env as `initialEnvVars`, from which it extracts ANTHROPIC_BASE_URL + * to wire up the tool-sanitization proxy. + * + * Lifecycle: proxies are started (spawned) but NOT stopped here. The caller + * is responsible for registering cleanup handlers (setupCleanupHandlers). + */ + +import * as path from 'path'; +import { warn } from '../../utils/ui'; +import { getCcsDir } from '../../utils/config-manager'; +import { + ToolSanitizationProxy, + type ToolSanitizationProxyConfig, +} from '../proxy/tool-sanitization-proxy'; +import { + CodexReasoningProxy, + type CodexReasoningProxyConfig, +} from '../ai-providers/codex-reasoning-proxy'; +import { shouldDisableCodexReasoning } from './thinking-override-resolver'; +import type { CLIProxyProvider, ExecutorConfig, ResolvedProxyConfig } from '../types'; +import type { ThinkingConfig } from '../../config/unified-config-types'; + +// ── Proxy constructor types (for dependency injection in tests) ─────────────── + +type ToolSanitizationProxyCtor = new (config: ToolSanitizationProxyConfig) => ToolSanitizationProxy; +type CodexReasoningProxyCtor = new (config: CodexReasoningProxyConfig) => CodexReasoningProxy; + +// ── Public types ────────────────────────────────────────────────────────────── + +export interface ProxyChainContext { + /** Provider being executed */ + provider: CLIProxyProvider; + /** True when routing to a remote proxy host */ + useRemoteProxy: boolean; + /** Resolved proxy configuration (host, port, protocol, auth, …) */ + proxyConfig: ResolvedProxyConfig; + /** Executor configuration */ + cfg: ExecutorConfig; + /** + * Initial environment from first-pass buildClaudeEnvironment. + * ANTHROPIC_BASE_URL and ANTHROPIC_MODEL* keys are consumed here. + */ + initialEnvVars: Partial>; + /** Thinking mode override (value or undefined for default) */ + thinkingOverride: string | number | undefined; + /** Thinking configuration from unified config */ + thinkingCfg: ThinkingConfig; + verbose: boolean; + log: (msg: string) => void; + /** + * Optional constructor overrides for unit testing. + * Production code omits these; tests inject stubs to avoid real HTTP servers. + */ + _ToolSanitizationProxy?: ToolSanitizationProxyCtor; + _CodexReasoningProxy?: CodexReasoningProxyCtor; +} + +export interface ProxyChainResult { + toolSanitizationProxy: ToolSanitizationProxy | null; + toolSanitizationPort: number | null; + codexReasoningProxy: CodexReasoningProxy | null; + codexReasoningPort: number | null; +} + +// ── Implementation ──────────────────────────────────────────────────────────── + +/** + * Build and start the two env-dependent proxy layers (tool-sanitization and + * codex-reasoning). Each layer is started independently; a failed start is + * swallowed with a verbose warning so the session can continue degraded. + * + * Note: HttpsTunnelProxy is started inline in the orchestrator (index.ts) + * before this function is called, because tunnelPort is required for + * imageAnalysisResolution and the first-pass buildClaudeEnvironment. + */ +export async function buildProxyChain(context: ProxyChainContext): Promise { + const { + provider, + useRemoteProxy, + proxyConfig, + cfg, + initialEnvVars, + thinkingOverride, + thinkingCfg, + verbose, + log, + // Allow test injection of proxy constructors so tests never spin up real servers + _ToolSanitizationProxy: ToolSanitizationProxyImpl = ToolSanitizationProxy, + _CodexReasoningProxy: CodexReasoningProxyImpl = CodexReasoningProxy, + } = context; + + // ── Step 1: Tool sanitization proxy ──────────────────────────────────────── + let toolSanitizationProxy: ToolSanitizationProxy | null = null; + let toolSanitizationPort: number | null = null; + + if (initialEnvVars.ANTHROPIC_BASE_URL) { + try { + toolSanitizationProxy = new ToolSanitizationProxyImpl({ + upstreamBaseUrl: initialEnvVars.ANTHROPIC_BASE_URL, + verbose, + warnOnSanitize: true, + allowSelfSigned: useRemoteProxy ? (proxyConfig.allowSelfSigned ?? false) : false, + }); + toolSanitizationPort = await toolSanitizationProxy.start(); + log(`Tool sanitization proxy active on port ${toolSanitizationPort}`); + } catch (error) { + const err = error as Error; + toolSanitizationProxy = null; + toolSanitizationPort = null; + if (verbose) { + console.error(warn(`Tool sanitization proxy disabled: ${err.message}`)); + } + } + } + + // ── Step 2: Codex reasoning proxy ────────────────────────────────────────── + let codexReasoningProxy: CodexReasoningProxy | null = null; + let codexReasoningPort: number | null = null; + + // Composite variants require root model-routed endpoints, never provider-pinned codex endpoints. + if (provider === 'codex' && !cfg.isComposite) { + const postSanitizationBaseUrl = toolSanitizationPort + ? `http://127.0.0.1:${toolSanitizationPort}` + : initialEnvVars.ANTHROPIC_BASE_URL; + + if (!postSanitizationBaseUrl) { + log('ANTHROPIC_BASE_URL not set for Codex, reasoning proxy disabled'); + } else { + try { + const traceEnabled = + process.env.CCS_CODEX_REASONING_TRACE === '1' || + process.env.CCS_CODEX_REASONING_TRACE === 'true'; + const stripPathPrefix = useRemoteProxy ? '/api/provider/codex' : undefined; + const codexThinkingOff = shouldDisableCodexReasoning(thinkingCfg, thinkingOverride); + codexReasoningProxy = new CodexReasoningProxyImpl({ + upstreamBaseUrl: postSanitizationBaseUrl, + verbose, + defaultEffort: 'medium', + disableEffort: codexThinkingOff, + traceFilePath: traceEnabled ? path.join(getCcsDir(), 'codex-reasoning-proxy.log') : '', + allowSelfSigned: useRemoteProxy ? (proxyConfig.allowSelfSigned ?? false) : false, + modelMap: { + defaultModel: initialEnvVars.ANTHROPIC_MODEL, + opusModel: initialEnvVars.ANTHROPIC_DEFAULT_OPUS_MODEL, + sonnetModel: initialEnvVars.ANTHROPIC_DEFAULT_SONNET_MODEL, + haikuModel: initialEnvVars.ANTHROPIC_DEFAULT_HAIKU_MODEL, + }, + stripPathPrefix, + }); + codexReasoningPort = await codexReasoningProxy.start(); + log( + `Codex reasoning proxy active: http://127.0.0.1:${codexReasoningPort}/api/provider/codex` + ); + } catch (error) { + const err = error as Error; + codexReasoningProxy = null; + codexReasoningPort = null; + if (verbose) { + console.error(warn(`Codex reasoning proxy disabled: ${err.message}`)); + } + } + } + } + + return { + toolSanitizationProxy, + toolSanitizationPort, + codexReasoningProxy, + codexReasoningPort, + }; +}