mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 02:14:32 +00:00
fix(cliproxy): keep proxy-chain base URL authoritative over --settings env
Claude Code applies the --settings `env` block over the process
environment, so the persisted ANTHROPIC_BASE_URL (CLIProxy-direct)
overrode the ephemeral proxy-chain URL CCS injects via env. Claude then
bypassed the tool-sanitization / codex-reasoning proxies, surfacing as
`400 {"detail":"System messages are not allowed"}` for Codex.
Write a runtime settings overlay (in os.tmpdir(), matching
createOpenAICompatLaunchSettings) whose routing env keys reflect the
resolved launch environment, and pass it to `--settings`. The overlay is
cleaned up on Claude exit; subcommands keep the persisted path untouched.
Reuse the canonical routing/model env key lists from shell-executor
instead of duplicating them.
This commit is contained in:
1 parent
36ea9064e7
commit
3847443da3
4 files changed
+285
-3
No files matched your search
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for launch-settings.ts
|
||||||
|
*
|
||||||
|
* Verifies that the runtime settings overlay keeps the resolved proxy-chain
|
||||||
|
* `ANTHROPIC_BASE_URL` (and related routing keys) authoritative when Claude is
|
||||||
|
* launched with `--settings`, instead of the persisted CLIProxy-direct URL.
|
||||||
|
*
|
||||||
|
* The overlay is written to an isolated os.tmpdir() directory, so these tests
|
||||||
|
* never touch the real ~/.ccs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it, beforeEach, afterEach } from 'bun:test';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { buildLaunchSettingsOverlay, prepareLaunchSettings } from '../launch-settings';
|
||||||
|
|
||||||
|
let tmpDir: string;
|
||||||
|
let settingsPath: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-launch-settings-test-'));
|
||||||
|
settingsPath = path.join(tmpDir, 'codex.settings.json');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function writePersisted(settings: unknown): void {
|
||||||
|
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('buildLaunchSettingsOverlay', () => {
|
||||||
|
it('overlays routing env keys from the resolved environment', () => {
|
||||||
|
writePersisted({
|
||||||
|
env: {
|
||||||
|
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex',
|
||||||
|
ANTHROPIC_MODEL: 'gpt-5.5',
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { settings, changed } = buildLaunchSettingsOverlay(settingsPath, {
|
||||||
|
ANTHROPIC_BASE_URL: 'http://127.0.0.1:50118/api/provider/codex',
|
||||||
|
ANTHROPIC_MODEL: 'gpt-5.5-high',
|
||||||
|
} as NodeJS.ProcessEnv);
|
||||||
|
|
||||||
|
expect(changed).toBe(true);
|
||||||
|
const env = settings.env as Record<string, string>;
|
||||||
|
expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:50118/api/provider/codex');
|
||||||
|
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.5-high');
|
||||||
|
// Untouched keys are preserved.
|
||||||
|
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('ccs-internal-managed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves non-env settings (permissions, hooks, etc.)', () => {
|
||||||
|
writePersisted({
|
||||||
|
env: { ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex' },
|
||||||
|
permissions: { allow: ['Bash'] },
|
||||||
|
statusLine: { type: 'command' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { settings } = buildLaunchSettingsOverlay(settingsPath, {
|
||||||
|
ANTHROPIC_BASE_URL: 'http://127.0.0.1:60000/api/provider/codex',
|
||||||
|
} as NodeJS.ProcessEnv);
|
||||||
|
|
||||||
|
expect(settings.permissions).toEqual({ allow: ['Bash'] });
|
||||||
|
expect(settings.statusLine).toEqual({ type: 'command' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports changed=false when resolved env matches persisted values', () => {
|
||||||
|
writePersisted({
|
||||||
|
env: { ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { changed } = buildLaunchSettingsOverlay(settingsPath, {
|
||||||
|
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex',
|
||||||
|
} as NodeJS.ProcessEnv);
|
||||||
|
|
||||||
|
expect(changed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to an env-only overlay when the settings file is missing', () => {
|
||||||
|
const { settings, changed } = buildLaunchSettingsOverlay(settingsPath, {
|
||||||
|
ANTHROPIC_BASE_URL: 'http://127.0.0.1:50118/api/provider/codex',
|
||||||
|
} as NodeJS.ProcessEnv);
|
||||||
|
|
||||||
|
expect(changed).toBe(true);
|
||||||
|
expect((settings.env as Record<string, string>).ANTHROPIC_BASE_URL).toBe(
|
||||||
|
'http://127.0.0.1:50118/api/provider/codex'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('prepareLaunchSettings', () => {
|
||||||
|
it('writes a runtime overlay file and cleans it up when routing changes', () => {
|
||||||
|
writePersisted({
|
||||||
|
env: { ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = prepareLaunchSettings(settingsPath, {
|
||||||
|
ANTHROPIC_BASE_URL: 'http://127.0.0.1:50118/api/provider/codex',
|
||||||
|
} as NodeJS.ProcessEnv);
|
||||||
|
|
||||||
|
expect(result.settingsPath).not.toBe(settingsPath);
|
||||||
|
expect(fs.existsSync(result.settingsPath)).toBe(true);
|
||||||
|
|
||||||
|
const written = JSON.parse(fs.readFileSync(result.settingsPath, 'utf8'));
|
||||||
|
expect(written.env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:50118/api/provider/codex');
|
||||||
|
|
||||||
|
result.cleanup();
|
||||||
|
expect(fs.existsSync(result.settingsPath)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the original path and a no-op cleanup when nothing changes', () => {
|
||||||
|
writePersisted({
|
||||||
|
env: { ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = prepareLaunchSettings(settingsPath, {
|
||||||
|
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex',
|
||||||
|
} as NodeJS.ProcessEnv);
|
||||||
|
|
||||||
|
expect(result.settingsPath).toBe(settingsPath);
|
||||||
|
// Cleanup must not remove the persisted settings file.
|
||||||
|
result.cleanup();
|
||||||
|
expect(fs.existsSync(settingsPath)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -23,6 +23,7 @@ import { CodexReasoningProxy } from '../ai-providers/codex-reasoning-proxy';
|
|||||||
import { ToolSanitizationProxy } from '../proxy/tool-sanitization-proxy';
|
import { ToolSanitizationProxy } from '../proxy/tool-sanitization-proxy';
|
||||||
import { HttpsTunnelProxy } from '../proxy/https-tunnel-proxy';
|
import { HttpsTunnelProxy } from '../proxy/https-tunnel-proxy';
|
||||||
import { setupCleanupHandlers } from './session-bridge';
|
import { setupCleanupHandlers } from './session-bridge';
|
||||||
|
import { prepareLaunchSettings } from './launch-settings';
|
||||||
import { resolveRuntimeQuotaMonitorProviders } from './account-resolution';
|
import { resolveRuntimeQuotaMonitorProviders } from './account-resolution';
|
||||||
import {
|
import {
|
||||||
isClaudeSubcommandInvocation,
|
isClaudeSubcommandInvocation,
|
||||||
@@ -92,7 +93,7 @@ export async function launchClaude(context: ClaudeLaunchContext): Promise<ChildP
|
|||||||
const isWindows = process.platform === 'win32';
|
const isWindows = process.platform === 'win32';
|
||||||
const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli);
|
const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli);
|
||||||
|
|
||||||
const settingsPath = cfg.customSettingsPath
|
const persistedSettingsPath = cfg.customSettingsPath
|
||||||
? cfg.customSettingsPath.replace(/^~/, os.homedir())
|
? cfg.customSettingsPath.replace(/^~/, os.homedir())
|
||||||
: getProviderSettingsPath(provider);
|
: getProviderSettingsPath(provider);
|
||||||
|
|
||||||
@@ -105,6 +106,16 @@ export async function launchClaude(context: ClaudeLaunchContext): Promise<ChildP
|
|||||||
? stripClaudeSubcommandSessionArgs(claudeArgs)
|
? stripClaudeSubcommandSessionArgs(claudeArgs)
|
||||||
: claudeArgs;
|
: claudeArgs;
|
||||||
|
|
||||||
|
// The persisted settings file pins ANTHROPIC_BASE_URL straight at CLIProxy.
|
||||||
|
// Claude applies the settings `env` block over the inherited environment, so
|
||||||
|
// without this overlay it would override the ephemeral proxy-chain URL CCS
|
||||||
|
// injected via env and bypass the tool-sanitization / codex-reasoning proxies
|
||||||
|
// (surfacing as `400 {"detail":"System messages are not allowed"}` for Codex).
|
||||||
|
// Subcommands skip `--settings`, so they keep the persisted path untouched.
|
||||||
|
const { settingsPath, cleanup: cleanupLaunchSettings } = isSubcommand
|
||||||
|
? { settingsPath: persistedSettingsPath, cleanup: () => {} }
|
||||||
|
: prepareLaunchSettings(persistedSettingsPath, env);
|
||||||
|
|
||||||
// Assemble final args: image analysis tools → browser tools → web search tools → settings
|
// Assemble final args: image analysis tools → browser tools → web search tools → settings
|
||||||
const imageAnalysisArgs = imageAnalysisMcpReady
|
const imageAnalysisArgs = imageAnalysisMcpReady
|
||||||
? appendThirdPartyImageAnalysisToolArgs(claudeSessionArgs)
|
? appendThirdPartyImageAnalysisToolArgs(claudeSessionArgs)
|
||||||
@@ -149,6 +160,11 @@ export async function launchClaude(context: ClaudeLaunchContext): Promise<ChildP
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove the runtime settings overlay once Claude has read it and exited.
|
||||||
|
// cleanup is idempotent, so registering on both events is safe.
|
||||||
|
claude.on('exit', cleanupLaunchSettings);
|
||||||
|
claude.on('error', cleanupLaunchSettings);
|
||||||
|
|
||||||
// Start runtime quota monitor (adaptive polling during session)
|
// Start runtime quota monitor (adaptive polling during session)
|
||||||
if (!skipLocalAuth) {
|
if (!skipLocalAuth) {
|
||||||
const { startQuotaMonitor } = await import('../quota/quota-manager');
|
const { startQuotaMonitor } = await import('../quota/quota-manager');
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* Launch settings overlay.
|
||||||
|
*
|
||||||
|
* CCS routes third-party providers through an ephemeral local proxy chain
|
||||||
|
* (tool-sanitization + codex-reasoning). The resolved `ANTHROPIC_BASE_URL`
|
||||||
|
* (and model/auth overrides) for that chain only exists in the spawned Claude
|
||||||
|
* process environment, because the proxy ports are random per launch and cannot
|
||||||
|
* be persisted to the on-disk provider settings file.
|
||||||
|
*
|
||||||
|
* Claude CLI is launched with `--settings <providerSettings.json>`. Recent
|
||||||
|
* Claude Code releases apply the settings file's `env` block on top of the
|
||||||
|
* inherited process environment, so the persisted `ANTHROPIC_BASE_URL` (which
|
||||||
|
* points straight at CLIProxy) overrides the proxy-chain URL CCS injected via
|
||||||
|
* env. Claude then bypasses the proxy chain entirely — breaking tool-name
|
||||||
|
* sanitization and Codex system-message folding (the latter surfaces as
|
||||||
|
* `400 {"detail":"System messages are not allowed"}`).
|
||||||
|
*
|
||||||
|
* To keep the proxy chain authoritative regardless of Claude's env precedence,
|
||||||
|
* we write a runtime copy of the settings file whose `env` routing keys are
|
||||||
|
* overlaid with the resolved launch environment, and pass that copy to
|
||||||
|
* `--settings`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import { ANTHROPIC_MODEL_ENV_KEYS, ANTHROPIC_ROUTING_ENV_KEYS } from '../../utils/shell-executor';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Environment keys that control provider routing/model selection and are read
|
||||||
|
* by Claude from the settings `env` block. These must reflect the resolved
|
||||||
|
* proxy-chain environment, not the persisted on-disk values. Reuses the
|
||||||
|
* canonical routing/model key lists from shell-executor.
|
||||||
|
*/
|
||||||
|
const ROUTING_ENV_KEYS = [...ANTHROPIC_ROUTING_ENV_KEYS, ...ANTHROPIC_MODEL_ENV_KEYS];
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a settings object based on the persisted settings file, with routing
|
||||||
|
* `env` keys overlaid from the resolved launch environment.
|
||||||
|
*
|
||||||
|
* @returns The merged settings and whether any routing key actually changed.
|
||||||
|
*/
|
||||||
|
export function buildLaunchSettingsOverlay(
|
||||||
|
settingsPath: string,
|
||||||
|
env: NodeJS.ProcessEnv
|
||||||
|
): { settings: Record<string, unknown>; changed: boolean } {
|
||||||
|
let base: Record<string, unknown> = {};
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(settingsPath)) {
|
||||||
|
const parsed: unknown = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||||
|
if (isRecord(parsed)) {
|
||||||
|
base = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Corrupt/unreadable settings file: fall back to an env-only overlay.
|
||||||
|
base = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedEnv: Record<string, unknown> = isRecord(base.env) ? { ...base.env } : {};
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
for (const key of ROUTING_ENV_KEYS) {
|
||||||
|
const resolved = env[key];
|
||||||
|
if (typeof resolved === 'string' && mergedEnv[key] !== resolved) {
|
||||||
|
mergedEnv[key] = resolved;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { settings: { ...base, env: mergedEnv }, changed };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepare the settings file path to hand to `claude --settings`.
|
||||||
|
*
|
||||||
|
* When the resolved launch environment changes any routing key relative to the
|
||||||
|
* persisted settings file (i.e. a proxy chain is active), a runtime overlay
|
||||||
|
* file is written and its path returned together with a cleanup callback that
|
||||||
|
* removes it. Otherwise the original `settingsPath` is returned unchanged and
|
||||||
|
* cleanup is a no-op.
|
||||||
|
*/
|
||||||
|
export function prepareLaunchSettings(
|
||||||
|
settingsPath: string,
|
||||||
|
env: NodeJS.ProcessEnv
|
||||||
|
): { settingsPath: string; cleanup: () => void } {
|
||||||
|
const noop = { settingsPath, cleanup: () => {} };
|
||||||
|
|
||||||
|
let overlay: { settings: Record<string, unknown>; changed: boolean };
|
||||||
|
try {
|
||||||
|
overlay = buildLaunchSettingsOverlay(settingsPath, env);
|
||||||
|
} catch {
|
||||||
|
return noop;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!overlay.changed) {
|
||||||
|
return noop;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Write to a private temp dir (matches createOpenAICompatLaunchSettings) so
|
||||||
|
// the overlay never lands in the user's ~/.ccs and is trivially isolated.
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-launch-settings-'));
|
||||||
|
fs.chmodSync(tempDir, 0o700);
|
||||||
|
|
||||||
|
const runtimePath = path.join(tempDir, path.basename(settingsPath) || 'settings.json');
|
||||||
|
fs.writeFileSync(runtimePath, JSON.stringify(overlay.settings, null, 2) + '\n', {
|
||||||
|
encoding: 'utf8',
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
|
||||||
|
let cleanedUp = false;
|
||||||
|
const cleanup = (): void => {
|
||||||
|
if (cleanedUp) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cleanedUp = true;
|
||||||
|
try {
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// best-effort cleanup
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { settingsPath: runtimePath, cleanup };
|
||||||
|
} catch {
|
||||||
|
// If we cannot write the overlay, fall back to the persisted file. Routing
|
||||||
|
// may bypass the proxy chain, but the session still launches.
|
||||||
|
return noop;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,13 +34,13 @@ export function stripAnthropicEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ANTHROPIC_ROUTING_ENV_KEYS = [
|
export const ANTHROPIC_ROUTING_ENV_KEYS = [
|
||||||
'ANTHROPIC_BASE_URL',
|
'ANTHROPIC_BASE_URL',
|
||||||
'ANTHROPIC_AUTH_TOKEN',
|
'ANTHROPIC_AUTH_TOKEN',
|
||||||
'ANTHROPIC_API_KEY',
|
'ANTHROPIC_API_KEY',
|
||||||
];
|
];
|
||||||
const ANTHROPIC_ROUTING_ENV_KEY_SET = new Set(ANTHROPIC_ROUTING_ENV_KEYS);
|
const ANTHROPIC_ROUTING_ENV_KEY_SET = new Set(ANTHROPIC_ROUTING_ENV_KEYS);
|
||||||
const ANTHROPIC_MODEL_ENV_KEYS = [
|
export const ANTHROPIC_MODEL_ENV_KEYS = [
|
||||||
'ANTHROPIC_MODEL',
|
'ANTHROPIC_MODEL',
|
||||||
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
||||||
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
||||||
|
|||||||
Reference in new issue
Block a user