refactor(cliproxy/executor): extract proxy-chain-builder from index.ts

Phase 07 of #1162. Splits tool-sanitization + codex-reasoning proxy spawn
out of the orchestrator:

- src/cliproxy/executor/proxy-chain-builder.ts (185 LOC):
  buildProxyChain + ProxyChainContext / ProxyChainResult types.
  DI escape hatch (_ToolSanitizationProxy / _CodexReasoningProxy) for
  testability without refactoring the proxy classes (Bun module cache
  blocks mock.module of already-loaded modules).
- 9 unit tests covering codex-only, tool-san only, both-together,
  spawn failure swallowing, env propagation.

index.ts: 716 -> 665 LOC (-51). HTTPS tunnel kept inline because
tunnelPort is needed by image-analysis resolution before first-pass
buildClaudeEnvironment — folding it in would also require moving image
analysis. Two-pass buildClaudeEnvironment dance preserved exactly.
Behavior unchanged; full suite passes 1824/1824.

Refs #1162
This commit is contained in:
Tam Nhu Tran
2026-05-02 22:32:37 -04:00
parent 8b7e7f4847
commit 8b39d8a25f
3 changed files with 476 additions and 73 deletions
@@ -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> | 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<typeof makeStubCtor>;
let defaultCodex: ReturnType<typeof makeStubCtor>;
beforeEach(() => {
defaultToolSan = makeStubCtor(19001);
defaultCodex = makeStubCtor(19002);
});
function baseCtx(overrides: Partial<ProxyChainContext> = {}): 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:<sanPort> 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();
});
});
+18 -73
View File
@@ -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({
@@ -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<Record<string, string>>;
/** 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<ProxyChainResult> {
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,
};
}