diff --git a/src/ccs.ts b/src/ccs.ts index 23389316..22eee96f 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -82,6 +82,7 @@ import { tryHandleRootCommand } from './commands/root-command-router'; // Import extracted utility functions import { execClaude, stripAnthropicRoutingEnv, stripBrowserEnv } from './utils/shell-executor'; import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from './utils/glmt-deprecation'; +import { createOpenAICompatLaunchSettings } from './utils/openai-compat-launch-settings'; import { maybeWarnAboutResumeLaneMismatch } from './auth/resume-lane-warning'; import { createLogger } from './services/logging'; import { buildCodexBrowserMcpOverrides } from './utils/browser-codex-overrides'; @@ -1459,10 +1460,11 @@ async function main(): Promise { ), }; delete proxyEnv.ANTHROPIC_API_KEY; + const launchSettings = createOpenAICompatLaunchSettings(expandedSettingsPath, settings); const launchArgs = [ '--settings', - expandedSettingsPath, + launchSettings.settingsPath, ...appendThirdPartyWebSearchToolArgs(browserArgs), ]; const traceEnv = createWebSearchTraceContext({ @@ -1473,7 +1475,7 @@ async function main(): Promise { settingsPath: expandedSettingsPath, }); - execClaude(claudeCli, launchArgs, { ...proxyEnv, ...traceEnv }); + execClaude(claudeCli, launchArgs, { ...proxyEnv, ...traceEnv }, launchSettings.cleanup); return; } const launchArgs = [ diff --git a/src/delegation/headless-executor.ts b/src/delegation/headless-executor.ts index 76e4bcdd..26e2760c 100644 --- a/src/delegation/headless-executor.ts +++ b/src/delegation/headless-executor.ts @@ -23,6 +23,7 @@ import { stripAnthropicRoutingEnv, stripClaudeCodeEnv, } from '../utils/shell-executor'; +import { createOpenAICompatLaunchSettings } from '../utils/openai-compat-launch-settings'; import { resolveProfileContinuityInheritance } from '../auth/profile-continuity-inheritance'; import { appendThirdPartyImageAnalysisToolArgs, @@ -255,8 +256,12 @@ export class HeadlessExecutor { // Smart slash command detection and preservation const processedPrompt = this._processSlashCommand(enhancedPrompt); + const launchSettings = openAICompatProfile + ? createOpenAICompatLaunchSettings(settingsPath, settings) + : { settingsPath, cleanup: () => {} }; + // Prepare arguments - const args: string[] = ['-p', processedPrompt, '--settings', settingsPath]; + const args: string[] = ['-p', processedPrompt, '--settings', launchSettings.settingsPath]; // Always use stream-json for real-time progress visibility args.push('--output-format', 'stream-json', '--verbose'); @@ -368,6 +373,7 @@ export class HeadlessExecutor { imageAnalysisEnv, runtimeEnvVars, traceEnv, + launchCleanup: launchSettings.cleanup, }); } @@ -388,6 +394,7 @@ export class HeadlessExecutor { imageAnalysisEnv?: Record; runtimeEnvVars?: NodeJS.ProcessEnv; traceEnv?: Record; + launchCleanup?: () => void; } ): Promise { const { @@ -401,6 +408,7 @@ export class HeadlessExecutor { imageAnalysisEnv = {}, runtimeEnvVars = {}, traceEnv = {}, + launchCleanup = () => {}, } = ctx; return new Promise((resolve, reject) => { @@ -438,6 +446,14 @@ export class HeadlessExecutor { let progressInterval: NodeJS.Timeout | undefined; const messages: StreamMessage[] = []; let timedOut = false; + let cleanedUp = false; + const cleanupLaunchArtifacts = () => { + if (cleanedUp) { + return; + } + cleanedUp = true; + launchCleanup(); + }; // Setup signal handlers for cleanup const cleanupHandler = () => { @@ -496,6 +512,7 @@ export class HeadlessExecutor { // Handle completion proc.on('close', (exitCode: number | null) => { + cleanupLaunchArtifacts(); const duration = Date.now() - startTime; if (progressInterval) { @@ -588,6 +605,7 @@ export class HeadlessExecutor { // Handle errors proc.on('error', (error: Error) => { + cleanupLaunchArtifacts(); if (progressInterval) clearInterval(progressInterval); reject(new Error(`Failed to execute Claude CLI: ${error.message}`)); }); diff --git a/src/proxy/transformers/request-transformer.ts b/src/proxy/transformers/request-transformer.ts index 58e7969e..b5877bd6 100644 --- a/src/proxy/transformers/request-transformer.ts +++ b/src/proxy/transformers/request-transformer.ts @@ -362,7 +362,7 @@ function transformToolChoice( function mapThinkingToReasoning( thinking: AnthropicThinking | undefined, outputConfig: AnthropicOutputConfig | undefined -): Pick { +): Pick { if (!thinking || thinking.type === 'disabled') { return {}; } @@ -371,10 +371,6 @@ function mapThinkingToReasoning( const effort = toOpenAIEffort(resolveOutputConfigEffort(outputConfig) ?? 'high'); return { reasoning_effort: effort, - reasoning: { - enabled: true, - effort, - }, }; } @@ -389,10 +385,6 @@ function mapThinkingToReasoning( return { reasoning_effort: effort, - reasoning: { - enabled: true, - effort, - }, }; } diff --git a/src/utils/openai-compat-launch-settings.ts b/src/utils/openai-compat-launch-settings.ts new file mode 100644 index 00000000..bfb0887d --- /dev/null +++ b/src/utils/openai-compat-launch-settings.ts @@ -0,0 +1,52 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import type { Settings } from '../types/config'; +import { stripAnthropicRoutingEnv } from './shell-executor'; + +export interface OpenAICompatLaunchSettings { + settingsPath: string; + cleanup: () => void; +} + +export function createOpenAICompatLaunchSettings( + settingsPath: string, + settings: Settings +): OpenAICompatLaunchSettings { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-openai-compat-settings-')); + fs.chmodSync(tempDir, 0o700); + + const launchSettings = JSON.parse(JSON.stringify(settings)) as Settings; + const sanitizedEnv = Object.fromEntries( + Object.entries(stripAnthropicRoutingEnv({ ...(launchSettings.env ?? {}) })).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string' + ) + ); + + if (Object.keys(sanitizedEnv).length > 0) { + launchSettings.env = sanitizedEnv; + } else { + delete launchSettings.env; + } + + const launchSettingsPath = path.join(tempDir, path.basename(settingsPath)); + fs.writeFileSync(launchSettingsPath, JSON.stringify(launchSettings, null, 2) + '\n', { + encoding: 'utf8', + mode: 0o600, + }); + + let cleanedUp = false; + const cleanup = (): void => { + if (cleanedUp) { + return; + } + cleanedUp = true; + fs.rmSync(tempDir, { recursive: true, force: true }); + }; + + return { + settingsPath: launchSettingsPath, + cleanup, + }; +} diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index a6a5fd63..35a7505d 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -208,7 +208,8 @@ export function getWindowsEscapedCommandShell(): SpawnOptions['shell'] { export function execClaude( claudeCli: string, args: string[], - envVars: NodeJS.ProcessEnv | null = null + envVars: NodeJS.ProcessEnv | null = null, + onExitCleanup?: () => void ): void { const isWindows = process.platform === 'win32'; const isPowerShellScript = isWindows && /\.ps1$/i.test(claudeCli); @@ -286,6 +287,17 @@ export function execClaude( }); } + let cleanedUp = false; + const runExitCleanup = (): void => { + if (cleanedUp) { + return; + } + cleanedUp = true; + onExitCleanup?.(); + }; + child.once('exit', runExitCleanup); + child.once('error', runExitCleanup); + wireChildProcessSignals(child, async (err: NodeJS.ErrnoException) => { if (err.code === 'EACCES') { console.error(`[X] Claude CLI is not executable: ${claudeCli}`); diff --git a/tests/integration/proxy/request-routing.test.ts b/tests/integration/proxy/request-routing.test.ts index 080405b2..7e44ae13 100644 --- a/tests/integration/proxy/request-routing.test.ts +++ b/tests/integration/proxy/request-routing.test.ts @@ -299,7 +299,58 @@ describe('openai proxy request routing', () => { expect(bodies[0]?.body).toMatchObject({ model: 'deepseek-reasoner', reasoning_effort: 'high', - reasoning: { enabled: true, effort: 'high' }, }); + expect((bodies[0]?.body as { reasoning?: unknown } | undefined)?.reasoning).toBeUndefined(); + }); + + it('forwards adaptive thinking to openai-profile upstreams via reasoning_effort only', async () => { + const hits: string[] = []; + const bodies: Array<{ label: string; body: unknown }> = []; + const upstreamPort = await startMockUpstream('openai', hits, bodies); + + const settingsPath = writeSettings('openai', { + ANTHROPIC_BASE_URL: 'https://api.openai.com/v1', + ANTHROPIC_AUTH_TOKEN: 'openai_token', + ANTHROPIC_MODEL: 'gpt-4.1', + }); + + fs.writeFileSync( + path.join(tempDir, '.ccs', 'config.json'), + JSON.stringify({ profiles: { openai: settingsPath } }, null, 2), + 'utf8' + ); + + const profile: OpenAICompatProfileConfig = { + profileName: 'openai', + settingsPath, + baseUrl: `http://127.0.0.1:${upstreamPort}`, + apiKey: 'openai_token', + provider: 'openai', + model: 'gpt-4.1', + }; + proxyServer = startOpenAICompatProxyServer({ + profile, + port: 0, + authToken: 'test-proxy-token', + }); + proxyPort = await waitForServerListening(proxyServer); + + const response = await requestProxy({ + model: 'gpt-4.1', + thinking: { type: 'adaptive' }, + output_config: { effort: 'max' }, + messages: [{ role: 'user', content: 'think adaptively' }], + }); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + content: [{ type: 'text', text: 'Reply from openai' }], + }); + expect(hits).toEqual(['openai']); + expect(bodies[0]?.body).toMatchObject({ + model: 'gpt-4.1', + reasoning_effort: 'high', + }); + expect((bodies[0]?.body as { reasoning?: unknown } | undefined)?.reasoning).toBeUndefined(); }); }); diff --git a/tests/unit/proxy/transformers/request-transformer-regressions.test.ts b/tests/unit/proxy/transformers/request-transformer-regressions.test.ts index 4bb3192f..9e4bc5a8 100644 --- a/tests/unit/proxy/transformers/request-transformer-regressions.test.ts +++ b/tests/unit/proxy/transformers/request-transformer-regressions.test.ts @@ -27,7 +27,7 @@ describe('ProxyRequestTransformer regressions', () => { }); expect(result.reasoning_effort).toBe('high'); - expect(result.reasoning).toEqual({ enabled: true, effort: 'high' }); + expect(result.reasoning).toBeUndefined(); }); it('explicitly normalizes anthropic xhigh adaptive effort for OpenAI-compatible upstreams', () => { @@ -38,7 +38,7 @@ describe('ProxyRequestTransformer regressions', () => { }); expect(result.reasoning_effort).toBe('high'); - expect(result.reasoning).toEqual({ enabled: true, effort: 'high' }); + expect(result.reasoning).toBeUndefined(); }); it('rejects unsupported thinking types instead of silently dropping them', () => { diff --git a/tests/unit/proxy/transformers/request-transformer.test.ts b/tests/unit/proxy/transformers/request-transformer.test.ts index 25687c20..543361a1 100644 --- a/tests/unit/proxy/transformers/request-transformer.test.ts +++ b/tests/unit/proxy/transformers/request-transformer.test.ts @@ -28,7 +28,7 @@ describe('ProxyRequestTransformer', () => { expect(result.stream).toBe(true); expect(result.reasoning_effort).toBe('high'); - expect(result.reasoning).toEqual({ enabled: true, effort: 'high' }); + expect(result.reasoning).toBeUndefined(); expect(result.max_tokens).toBe(1024); expect(result.temperature).toBe(0.2); expect(result.top_p).toBe(0.9); diff --git a/tests/unit/targets/settings-profile-browser-launch.test.ts b/tests/unit/targets/settings-profile-browser-launch.test.ts index e89e8855..73d04f8c 100644 --- a/tests/unit/targets/settings-profile-browser-launch.test.ts +++ b/tests/unit/targets/settings-profile-browser-launch.test.ts @@ -5,6 +5,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { mutateUnifiedConfig } from '../../../src/config/unified-config-loader'; +import { stopOpenAICompatProxy } from '../../../src/proxy/proxy-daemon'; const BROWSER_PROMPT_SNIPPET = 'prefer the CCS MCP Browser tool'; setDefaultTimeout(30000); @@ -30,6 +31,14 @@ function runCcs(args: string[], env: NodeJS.ProcessEnv): RunResult { }; } +function readLaunchedArgs(argsLogPath: string): string[] { + return fs + .readFileSync(argsLogPath, 'utf8') + .split('\n') + .map((arg) => arg.trim()) + .filter((arg) => arg.length > 0); +} + function reserveClosedPort(): number { const server = Bun.serve({ port: 0, @@ -83,6 +92,8 @@ describe('settings profile browser launch', () => { let fakeClaudePath = ''; let claudeArgsLogPath = ''; let claudeEnvLogPath = ''; + let claudeSettingsPathLogPath = ''; + let claudeSettingsSnapshotPath = ''; let browserProfileDir = ''; let devtoolsServer: ChildProcess | undefined; let baseEnv: NodeJS.ProcessEnv; @@ -98,6 +109,8 @@ describe('settings profile browser launch', () => { fakeClaudePath = path.join(tmpHome, 'fake-claude.sh'); claudeArgsLogPath = path.join(tmpHome, 'claude-args.txt'); claudeEnvLogPath = path.join(tmpHome, 'claude-env.txt'); + claudeSettingsPathLogPath = path.join(tmpHome, 'claude-settings-path.txt'); + claudeSettingsSnapshotPath = path.join(tmpHome, 'claude-settings-snapshot.json'); browserProfileDir = path.join(tmpHome, 'chrome-user-data'); fs.mkdirSync(ccsDir, { recursive: true }); @@ -129,6 +142,19 @@ describe('settings profile browser launch', () => { fakeClaudePath, `#!/bin/sh printf "%s\n" "$@" > "${claudeArgsLogPath}" +settings_path="" +prev="" +for arg in "$@"; do + if [ "$prev" = "--settings" ]; then + settings_path="$arg" + break + fi + prev="$arg" +done +printf "%s" "$settings_path" > "${claudeSettingsPathLogPath}" +if [ -n "$settings_path" ] && [ -f "$settings_path" ]; then + cat "$settings_path" > "${claudeSettingsSnapshotPath}" +fi { printf "userDataDir=%s\n" "$CCS_BROWSER_USER_DATA_DIR" printf "legacyProfileDir=%s\n" "$CCS_BROWSER_PROFILE_DIR" @@ -167,7 +193,7 @@ exit 0 }; }); - afterEach(() => { + afterEach(async () => { if (devtoolsServer) { devtoolsServer.kill(); devtoolsServer = undefined; @@ -176,6 +202,7 @@ exit 0 return; } + await stopOpenAICompatProxy(); fs.rmSync(tmpHome, { recursive: true, force: true }); }); @@ -257,6 +284,76 @@ exit 0 } }); + it('passes a sanitized settings copy for local OpenAI-compatible proxy launches', () => { + if (process.platform === 'win32') return; + + fs.writeFileSync( + settingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'https://api.openai.com/v1', + ANTHROPIC_AUTH_TOKEN: 'profile-token', + ANTHROPIC_API_KEY: 'profile-api-key', + ANTHROPIC_MODEL: 'gpt-5.4', + CLAUDE_CODE_MAX_OUTPUT_TOKENS: '12345', + }, + hooks: { + PreToolUse: [ + { + matcher: 'Read', + hooks: [{ type: 'command', command: 'echo keep-profile-settings' }], + }, + ], + }, + }, + null, + 2 + ) + '\n' + ); + + const result = runCcs(['glm', 'smoke'], baseEnv); + + expect(result.status).toBe(0); + + const launchedArgs = readLaunchedArgs(claudeArgsLogPath); + const settingsIndex = launchedArgs.indexOf('--settings'); + expect(settingsIndex).toBeGreaterThanOrEqual(0); + + const launchSettingsPath = launchedArgs[settingsIndex + 1]; + expect(launchSettingsPath).toBeDefined(); + expect(launchSettingsPath).not.toBe(settingsPath); + + const persistedLaunchSettings = JSON.parse( + fs.readFileSync(claudeSettingsSnapshotPath, 'utf8') + ) as { + env?: Record; + hooks?: { + PreToolUse?: Array<{ + matcher?: string; + hooks?: Array<{ command?: string }>; + }>; + }; + }; + + expect(persistedLaunchSettings.env?.ANTHROPIC_BASE_URL).toBeUndefined(); + expect(persistedLaunchSettings.env?.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); + expect(persistedLaunchSettings.env?.ANTHROPIC_API_KEY).toBeUndefined(); + expect(persistedLaunchSettings.env?.ANTHROPIC_MODEL).toBe('gpt-5.4'); + expect( + persistedLaunchSettings.hooks?.PreToolUse?.[0]?.hooks?.[0]?.command + ).toBe('echo keep-profile-settings'); + + const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8'); + expect(launchedEnv).toContain('stripAnthropic='); + expect(launchedEnv).toContain('anthropicBaseUrl=http://127.0.0.1:'); + expect(launchedEnv).not.toContain('anthropicBaseUrl=https://api.openai.com/v1'); + expect(launchedEnv).toContain('anthropicModel=gpt-5.4'); + expect(launchedEnv).toContain('maxOutputTokens=12345'); + expect(fs.readFileSync(claudeSettingsPathLogPath, 'utf8')).toBe(launchSettingsPath); + expect(fs.existsSync(launchSettingsPath as string)).toBe(false); + }); + it('does not auto-enable browser reuse for settings-profile launches from env overrides alone', async () => { if (process.platform === 'win32') return; diff --git a/tests/unit/utils/claudecode-env-stripping.test.ts b/tests/unit/utils/claudecode-env-stripping.test.ts index bb64e71d..165314b2 100644 --- a/tests/unit/utils/claudecode-env-stripping.test.ts +++ b/tests/unit/utils/claudecode-env-stripping.test.ts @@ -31,6 +31,7 @@ const STEERING_PROMPT_SNIPPET = 'prefer the CCS MCP tool WebSearch instead of Bash/curl/http fetches'; const spawnCalls: SpawnCall[] = []; const spawnSyncCalls: SpawnSyncCall[] = []; +const launchSettingsSnapshots: Array<{ path: string; content: string }> = []; const originalPlatform = process.platform; let baselineSigintListeners: Array<(...args: unknown[]) => void> = []; let baselineSigtermListeners: Array<(...args: unknown[]) => void> = []; @@ -99,6 +100,16 @@ function registerChildProcessMock(): void { } spawnCalls.push({ command, args, options }); + const settingsIndex = args.indexOf('--settings'); + if (settingsIndex >= 0) { + const settingsPath = args[settingsIndex + 1]; + if (settingsPath && fs.existsSync(settingsPath)) { + launchSettingsSnapshots.push({ + path: settingsPath, + content: fs.readFileSync(settingsPath, 'utf8'), + }); + } + } const child = createMockChild(); setTimeout(() => child.emit('close', 0), 0); @@ -193,6 +204,7 @@ describe('CLAUDECODE environment stripping', () => { beforeEach(() => { spawnCalls.length = 0; spawnSyncCalls.length = 0; + launchSettingsSnapshots.length = 0; process.env.CCS_QUIET = '1'; // Save original env values for restoration in afterEach @@ -651,6 +663,14 @@ describe('CLAUDECODE environment stripping', () => { ANTHROPIC_MODEL: 'gpt-5.4', CLAUDE_CODE_MAX_OUTPUT_TOKENS: '12345', }, + hooks: { + PreToolUse: [ + { + matcher: 'Read', + hooks: [{ type: 'command', command: 'echo headless-bridge-hook' }], + }, + ], + }, }, null, 2 @@ -679,6 +699,37 @@ describe('CLAUDECODE environment stripping', () => { expect(env.ANTHROPIC_AUTH_TOKEN).not.toBe('parent-routing-token'); expect(env.ANTHROPIC_MODEL).toBe('gpt-5.4'); expect(env.CLAUDE_CODE_MAX_OUTPUT_TOKENS).toBe('12345'); + + const args = spawnCalls[0].args; + const settingsIndex = args.indexOf('--settings'); + expect(settingsIndex).toBeGreaterThanOrEqual(0); + + const launchSettingsPath = args[settingsIndex + 1]; + expect(launchSettingsPath).toBeDefined(); + expect(launchSettingsPath).not.toBe(path.join(ccsDir, 'bridge.settings.json')); + const launchSettingsSnapshot = launchSettingsSnapshots.find( + (snapshot) => snapshot.path === launchSettingsPath + ); + expect(launchSettingsSnapshot).toBeDefined(); + + const persistedLaunchSettings = JSON.parse(launchSettingsSnapshot?.content || '{}') as { + env?: Record; + hooks?: { + PreToolUse?: Array<{ + matcher?: string; + hooks?: Array<{ command?: string }>; + }>; + }; + }; + + expect(persistedLaunchSettings.env?.ANTHROPIC_BASE_URL).toBeUndefined(); + expect(persistedLaunchSettings.env?.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); + expect(persistedLaunchSettings.env?.ANTHROPIC_API_KEY).toBeUndefined(); + expect(persistedLaunchSettings.env?.ANTHROPIC_MODEL).toBe('gpt-5.4'); + expect( + persistedLaunchSettings.hooks?.PreToolUse?.[0]?.hooks?.[0]?.command + ).toBe('echo headless-bridge-hook'); + expect(fs.existsSync(launchSettingsPath)).toBe(false); }); it('headless executor prepares image-analysis MCP and suppresses the legacy hook on healthy launches', async () => {