mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 00:22:34 +00:00
refactor(cliproxy/executor): extract model-warnings + claude-launcher and polish orchestrator
Phases 08+09+10 of #1162. Final extractions and orchestrator cleanup: - src/cliproxy/executor/model-warnings.ts (80 LOC): warnBrokenModels — handles composite + simple paths, broken-model notification with replacement suggestions. - src/cliproxy/executor/claude-launcher.ts (161 LOC): launchClaude — final args assembly (web search, image analysis, browser tool args), trace context env, Windows shell escaping, spawn, quota monitor wiring, cleanup handlers. - New tests: 227 + 217 LOC. - index.ts cleanup: numbered section comments through orchestrator, removed ~17 now-unused imports, re-export block preserved. index.ts: 665 -> 550 LOC. Final reduction across all 10 phases: 1428 -> 550 (-878, -61%). Behavior unchanged; full suite passes 1824/1824. The remaining 550 LOC is the natural orchestrator floor — further extraction would require a 15-field context struct, which is manufactured complexity rather than genuine separation of concerns. Closes phases 02-10 of #1162. Refs #1162
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Tests for claude-launcher.ts — Phase 09
|
||||
*
|
||||
* Verifies:
|
||||
* - spawn called with expected args and env
|
||||
* - Windows shell escaping path
|
||||
* - Cleanup handlers registered (setupCleanupHandlers called)
|
||||
*
|
||||
* Strategy: mock child_process.spawn and all side-effectful dependencies so
|
||||
* no real processes are started.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, jest, beforeEach, afterEach, mock } from 'bun:test';
|
||||
import type { ChildProcess } from 'child_process';
|
||||
import type { ExecutorConfig } from '../../types';
|
||||
|
||||
// ── Spawn mock ────────────────────────────────────────────────────────────────
|
||||
|
||||
const mockSpawnResult = {
|
||||
pid: 42,
|
||||
on: jest.fn(),
|
||||
stdout: null,
|
||||
stderr: null,
|
||||
} as unknown as ChildProcess;
|
||||
|
||||
const mockSpawn = jest.fn().mockReturnValue(mockSpawnResult);
|
||||
|
||||
mock.module('child_process', () => ({
|
||||
spawn: mockSpawn,
|
||||
}));
|
||||
|
||||
// ── Dependency mocks ──────────────────────────────────────────────────────────
|
||||
|
||||
const mockEscapeShellArg = jest.fn((s: string) => `"${s}"`);
|
||||
const mockGetWindowsEscapedCommandShell = jest.fn().mockReturnValue('cmd.exe');
|
||||
|
||||
mock.module('../../utils/shell-executor', () => ({
|
||||
escapeShellArg: mockEscapeShellArg,
|
||||
getWindowsEscapedCommandShell: mockGetWindowsEscapedCommandShell,
|
||||
}));
|
||||
|
||||
mock.module('../../config/config-generator', () => ({
|
||||
getProviderSettingsPath: jest.fn().mockReturnValue('/fake/.ccs/settings/gemini.json'),
|
||||
CLIPROXY_DEFAULT_PORT: 8317,
|
||||
generateConfig: jest.fn(),
|
||||
getProviderConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
mock.module('../../utils/websearch-manager', () => ({
|
||||
appendThirdPartyWebSearchToolArgs: (args: string[]) => args,
|
||||
createWebSearchTraceContext: jest.fn().mockReturnValue({}),
|
||||
ensureWebSearchMcpOrThrow: jest.fn(),
|
||||
displayWebSearchStatus: jest.fn(),
|
||||
getWebSearchHookEnv: jest.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
mock.module('../../utils/image-analysis', () => ({
|
||||
appendThirdPartyImageAnalysisToolArgs: (args: string[]) => [...args, '--mcp-image-analysis'],
|
||||
syncImageAnalysisMcpToConfigDir: jest.fn(),
|
||||
ensureImageAnalysisMcpOrThrow: jest.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
mock.module('../../utils/browser', () => ({
|
||||
appendBrowserToolArgs: (args: string[]) => [...args, '--browser'],
|
||||
}));
|
||||
|
||||
mock.module('../accounts/account-manager', () => ({
|
||||
getDefaultAccount: jest.fn().mockReturnValue(null),
|
||||
}));
|
||||
|
||||
const mockSetupCleanupHandlers = jest.fn();
|
||||
mock.module('../session-bridge', () => ({
|
||||
setupCleanupHandlers: mockSetupCleanupHandlers,
|
||||
checkOrJoinProxy: jest.fn(),
|
||||
registerProxySession: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockResolveRuntimeQuotaMonitorProviders = jest.fn().mockReturnValue([]);
|
||||
mock.module('../account-resolution', () => ({
|
||||
resolveRuntimeQuotaMonitorProviders: mockResolveRuntimeQuotaMonitorProviders,
|
||||
resolveAccounts: jest.fn(),
|
||||
}));
|
||||
|
||||
// Dynamic import for quota-manager
|
||||
mock.module('../quota/quota-manager', () => ({
|
||||
startQuotaMonitor: jest.fn(),
|
||||
stopQuotaMonitor: jest.fn(),
|
||||
}));
|
||||
|
||||
// ── Subject under test ────────────────────────────────────────────────────────
|
||||
|
||||
import { launchClaude } from '../claude-launcher';
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeCfg(overrides: Partial<ExecutorConfig> = {}): ExecutorConfig {
|
||||
return {
|
||||
port: 8317,
|
||||
timeout: 5000,
|
||||
verbose: false,
|
||||
pollInterval: 100,
|
||||
...overrides,
|
||||
} as ExecutorConfig;
|
||||
}
|
||||
|
||||
function baseContext(overrides: object = {}) {
|
||||
return {
|
||||
claudeCli: '/usr/local/bin/claude',
|
||||
claudeArgs: ['chat', '--model', 'gemini-2.5-pro'],
|
||||
env: { ANTHROPIC_BASE_URL: 'http://localhost:8317' } as NodeJS.ProcessEnv,
|
||||
cfg: makeCfg(),
|
||||
provider: 'gemini' as const,
|
||||
compositeProviders: [] as string[],
|
||||
skipLocalAuth: true,
|
||||
sessionId: undefined,
|
||||
imageAnalysisMcpReady: false,
|
||||
browserRuntimeEnv: undefined,
|
||||
inheritedClaudeConfigDir: undefined,
|
||||
codexReasoningProxy: null,
|
||||
toolSanitizationProxy: null,
|
||||
httpsTunnel: null,
|
||||
verbose: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('launchClaude', () => {
|
||||
beforeEach(() => {
|
||||
mockSpawn.mockClear();
|
||||
mockSetupCleanupHandlers.mockClear();
|
||||
mockEscapeShellArg.mockClear();
|
||||
});
|
||||
|
||||
it('calls spawn with claudeCli and includes --settings arg', async () => {
|
||||
const ctx = baseContext();
|
||||
await launchClaude(ctx);
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledTimes(1);
|
||||
const [cmd, spawnArgs] = mockSpawn.mock.calls[0] as [string, string[]];
|
||||
expect(cmd).toBe('/usr/local/bin/claude');
|
||||
expect(spawnArgs).toContain('--settings');
|
||||
});
|
||||
|
||||
it('inherits process env merged with provided env', async () => {
|
||||
const ctx = baseContext({ env: { CUSTOM_KEY: 'custom-value' } as NodeJS.ProcessEnv });
|
||||
await launchClaude(ctx);
|
||||
|
||||
const spawnOpts = mockSpawn.mock.calls[0][2] as { env: NodeJS.ProcessEnv };
|
||||
expect(spawnOpts.env?.CUSTOM_KEY).toBe('custom-value');
|
||||
});
|
||||
|
||||
it('passes stdio: inherit', async () => {
|
||||
await launchClaude(baseContext());
|
||||
const spawnOpts = mockSpawn.mock.calls[0][2] as { stdio: string };
|
||||
expect(spawnOpts.stdio).toBe('inherit');
|
||||
});
|
||||
|
||||
it('appends image analysis args when imageAnalysisMcpReady=true', async () => {
|
||||
await launchClaude(baseContext({ imageAnalysisMcpReady: true }));
|
||||
const spawnArgs = mockSpawn.mock.calls[0][1] as string[];
|
||||
expect(spawnArgs).toContain('--mcp-image-analysis');
|
||||
});
|
||||
|
||||
it('does not append image analysis args when imageAnalysisMcpReady=false', async () => {
|
||||
await launchClaude(baseContext({ imageAnalysisMcpReady: false }));
|
||||
const spawnArgs = mockSpawn.mock.calls[0][1] as string[];
|
||||
expect(spawnArgs).not.toContain('--mcp-image-analysis');
|
||||
});
|
||||
|
||||
it('appends browser tool args when browserRuntimeEnv is set', async () => {
|
||||
await launchClaude(
|
||||
baseContext({ browserRuntimeEnv: { CCS_BROWSER: '1' } as NodeJS.ProcessEnv })
|
||||
);
|
||||
const spawnArgs = mockSpawn.mock.calls[0][1] as string[];
|
||||
expect(spawnArgs).toContain('--browser');
|
||||
});
|
||||
|
||||
it('registers cleanup handlers after spawn', async () => {
|
||||
await launchClaude(baseContext());
|
||||
expect(mockSetupCleanupHandlers).toHaveBeenCalledTimes(1);
|
||||
// First arg should be the ChildProcess returned by spawn
|
||||
expect(mockSetupCleanupHandlers.mock.calls[0][0]).toBe(mockSpawnResult);
|
||||
});
|
||||
|
||||
it('returns the ChildProcess from spawn', async () => {
|
||||
const result = await launchClaude(baseContext());
|
||||
expect(result).toBe(mockSpawnResult);
|
||||
});
|
||||
|
||||
describe('Windows shell escaping', () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
|
||||
});
|
||||
|
||||
it('uses shell mode for .cmd executables on Windows', async () => {
|
||||
await launchClaude(baseContext({ claudeCli: 'C:\\tools\\claude.cmd' }));
|
||||
const spawnOpts = mockSpawn.mock.calls[0][2] as { shell: string | boolean };
|
||||
// shell property should be set (cmd.exe from mock)
|
||||
expect(spawnOpts.shell).toBeTruthy();
|
||||
});
|
||||
|
||||
it('skips shell mode for non-script executables on Windows', async () => {
|
||||
await launchClaude(baseContext({ claudeCli: 'C:\\tools\\claude.exe' }));
|
||||
// spawn should be called with separate args array, not a shell cmd string
|
||||
const [, secondArg] = mockSpawn.mock.calls[0];
|
||||
expect(Array.isArray(secondArg)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Tests for model-warnings.ts — Phase 08
|
||||
*
|
||||
* Verifies that warnBrokenModels emits warnings for broken models and is
|
||||
* silent for healthy ones, covering both simple and composite providers.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, jest } from 'bun:test';
|
||||
|
||||
// ── Stubs ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// getCurrentModel: return whatever the test configures
|
||||
const mockGetCurrentModel = jest.fn<string | undefined, [string, string | undefined]>();
|
||||
// isModelBroken: return false by default
|
||||
const mockIsModelBroken = jest.fn<boolean, [string, string]>().mockReturnValue(false);
|
||||
const mockGetModelIssueUrl = jest
|
||||
.fn<string | undefined, [string, string]>()
|
||||
.mockReturnValue(undefined);
|
||||
const mockFindModel = jest
|
||||
.fn<{ name: string } | undefined, [string, string]>()
|
||||
.mockReturnValue(undefined);
|
||||
const mockGetSuggestedReplacementModel = jest
|
||||
.fn<string | undefined, [string, string]>()
|
||||
.mockReturnValue(undefined);
|
||||
|
||||
jest.mock('../model-warnings', () => {
|
||||
// We test the real implementation — only mock its dependencies
|
||||
return jest.requireActual('../model-warnings');
|
||||
});
|
||||
|
||||
jest.mock('../../config/model-config', () => ({
|
||||
getCurrentModel: mockGetCurrentModel,
|
||||
}));
|
||||
|
||||
jest.mock('../../cliproxy/model-catalog', () => ({
|
||||
isModelBroken: mockIsModelBroken,
|
||||
getModelIssueUrl: mockGetModelIssueUrl,
|
||||
findModel: mockFindModel,
|
||||
getSuggestedReplacementModel: mockGetSuggestedReplacementModel,
|
||||
}));
|
||||
|
||||
// We import from real path after jest.mock so actual module is used
|
||||
import { warnBrokenModels } from '../model-warnings';
|
||||
import type { ExecutorConfig } from '../../types';
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeSimpleCfg(overrides: Partial<ExecutorConfig> = {}): ExecutorConfig {
|
||||
return {
|
||||
port: 8317,
|
||||
timeout: 5000,
|
||||
verbose: false,
|
||||
pollInterval: 100,
|
||||
isComposite: false,
|
||||
...overrides,
|
||||
} as ExecutorConfig;
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('warnBrokenModels', () => {
|
||||
let errorSpy: ReturnType<typeof jest.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mockGetCurrentModel.mockReset();
|
||||
mockIsModelBroken.mockReturnValue(false);
|
||||
mockGetModelIssueUrl.mockReturnValue(undefined);
|
||||
mockFindModel.mockReturnValue(undefined);
|
||||
mockGetSuggestedReplacementModel.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('does not warn when no model is configured', () => {
|
||||
mockGetCurrentModel.mockReturnValue(undefined);
|
||||
|
||||
warnBrokenModels({
|
||||
provider: 'gemini',
|
||||
cfg: makeSimpleCfg(),
|
||||
compositeProviders: [],
|
||||
skipLocalAuth: false,
|
||||
});
|
||||
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not warn when model is healthy', () => {
|
||||
mockGetCurrentModel.mockReturnValue('gemini-2.5-pro');
|
||||
mockIsModelBroken.mockReturnValue(false);
|
||||
|
||||
warnBrokenModels({
|
||||
provider: 'gemini',
|
||||
cfg: makeSimpleCfg(),
|
||||
compositeProviders: [],
|
||||
skipLocalAuth: false,
|
||||
});
|
||||
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('warns when model is broken (no replacement, no issue url)', () => {
|
||||
mockGetCurrentModel.mockReturnValue('gemini-old');
|
||||
mockIsModelBroken.mockReturnValue(true);
|
||||
mockFindModel.mockReturnValue({ name: 'Gemini Old' } as { name: string });
|
||||
|
||||
warnBrokenModels({
|
||||
provider: 'gemini',
|
||||
cfg: makeSimpleCfg(),
|
||||
compositeProviders: [],
|
||||
skipLocalAuth: false,
|
||||
});
|
||||
|
||||
const calls = errorSpy.mock.calls.map((c) => String(c[0]));
|
||||
expect(calls.some((m) => m.includes('has known issues with Claude Code'))).toBe(true);
|
||||
expect(calls.some((m) => m.includes('Tool calls will fail'))).toBe(true);
|
||||
});
|
||||
|
||||
it('includes replacement model suggestion when available', () => {
|
||||
mockGetCurrentModel.mockReturnValue('gemini-old');
|
||||
mockIsModelBroken.mockReturnValue(true);
|
||||
mockGetSuggestedReplacementModel.mockReturnValue('gemini-2.5-pro');
|
||||
|
||||
warnBrokenModels({
|
||||
provider: 'gemini',
|
||||
cfg: makeSimpleCfg(),
|
||||
compositeProviders: [],
|
||||
skipLocalAuth: false,
|
||||
});
|
||||
|
||||
const calls = errorSpy.mock.calls.map((c) => String(c[0]));
|
||||
expect(calls.some((m) => m.includes('gemini-2.5-pro'))).toBe(true);
|
||||
});
|
||||
|
||||
it('includes tracking URL when issue url is available', () => {
|
||||
mockGetCurrentModel.mockReturnValue('gemini-old');
|
||||
mockIsModelBroken.mockReturnValue(true);
|
||||
mockGetModelIssueUrl.mockReturnValue('https://github.com/issues/123');
|
||||
|
||||
warnBrokenModels({
|
||||
provider: 'gemini',
|
||||
cfg: makeSimpleCfg(),
|
||||
compositeProviders: [],
|
||||
skipLocalAuth: false,
|
||||
});
|
||||
|
||||
const calls = errorSpy.mock.calls.map((c) => String(c[0]));
|
||||
expect(calls.some((m) => m.includes('https://github.com/issues/123'))).toBe(true);
|
||||
});
|
||||
|
||||
it('includes remote proxy note when skipLocalAuth=true', () => {
|
||||
mockGetCurrentModel.mockReturnValue('gemini-old');
|
||||
mockIsModelBroken.mockReturnValue(true);
|
||||
|
||||
warnBrokenModels({
|
||||
provider: 'gemini',
|
||||
cfg: makeSimpleCfg(),
|
||||
compositeProviders: [],
|
||||
skipLocalAuth: true,
|
||||
});
|
||||
|
||||
const calls = errorSpy.mock.calls.map((c) => String(c[0]));
|
||||
expect(calls.some((m) => m.includes('remote proxy'))).toBe(true);
|
||||
});
|
||||
|
||||
it('includes --config suggestion when skipLocalAuth=false', () => {
|
||||
mockGetCurrentModel.mockReturnValue('gemini-old');
|
||||
mockIsModelBroken.mockReturnValue(true);
|
||||
|
||||
warnBrokenModels({
|
||||
provider: 'gemini',
|
||||
cfg: makeSimpleCfg(),
|
||||
compositeProviders: [],
|
||||
skipLocalAuth: false,
|
||||
});
|
||||
|
||||
const calls = errorSpy.mock.calls.map((c) => String(c[0]));
|
||||
expect(calls.some((m) => m.includes('--config'))).toBe(true);
|
||||
});
|
||||
|
||||
describe('composite variants', () => {
|
||||
function makeCompositeCfg(): ExecutorConfig {
|
||||
return {
|
||||
port: 8317,
|
||||
timeout: 5000,
|
||||
verbose: false,
|
||||
pollInterval: 100,
|
||||
isComposite: true,
|
||||
compositeTiers: {
|
||||
opus: { provider: 'gemini', model: 'gemini-opus-old' },
|
||||
sonnet: { provider: 'gemini', model: 'gemini-sonnet-ok' },
|
||||
haiku: { provider: 'gemini', model: 'gemini-haiku-ok' },
|
||||
},
|
||||
} as unknown as ExecutorConfig;
|
||||
}
|
||||
|
||||
it('warns for broken composite tier', () => {
|
||||
mockIsModelBroken.mockImplementation((_p, m) => m === 'gemini-opus-old');
|
||||
mockFindModel.mockReturnValue({ name: 'Gemini Opus Old' } as { name: string });
|
||||
|
||||
warnBrokenModels({
|
||||
provider: 'gemini',
|
||||
cfg: makeCompositeCfg(),
|
||||
compositeProviders: ['gemini'],
|
||||
skipLocalAuth: false,
|
||||
});
|
||||
|
||||
const calls = errorSpy.mock.calls.map((c) => String(c[0]));
|
||||
expect(calls.some((m) => m.includes('opus tier'))).toBe(true);
|
||||
});
|
||||
|
||||
it('does not warn for healthy composite tiers', () => {
|
||||
mockIsModelBroken.mockReturnValue(false);
|
||||
|
||||
warnBrokenModels({
|
||||
provider: 'gemini',
|
||||
cfg: makeCompositeCfg(),
|
||||
compositeProviders: ['gemini'],
|
||||
skipLocalAuth: false,
|
||||
});
|
||||
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Claude Launcher — Concern H
|
||||
*
|
||||
* Handles final argument assembly, trace context injection, Windows shell
|
||||
* escaping, spawning the Claude CLI process, starting the runtime quota
|
||||
* monitor, and wiring up cleanup handlers.
|
||||
*/
|
||||
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import * as os from 'os';
|
||||
import { escapeShellArg, getWindowsEscapedCommandShell } from '../../utils/shell-executor';
|
||||
import { getProviderSettingsPath } from '../config/config-generator';
|
||||
import {
|
||||
ensureWebSearchMcpOrThrow as _ensureWebSearchMcpOrThrow,
|
||||
appendThirdPartyWebSearchToolArgs,
|
||||
createWebSearchTraceContext,
|
||||
} from '../../utils/websearch-manager';
|
||||
import { appendThirdPartyImageAnalysisToolArgs } from '../../utils/image-analysis';
|
||||
import { appendBrowserToolArgs } from '../../utils/browser';
|
||||
import { getDefaultAccount } from '../accounts/account-manager';
|
||||
import { CLIProxyProvider, ExecutorConfig } from '../types';
|
||||
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 { resolveRuntimeQuotaMonitorProviders } from './account-resolution';
|
||||
|
||||
export interface ClaudeLaunchContext {
|
||||
/** Path to the Claude CLI executable */
|
||||
claudeCli: string;
|
||||
/** Pre-filtered Claude args (CCS flags already stripped) */
|
||||
claudeArgs: string[];
|
||||
/** Fully assembled environment variables (without trace additions) */
|
||||
env: NodeJS.ProcessEnv;
|
||||
/** Resolved executor config */
|
||||
cfg: ExecutorConfig;
|
||||
/** Active CLIProxy provider */
|
||||
provider: CLIProxyProvider;
|
||||
/** Providers derived from composite tiers (empty for simple providers) */
|
||||
compositeProviders: CLIProxyProvider[];
|
||||
/** Whether local OAuth was skipped (remote proxy auth in use) */
|
||||
skipLocalAuth: boolean;
|
||||
/** Session ID for cleanup tracking */
|
||||
sessionId: string | undefined;
|
||||
/** Whether image analysis MCP is ready */
|
||||
imageAnalysisMcpReady: boolean;
|
||||
/** Browser runtime environment variables (undefined if browser not active) */
|
||||
browserRuntimeEnv: NodeJS.ProcessEnv | undefined;
|
||||
/** Inherited Claude config dir for continuity */
|
||||
inheritedClaudeConfigDir: string | undefined;
|
||||
/** Active Codex reasoning proxy (if any) */
|
||||
codexReasoningProxy: CodexReasoningProxy | null;
|
||||
/** Active tool sanitization proxy (if any) */
|
||||
toolSanitizationProxy: ToolSanitizationProxy | null;
|
||||
/** Active HTTPS tunnel proxy (if any) */
|
||||
httpsTunnel: HttpsTunnelProxy | null;
|
||||
/** Whether verbose logging is enabled */
|
||||
verbose: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble final Claude CLI arguments, inject trace context, spawn the process,
|
||||
* start the runtime quota monitor, and wire cleanup handlers.
|
||||
*
|
||||
* @returns The spawned ChildProcess for the Claude CLI.
|
||||
*/
|
||||
export async function launchClaude(context: ClaudeLaunchContext): Promise<ChildProcess> {
|
||||
const {
|
||||
claudeCli,
|
||||
claudeArgs,
|
||||
env,
|
||||
cfg,
|
||||
provider,
|
||||
compositeProviders,
|
||||
skipLocalAuth,
|
||||
sessionId,
|
||||
imageAnalysisMcpReady,
|
||||
browserRuntimeEnv,
|
||||
inheritedClaudeConfigDir,
|
||||
codexReasoningProxy,
|
||||
toolSanitizationProxy,
|
||||
httpsTunnel,
|
||||
verbose,
|
||||
} = context;
|
||||
|
||||
const isWindows = process.platform === 'win32';
|
||||
const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli);
|
||||
|
||||
const settingsPath = cfg.customSettingsPath
|
||||
? cfg.customSettingsPath.replace(/^~/, os.homedir())
|
||||
: getProviderSettingsPath(provider);
|
||||
|
||||
// Assemble final args: image analysis tools → browser tools → web search tools → settings
|
||||
const imageAnalysisArgs = imageAnalysisMcpReady
|
||||
? appendThirdPartyImageAnalysisToolArgs(claudeArgs)
|
||||
: claudeArgs;
|
||||
const browserArgs = browserRuntimeEnv
|
||||
? appendBrowserToolArgs(imageAnalysisArgs)
|
||||
: imageAnalysisArgs;
|
||||
const launchArgs = [
|
||||
'--settings',
|
||||
settingsPath,
|
||||
...appendThirdPartyWebSearchToolArgs(browserArgs),
|
||||
];
|
||||
|
||||
// Inject web search trace context into env
|
||||
const traceEnv = createWebSearchTraceContext({
|
||||
launcher: 'cliproxy.executor',
|
||||
args: launchArgs,
|
||||
profile: cfg.profileName || provider,
|
||||
profileType: 'cliproxy',
|
||||
settingsPath,
|
||||
claudeConfigDir: inheritedClaudeConfigDir,
|
||||
});
|
||||
const tracedEnv = { ...env, ...traceEnv };
|
||||
|
||||
// Spawn: Windows .cmd/.bat/.ps1 need shell escaping; all others spawn directly
|
||||
let claude: ChildProcess;
|
||||
if (needsShell) {
|
||||
const cmdString = [claudeCli, ...launchArgs].map(escapeShellArg).join(' ');
|
||||
claude = spawn(cmdString, {
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
shell: getWindowsEscapedCommandShell(),
|
||||
env: tracedEnv,
|
||||
});
|
||||
} else {
|
||||
claude = spawn(claudeCli, launchArgs, {
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
env: tracedEnv,
|
||||
});
|
||||
}
|
||||
|
||||
// Start runtime quota monitor (adaptive polling during session)
|
||||
if (!skipLocalAuth) {
|
||||
const { startQuotaMonitor } = await import('../quota/quota-manager');
|
||||
for (const monitorProvider of resolveRuntimeQuotaMonitorProviders(
|
||||
provider,
|
||||
compositeProviders
|
||||
)) {
|
||||
const monitorAccount = getDefaultAccount(monitorProvider);
|
||||
if (monitorAccount) {
|
||||
startQuotaMonitor(monitorProvider, monitorAccount.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wire cleanup handlers (process exit, SIGINT, SIGTERM, proxy teardown)
|
||||
setupCleanupHandlers(
|
||||
claude,
|
||||
sessionId,
|
||||
cfg.port,
|
||||
codexReasoningProxy,
|
||||
toolSanitizationProxy,
|
||||
httpsTunnel,
|
||||
verbose
|
||||
);
|
||||
|
||||
return claude;
|
||||
}
|
||||
+21
-132
@@ -10,41 +10,24 @@
|
||||
* 6. Claude CLI execution with cleanup handlers
|
||||
*/
|
||||
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import { ChildProcess } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import { fail, info, warn } from '../../utils/ui';
|
||||
import { escapeShellArg, getWindowsEscapedCommandShell } from '../../utils/shell-executor';
|
||||
import {
|
||||
generateConfig,
|
||||
getProviderConfig,
|
||||
getProviderSettingsPath,
|
||||
CLIPROXY_DEFAULT_PORT,
|
||||
} from '../config/config-generator';
|
||||
import { configureProviderModel, getCurrentModel } from '../config/model-config';
|
||||
import { configureProviderModel } from '../config/model-config';
|
||||
import { supportsModelConfig } from '../model-catalog';
|
||||
import { CLIProxyProvider, ExecutorConfig } from '../types';
|
||||
import {
|
||||
isModelBroken,
|
||||
getModelIssueUrl,
|
||||
findModel,
|
||||
getSuggestedReplacementModel,
|
||||
} from '../model-catalog';
|
||||
import { CodexReasoningProxy } from '../ai-providers/codex-reasoning-proxy';
|
||||
import { ToolSanitizationProxy } from '../proxy/tool-sanitization-proxy';
|
||||
import {
|
||||
ensureWebSearchMcpOrThrow,
|
||||
displayWebSearchStatus,
|
||||
appendThirdPartyWebSearchToolArgs,
|
||||
createWebSearchTraceContext,
|
||||
} from '../../utils/websearch-manager';
|
||||
import { ensureWebSearchMcpOrThrow, displayWebSearchStatus } from '../../utils/websearch-manager';
|
||||
import {
|
||||
ensureImageAnalysisMcpOrThrow,
|
||||
syncImageAnalysisMcpToConfigDir,
|
||||
appendThirdPartyImageAnalysisToolArgs,
|
||||
} from '../../utils/image-analysis';
|
||||
import { getDefaultAccount } from '../accounts/account-manager';
|
||||
import { appendBrowserToolArgs } from '../../utils/browser';
|
||||
import { loadOrCreateUnifiedConfig, getThinkingConfig } from '../../config/unified-config-loader';
|
||||
import { HttpsTunnelProxy } from '../proxy/https-tunnel-proxy';
|
||||
import { resolveProfileContinuityInheritance } from '../../auth/profile-continuity-inheritance';
|
||||
@@ -61,7 +44,7 @@ import {
|
||||
logEnvironment,
|
||||
resolveCliproxyImageAnalysisEnv,
|
||||
} from './env-resolver';
|
||||
import { checkOrJoinProxy, registerProxySession, setupCleanupHandlers } from './session-bridge';
|
||||
import { checkOrJoinProxy, registerProxySession } from './session-bridge';
|
||||
import { getWebSearchHookEnv } from '../../utils/websearch-manager';
|
||||
import {
|
||||
handleLogout,
|
||||
@@ -82,6 +65,8 @@ import { shouldStartHttpsTunnel } from './https-tunnel-policy';
|
||||
import { filterCcsFlags, parseExecutorFlags, validateFlagCombinations } from './arg-parser';
|
||||
import { resolveExecutorProxy } from './proxy-resolver';
|
||||
import { buildProxyChain } from './proxy-chain-builder';
|
||||
import { warnBrokenModels } from './model-warnings';
|
||||
import { launchClaude } from './claude-launcher';
|
||||
|
||||
/** Local alias so internal call sites need no change */
|
||||
const resolveRuntimeQuotaMonitorProviders = _resolveRuntimeQuotaMonitorProviders;
|
||||
@@ -276,51 +261,7 @@ export async function execClaudeWithCLIProxy(
|
||||
}
|
||||
|
||||
// 5. Check for broken models (multi-tier for composite)
|
||||
if (compositeProviders.length > 0 && cfg.compositeTiers) {
|
||||
// Check all tier models in composite variant
|
||||
const tiers: Array<'opus' | 'sonnet' | 'haiku'> = ['opus', 'sonnet', 'haiku'];
|
||||
for (const tier of tiers) {
|
||||
const tierConfig = cfg.compositeTiers[tier];
|
||||
if (tierConfig && isModelBroken(tierConfig.provider, tierConfig.model)) {
|
||||
const modelEntry = findModel(tierConfig.provider, tierConfig.model);
|
||||
const issueUrl = getModelIssueUrl(tierConfig.provider, tierConfig.model);
|
||||
console.error('');
|
||||
console.error(
|
||||
warn(
|
||||
`${tier} tier: ${modelEntry?.name || tierConfig.model} has known issues with Claude Code`
|
||||
)
|
||||
);
|
||||
console.error(' Tool calls will fail. Consider changing the model in config.yaml.');
|
||||
if (issueUrl) {
|
||||
console.error(` Tracking: ${issueUrl}`);
|
||||
}
|
||||
console.error('');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const currentModel = getCurrentModel(provider, cfg.customSettingsPath);
|
||||
if (currentModel && isModelBroken(provider, currentModel)) {
|
||||
const modelEntry = findModel(provider, currentModel);
|
||||
const issueUrl = getModelIssueUrl(provider, currentModel);
|
||||
const replacementModel = getSuggestedReplacementModel(provider, currentModel);
|
||||
console.error('');
|
||||
console.error(warn(`${modelEntry?.name || currentModel} has known issues with Claude Code`));
|
||||
if (replacementModel) {
|
||||
console.error(` Tool calls will fail. Use "${replacementModel}" instead.`);
|
||||
} else {
|
||||
console.error(' Tool calls will fail. Consider changing the model in config.yaml.');
|
||||
}
|
||||
if (issueUrl) {
|
||||
console.error(` Tracking: ${issueUrl}`);
|
||||
}
|
||||
if (skipLocalAuth) {
|
||||
console.error(' Note: Model may be overridden by remote proxy configuration.');
|
||||
} else {
|
||||
console.error(` Run "ccs ${provider} --config" to change model.`);
|
||||
}
|
||||
console.error('');
|
||||
}
|
||||
}
|
||||
warnBrokenModels({ provider, cfg, compositeProviders, skipLocalAuth });
|
||||
|
||||
// 6. Ensure user settings file exists
|
||||
ensureProviderSettingsFile(provider);
|
||||
@@ -571,77 +512,25 @@ export async function execClaudeWithCLIProxy(
|
||||
console.error(`[i] Thinking: ${thinkingLabel} (${sourceLabel})`);
|
||||
}
|
||||
|
||||
// 12. Filter CCS-specific flags before passing to Claude CLI
|
||||
// 12. Filter CCS flags, spawn Claude CLI, start quota monitor, wire cleanup
|
||||
const claudeArgs = filterCcsFlags(argsWithoutBrowserFlags);
|
||||
|
||||
const isWindows = process.platform === 'win32';
|
||||
const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli);
|
||||
|
||||
const settingsPath = cfg.customSettingsPath
|
||||
? cfg.customSettingsPath.replace(/^~/, os.homedir())
|
||||
: getProviderSettingsPath(provider);
|
||||
|
||||
let claude: ChildProcess;
|
||||
const imageAnalysisArgs = imageAnalysisMcpReady
|
||||
? appendThirdPartyImageAnalysisToolArgs(claudeArgs)
|
||||
: claudeArgs;
|
||||
const browserArgs = browserRuntimeEnv
|
||||
? appendBrowserToolArgs(imageAnalysisArgs)
|
||||
: imageAnalysisArgs;
|
||||
const launchArgs = [
|
||||
'--settings',
|
||||
settingsPath,
|
||||
...appendThirdPartyWebSearchToolArgs(browserArgs),
|
||||
];
|
||||
const traceEnv = createWebSearchTraceContext({
|
||||
launcher: 'cliproxy.executor',
|
||||
args: launchArgs,
|
||||
profile: cfg.profileName || provider,
|
||||
profileType: 'cliproxy',
|
||||
settingsPath,
|
||||
claudeConfigDir: inheritedClaudeConfigDir,
|
||||
});
|
||||
const tracedEnv = { ...env, ...traceEnv };
|
||||
if (needsShell) {
|
||||
const cmdString = [claudeCli, ...launchArgs].map(escapeShellArg).join(' ');
|
||||
claude = spawn(cmdString, {
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
shell: getWindowsEscapedCommandShell(),
|
||||
env: tracedEnv,
|
||||
});
|
||||
} else {
|
||||
claude = spawn(claudeCli, launchArgs, {
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
env: tracedEnv,
|
||||
});
|
||||
}
|
||||
|
||||
// 12b. Start runtime quota monitor (adaptive polling during session)
|
||||
if (!skipLocalAuth) {
|
||||
const { startQuotaMonitor } = await import('../quota/quota-manager');
|
||||
for (const monitorProvider of resolveRuntimeQuotaMonitorProviders(
|
||||
provider,
|
||||
compositeProviders
|
||||
)) {
|
||||
const monitorAccount = getDefaultAccount(monitorProvider);
|
||||
if (monitorAccount) {
|
||||
startQuotaMonitor(monitorProvider, monitorAccount.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 13. Setup cleanup handlers
|
||||
setupCleanupHandlers(
|
||||
claude,
|
||||
await launchClaude({
|
||||
claudeCli,
|
||||
claudeArgs,
|
||||
env,
|
||||
cfg,
|
||||
provider,
|
||||
compositeProviders,
|
||||
skipLocalAuth,
|
||||
sessionId,
|
||||
cfg.port,
|
||||
imageAnalysisMcpReady,
|
||||
browserRuntimeEnv,
|
||||
inheritedClaudeConfigDir,
|
||||
codexReasoningProxy,
|
||||
toolSanitizationProxy,
|
||||
httpsTunnel,
|
||||
verbose
|
||||
);
|
||||
verbose,
|
||||
});
|
||||
}
|
||||
|
||||
// Re-export utility functions for backwards compatibility
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Model Warnings — Concern G
|
||||
*
|
||||
* Emits console warnings when the active model (or any tier model in composite
|
||||
* variants) is flagged as broken in the model catalog.
|
||||
*/
|
||||
|
||||
import { warn } from '../../utils/ui';
|
||||
import { getCurrentModel } from '../config/model-config';
|
||||
import {
|
||||
isModelBroken,
|
||||
getModelIssueUrl,
|
||||
findModel,
|
||||
getSuggestedReplacementModel,
|
||||
} from '../model-catalog';
|
||||
import { CLIProxyProvider, ExecutorConfig } from '../types';
|
||||
|
||||
export interface ModelWarningsContext {
|
||||
provider: CLIProxyProvider;
|
||||
cfg: ExecutorConfig;
|
||||
compositeProviders: CLIProxyProvider[];
|
||||
skipLocalAuth: boolean;
|
||||
customSettingsPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check all active models for known issues and emit warnings.
|
||||
*
|
||||
* For composite variants, checks every tier model.
|
||||
* For simple providers, checks the currently configured model.
|
||||
*/
|
||||
export function warnBrokenModels(context: ModelWarningsContext): void {
|
||||
const { provider, cfg, skipLocalAuth } = context;
|
||||
|
||||
if (cfg.isComposite && cfg.compositeTiers) {
|
||||
// Check all tier models in composite variant
|
||||
const tiers: Array<'opus' | 'sonnet' | 'haiku'> = ['opus', 'sonnet', 'haiku'];
|
||||
for (const tier of tiers) {
|
||||
const tierConfig = cfg.compositeTiers[tier];
|
||||
if (tierConfig && isModelBroken(tierConfig.provider, tierConfig.model)) {
|
||||
const modelEntry = findModel(tierConfig.provider, tierConfig.model);
|
||||
const issueUrl = getModelIssueUrl(tierConfig.provider, tierConfig.model);
|
||||
console.error('');
|
||||
console.error(
|
||||
warn(
|
||||
`${tier} tier: ${modelEntry?.name || tierConfig.model} has known issues with Claude Code`
|
||||
)
|
||||
);
|
||||
console.error(' Tool calls will fail. Consider changing the model in config.yaml.');
|
||||
if (issueUrl) {
|
||||
console.error(` Tracking: ${issueUrl}`);
|
||||
}
|
||||
console.error('');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const currentModel = getCurrentModel(provider, cfg.customSettingsPath);
|
||||
if (currentModel && isModelBroken(provider, currentModel)) {
|
||||
const modelEntry = findModel(provider, currentModel);
|
||||
const issueUrl = getModelIssueUrl(provider, currentModel);
|
||||
const replacementModel = getSuggestedReplacementModel(provider, currentModel);
|
||||
console.error('');
|
||||
console.error(warn(`${modelEntry?.name || currentModel} has known issues with Claude Code`));
|
||||
if (replacementModel) {
|
||||
console.error(` Tool calls will fail. Use "${replacementModel}" instead.`);
|
||||
} else {
|
||||
console.error(' Tool calls will fail. Consider changing the model in config.yaml.');
|
||||
}
|
||||
if (issueUrl) {
|
||||
console.error(` Tracking: ${issueUrl}`);
|
||||
}
|
||||
if (skipLocalAuth) {
|
||||
console.error(' Note: Model may be overridden by remote proxy configuration.');
|
||||
} else {
|
||||
console.error(` Run "ccs ${provider} --config" to change model.`);
|
||||
}
|
||||
console.error('');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user