mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 12:21:20 +00:00
fix(runtime): isolate nested Anthropic env across delegation paths
This commit is contained in:
@@ -16,8 +16,13 @@ import { type ExecutionOptions, type ExecutionResult, type StreamMessage } from
|
|||||||
import { StreamBuffer, formatToolVerbose } from './executor/stream-parser';
|
import { StreamBuffer, formatToolVerbose } from './executor/stream-parser';
|
||||||
import { buildExecutionResult } from './executor/result-aggregator';
|
import { buildExecutionResult } from './executor/result-aggregator';
|
||||||
import { getCcsDir, getModelDisplayName, loadSettings } from '../utils/config-manager';
|
import { getCcsDir, getModelDisplayName, loadSettings } from '../utils/config-manager';
|
||||||
|
import { getGlobalEnvConfig } from '../config/unified-config-loader';
|
||||||
import { getProfileLookupCandidates } from '../utils/profile-compat';
|
import { getProfileLookupCandidates } from '../utils/profile-compat';
|
||||||
import { getClaudeLaunchEnvOverrides, stripClaudeCodeEnv } from '../utils/shell-executor';
|
import {
|
||||||
|
getClaudeLaunchEnvOverrides,
|
||||||
|
stripAnthropicRoutingEnv,
|
||||||
|
stripClaudeCodeEnv,
|
||||||
|
} from '../utils/shell-executor';
|
||||||
import { resolveProfileContinuityInheritance } from '../auth/profile-continuity-inheritance';
|
import { resolveProfileContinuityInheritance } from '../auth/profile-continuity-inheritance';
|
||||||
import {
|
import {
|
||||||
appendThirdPartyImageAnalysisToolArgs,
|
appendThirdPartyImageAnalysisToolArgs,
|
||||||
@@ -38,6 +43,11 @@ import {
|
|||||||
import { resolveCliproxyBridgeMetadata } from '../api/services';
|
import { resolveCliproxyBridgeMetadata } from '../api/services';
|
||||||
import { ensureCliproxyService } from '../cliproxy';
|
import { ensureCliproxyService } from '../cliproxy';
|
||||||
import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager';
|
import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager';
|
||||||
|
import {
|
||||||
|
buildOpenAICompatProxyEnv,
|
||||||
|
resolveOpenAICompatProfileConfig,
|
||||||
|
startOpenAICompatProxy,
|
||||||
|
} from '../proxy';
|
||||||
import {
|
import {
|
||||||
appendThirdPartyWebSearchToolArgs,
|
appendThirdPartyWebSearchToolArgs,
|
||||||
appendWebSearchTrace,
|
appendWebSearchTrace,
|
||||||
@@ -129,6 +139,14 @@ export class HeadlessExecutor {
|
|||||||
syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir);
|
syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir);
|
||||||
|
|
||||||
const settings = loadSettings(settingsPath);
|
const settings = loadSettings(settingsPath);
|
||||||
|
const globalEnvConfig = getGlobalEnvConfig();
|
||||||
|
const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {};
|
||||||
|
const settingsEnv = settings.env || {};
|
||||||
|
const openAICompatProfile = resolveOpenAICompatProfileConfig(
|
||||||
|
profile,
|
||||||
|
settingsPath,
|
||||||
|
settingsEnv
|
||||||
|
);
|
||||||
const cliproxyBridge = resolveCliproxyBridgeMetadata(settings);
|
const cliproxyBridge = resolveCliproxyBridgeMetadata(settings);
|
||||||
let imageAnalysisFallbackHookReady: boolean | undefined;
|
let imageAnalysisFallbackHookReady: boolean | undefined;
|
||||||
if (imageAnalysisMcpReady) {
|
if (imageAnalysisMcpReady) {
|
||||||
@@ -207,6 +225,33 @@ export class HeadlessExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let runtimeEnvVars: NodeJS.ProcessEnv = {
|
||||||
|
...stripAnthropicRoutingEnv({ ...globalEnv, ...settingsEnv }),
|
||||||
|
...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}),
|
||||||
|
CCS_PROFILE_TYPE: 'settings',
|
||||||
|
CCS_STRIP_INHERITED_ANTHROPIC_ENV: '1',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (openAICompatProfile) {
|
||||||
|
const proxyStart = await startOpenAICompatProxy(openAICompatProfile, {
|
||||||
|
insecure: openAICompatProfile.insecure,
|
||||||
|
});
|
||||||
|
if (!proxyStart.success) {
|
||||||
|
throw new Error(proxyStart.error || 'Failed to start local OpenAI-compatible proxy');
|
||||||
|
}
|
||||||
|
|
||||||
|
runtimeEnvVars = {
|
||||||
|
...runtimeEnvVars,
|
||||||
|
...buildOpenAICompatProxyEnv(
|
||||||
|
openAICompatProfile,
|
||||||
|
proxyStart.port,
|
||||||
|
proxyStart.authToken || '',
|
||||||
|
inheritedClaudeConfigDir
|
||||||
|
),
|
||||||
|
};
|
||||||
|
delete runtimeEnvVars.ANTHROPIC_API_KEY;
|
||||||
|
}
|
||||||
|
|
||||||
// Smart slash command detection and preservation
|
// Smart slash command detection and preservation
|
||||||
const processedPrompt = this._processSlashCommand(enhancedPrompt);
|
const processedPrompt = this._processSlashCommand(enhancedPrompt);
|
||||||
|
|
||||||
@@ -321,6 +366,7 @@ export class HeadlessExecutor {
|
|||||||
sessionMgr,
|
sessionMgr,
|
||||||
claudeConfigDir: inheritedClaudeConfigDir,
|
claudeConfigDir: inheritedClaudeConfigDir,
|
||||||
imageAnalysisEnv,
|
imageAnalysisEnv,
|
||||||
|
runtimeEnvVars,
|
||||||
traceEnv,
|
traceEnv,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -340,6 +386,7 @@ export class HeadlessExecutor {
|
|||||||
sessionMgr: SessionManager;
|
sessionMgr: SessionManager;
|
||||||
claudeConfigDir?: string;
|
claudeConfigDir?: string;
|
||||||
imageAnalysisEnv?: Record<string, string>;
|
imageAnalysisEnv?: Record<string, string>;
|
||||||
|
runtimeEnvVars?: NodeJS.ProcessEnv;
|
||||||
traceEnv?: Record<string, string>;
|
traceEnv?: Record<string, string>;
|
||||||
}
|
}
|
||||||
): Promise<ExecutionResult> {
|
): Promise<ExecutionResult> {
|
||||||
@@ -352,6 +399,7 @@ export class HeadlessExecutor {
|
|||||||
sessionMgr,
|
sessionMgr,
|
||||||
claudeConfigDir,
|
claudeConfigDir,
|
||||||
imageAnalysisEnv = {},
|
imageAnalysisEnv = {},
|
||||||
|
runtimeEnvVars = {},
|
||||||
traceEnv = {},
|
traceEnv = {},
|
||||||
} = ctx;
|
} = ctx;
|
||||||
|
|
||||||
@@ -368,9 +416,10 @@ export class HeadlessExecutor {
|
|||||||
// Strip Claude Code nested session guard env var to allow CCS delegation
|
// Strip Claude Code nested session guard env var to allow CCS delegation
|
||||||
// (Claude Code v2.1.39+ sets CLAUDECODE to detect nested sessions)
|
// (Claude Code v2.1.39+ sets CLAUDECODE to detect nested sessions)
|
||||||
const cleanEnv = stripClaudeCodeEnv({
|
const cleanEnv = stripClaudeCodeEnv({
|
||||||
...process.env,
|
...stripAnthropicRoutingEnv(process.env),
|
||||||
...getClaudeLaunchEnvOverrides(),
|
...getClaudeLaunchEnvOverrides(),
|
||||||
...getWebSearchHookEnv(),
|
...getWebSearchHookEnv(),
|
||||||
|
...runtimeEnvVars,
|
||||||
...imageAnalysisEnv,
|
...imageAnalysisEnv,
|
||||||
...traceEnv,
|
...traceEnv,
|
||||||
...(claudeConfigDir ? { CLAUDE_CONFIG_DIR: claudeConfigDir } : {}),
|
...(claudeConfigDir ? { CLAUDE_CONFIG_DIR: claudeConfigDir } : {}),
|
||||||
|
|||||||
+49
-17
@@ -26,11 +26,28 @@ export function stripAnthropicEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ANTHROPIC_ROUTING_ENV_KEYS = new Set([
|
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_MODEL_ENV_KEYS = [
|
||||||
|
'ANTHROPIC_MODEL',
|
||||||
|
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
||||||
|
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
||||||
|
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||||
|
'ANTHROPIC_SMALL_FAST_MODEL',
|
||||||
|
];
|
||||||
|
const TMUX_SYNC_ENV_KEYS = [
|
||||||
|
'CLAUDE_CONFIG_DIR',
|
||||||
|
'CCS_PROFILE_TYPE',
|
||||||
|
'CCS_WEBSEARCH_SKIP',
|
||||||
|
'CCS_STRIP_INHERITED_ANTHROPIC_ENV',
|
||||||
|
'CLAUDE_CODE_MAX_OUTPUT_TOKENS',
|
||||||
|
...ANTHROPIC_MODEL_ENV_KEYS,
|
||||||
|
...ANTHROPIC_ROUTING_ENV_KEYS,
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Strip inherited Anthropic routing/auth env while preserving model intent.
|
* Strip inherited Anthropic routing/auth env while preserving model intent.
|
||||||
@@ -41,13 +58,39 @@ const ANTHROPIC_ROUTING_ENV_KEYS = new Set([
|
|||||||
export function stripAnthropicRoutingEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
export function stripAnthropicRoutingEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||||
const result: NodeJS.ProcessEnv = {};
|
const result: NodeJS.ProcessEnv = {};
|
||||||
for (const key of Object.keys(env)) {
|
for (const key of Object.keys(env)) {
|
||||||
if (!ANTHROPIC_ROUTING_ENV_KEYS.has(key)) {
|
if (!ANTHROPIC_ROUTING_ENV_KEY_SET.has(key.toUpperCase())) {
|
||||||
result[key] = env[key];
|
result[key] = env[key];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncTmuxNestedSessionEnv(env: NodeJS.ProcessEnv, profileType: string | undefined): void {
|
||||||
|
if (!process.env.TMUX) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nestedSessionEnv =
|
||||||
|
profileType === 'account' || profileType === 'default'
|
||||||
|
? stripAnthropicEnv(env)
|
||||||
|
: profileType === 'settings'
|
||||||
|
? stripAnthropicRoutingEnv(env)
|
||||||
|
: env;
|
||||||
|
|
||||||
|
for (const key of TMUX_SYNC_ENV_KEYS) {
|
||||||
|
try {
|
||||||
|
const value = nestedSessionEnv[key];
|
||||||
|
if (value !== undefined) {
|
||||||
|
spawnSync('tmux', ['setenv', key, value], { stdio: 'ignore' });
|
||||||
|
} else {
|
||||||
|
spawnSync('tmux', ['setenv', '-u', key], { stdio: 'ignore' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// tmux setenv can fail if not in a tmux session; safe to ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Strip Claude Code nested-session guard env var from a process environment.
|
* Strip Claude Code nested-session guard env var from a process environment.
|
||||||
*
|
*
|
||||||
@@ -193,20 +236,9 @@ export function execClaude(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// propagate key env vars to tmux session so agent team teammates
|
// Keep tmux teammate panes aligned with the nested-safe Claude runtime env
|
||||||
// (spawned via tmux split-window) inherit the correct config dir
|
// rather than the tmux server's original shell environment.
|
||||||
if (process.env.TMUX && envVars) {
|
syncTmuxNestedSessionEnv(env, profileType);
|
||||||
const tmuxPropagateVars = ['CLAUDE_CONFIG_DIR', 'CCS_PROFILE_TYPE', 'CCS_WEBSEARCH_SKIP'];
|
|
||||||
for (const key of tmuxPropagateVars) {
|
|
||||||
if (envVars[key]) {
|
|
||||||
try {
|
|
||||||
spawnSync('tmux', ['setenv', key, envVars[key] ?? ''], { stdio: 'ignore' });
|
|
||||||
} catch {
|
|
||||||
// tmux setenv can fail if not in a tmux session; safe to ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let child: ChildProcess;
|
let child: ChildProcess;
|
||||||
if (isPowerShellScript) {
|
if (isPowerShellScript) {
|
||||||
|
|||||||
@@ -21,8 +21,16 @@ type SpawnCall = {
|
|||||||
options: Record<string, unknown> | undefined;
|
options: Record<string, unknown> | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const STEERING_PROMPT_SNIPPET = 'prefer the CCS MCP tool WebSearch instead of Bash/curl/http fetches';
|
type SpawnSyncCall = {
|
||||||
|
command: string;
|
||||||
|
args: string[];
|
||||||
|
options: Record<string, unknown> | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STEERING_PROMPT_SNIPPET =
|
||||||
|
'prefer the CCS MCP tool WebSearch instead of Bash/curl/http fetches';
|
||||||
const spawnCalls: SpawnCall[] = [];
|
const spawnCalls: SpawnCall[] = [];
|
||||||
|
const spawnSyncCalls: SpawnSyncCall[] = [];
|
||||||
const originalPlatform = process.platform;
|
const originalPlatform = process.platform;
|
||||||
let baselineSigintListeners: Array<(...args: unknown[]) => void> = [];
|
let baselineSigintListeners: Array<(...args: unknown[]) => void> = [];
|
||||||
let baselineSigtermListeners: Array<(...args: unknown[]) => void> = [];
|
let baselineSigtermListeners: Array<(...args: unknown[]) => void> = [];
|
||||||
@@ -31,6 +39,7 @@ let originalCcsHome: string | undefined;
|
|||||||
let originalCcsClaudePath: string | undefined;
|
let originalCcsClaudePath: string | undefined;
|
||||||
let originalDisableAutoUpdater: string | undefined;
|
let originalDisableAutoUpdater: string | undefined;
|
||||||
let originalClaudeConfigDir: string | undefined;
|
let originalClaudeConfigDir: string | undefined;
|
||||||
|
let originalTmux: string | undefined;
|
||||||
const realSpawn = childProcess.spawn.bind(childProcess);
|
const realSpawn = childProcess.spawn.bind(childProcess);
|
||||||
const realSpawnSync = childProcess.spawnSync.bind(childProcess);
|
const realSpawnSync = childProcess.spawnSync.bind(childProcess);
|
||||||
const realExecSync = childProcess.execSync.bind(childProcess);
|
const realExecSync = childProcess.execSync.bind(childProcess);
|
||||||
@@ -103,6 +112,18 @@ function registerChildProcessMock(): void {
|
|||||||
| Record<string, unknown>
|
| Record<string, unknown>
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
|
if (command === 'tmux') {
|
||||||
|
spawnSyncCalls.push({ command, args, options });
|
||||||
|
return {
|
||||||
|
pid: process.pid,
|
||||||
|
output: ['', '', ''],
|
||||||
|
stdout: '',
|
||||||
|
stderr: '',
|
||||||
|
status: 0,
|
||||||
|
signal: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return realSpawnSync(command, args, options as Parameters<typeof childProcess.spawnSync>[2]);
|
return realSpawnSync(command, args, options as Parameters<typeof childProcess.spawnSync>[2]);
|
||||||
},
|
},
|
||||||
execSync: (...execArgs: unknown[]) =>
|
execSync: (...execArgs: unknown[]) =>
|
||||||
@@ -140,6 +161,7 @@ ${yamlBody}
|
|||||||
}
|
}
|
||||||
|
|
||||||
let execClaude: typeof import('../../../src/utils/shell-executor').execClaude;
|
let execClaude: typeof import('../../../src/utils/shell-executor').execClaude;
|
||||||
|
let stripAnthropicRoutingEnv: typeof import('../../../src/utils/shell-executor').stripAnthropicRoutingEnv;
|
||||||
let stripClaudeCodeEnv: typeof import('../../../src/utils/shell-executor').stripClaudeCodeEnv;
|
let stripClaudeCodeEnv: typeof import('../../../src/utils/shell-executor').stripClaudeCodeEnv;
|
||||||
let HeadlessExecutor: typeof import('../../../src/delegation/headless-executor').HeadlessExecutor;
|
let HeadlessExecutor: typeof import('../../../src/delegation/headless-executor').HeadlessExecutor;
|
||||||
let SharedManager: typeof import('../../../src/management/shared-manager').default;
|
let SharedManager: typeof import('../../../src/management/shared-manager').default;
|
||||||
@@ -149,6 +171,7 @@ beforeAll(async () => {
|
|||||||
|
|
||||||
const shellExecutor = await import('../../../src/utils/shell-executor');
|
const shellExecutor = await import('../../../src/utils/shell-executor');
|
||||||
execClaude = shellExecutor.execClaude;
|
execClaude = shellExecutor.execClaude;
|
||||||
|
stripAnthropicRoutingEnv = shellExecutor.stripAnthropicRoutingEnv;
|
||||||
stripClaudeCodeEnv = shellExecutor.stripClaudeCodeEnv;
|
stripClaudeCodeEnv = shellExecutor.stripClaudeCodeEnv;
|
||||||
|
|
||||||
const sharedManagerModule = await import('../../../src/management/shared-manager');
|
const sharedManagerModule = await import('../../../src/management/shared-manager');
|
||||||
@@ -165,6 +188,7 @@ afterAll(() => {
|
|||||||
describe('CLAUDECODE environment stripping', () => {
|
describe('CLAUDECODE environment stripping', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
spawnCalls.length = 0;
|
spawnCalls.length = 0;
|
||||||
|
spawnSyncCalls.length = 0;
|
||||||
process.env.CCS_QUIET = '1';
|
process.env.CCS_QUIET = '1';
|
||||||
|
|
||||||
// Save original env values for restoration in afterEach
|
// Save original env values for restoration in afterEach
|
||||||
@@ -172,10 +196,12 @@ describe('CLAUDECODE environment stripping', () => {
|
|||||||
originalCcsClaudePath = process.env.CCS_CLAUDE_PATH;
|
originalCcsClaudePath = process.env.CCS_CLAUDE_PATH;
|
||||||
originalDisableAutoUpdater = process.env.DISABLE_AUTOUPDATER;
|
originalDisableAutoUpdater = process.env.DISABLE_AUTOUPDATER;
|
||||||
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||||
|
originalTmux = process.env.TMUX;
|
||||||
|
|
||||||
// Clear CCS-managed env vars that leak from host sessions
|
// Clear CCS-managed env vars that leak from host sessions
|
||||||
delete process.env.DISABLE_AUTOUPDATER;
|
delete process.env.DISABLE_AUTOUPDATER;
|
||||||
delete process.env.CLAUDE_CONFIG_DIR;
|
delete process.env.CLAUDE_CONFIG_DIR;
|
||||||
|
delete process.env.TMUX;
|
||||||
|
|
||||||
baselineSigintListeners = process.listeners('SIGINT');
|
baselineSigintListeners = process.listeners('SIGINT');
|
||||||
baselineSigtermListeners = process.listeners('SIGTERM');
|
baselineSigtermListeners = process.listeners('SIGTERM');
|
||||||
@@ -197,8 +223,20 @@ describe('CLAUDECODE environment stripping', () => {
|
|||||||
} else {
|
} else {
|
||||||
delete process.env.DISABLE_AUTOUPDATER;
|
delete process.env.DISABLE_AUTOUPDATER;
|
||||||
}
|
}
|
||||||
if (originalClaudeConfigDir !== undefined) process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
|
if (originalClaudeConfigDir !== undefined)
|
||||||
|
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
|
||||||
else delete process.env.CLAUDE_CONFIG_DIR;
|
else delete process.env.CLAUDE_CONFIG_DIR;
|
||||||
|
if (originalTmux !== undefined) process.env.TMUX = originalTmux;
|
||||||
|
else delete process.env.TMUX;
|
||||||
|
delete process.env.ANTHROPIC_BASE_URL;
|
||||||
|
delete process.env.ANTHROPIC_AUTH_TOKEN;
|
||||||
|
delete process.env.ANTHROPIC_API_KEY;
|
||||||
|
delete process.env.ANTHROPIC_MODEL;
|
||||||
|
delete process.env.ANTHROPIC_DEFAULT_OPUS_MODEL;
|
||||||
|
delete process.env.ANTHROPIC_DEFAULT_SONNET_MODEL;
|
||||||
|
delete process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL;
|
||||||
|
delete process.env.ANTHROPIC_SMALL_FAST_MODEL;
|
||||||
|
delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS;
|
||||||
|
|
||||||
for (const listener of process.listeners('SIGINT')) {
|
for (const listener of process.listeners('SIGINT')) {
|
||||||
if (!baselineSigintListeners.includes(listener)) {
|
if (!baselineSigintListeners.includes(listener)) {
|
||||||
@@ -230,6 +268,25 @@ describe('CLAUDECODE environment stripping', () => {
|
|||||||
expect(result.PATH).toBe('/usr/bin');
|
expect(result.PATH).toBe('/usr/bin');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('stripAnthropicRoutingEnv removes routing/auth env case-insensitively while preserving model vars', () => {
|
||||||
|
const input: NodeJS.ProcessEnv = {
|
||||||
|
anthropic_base_url: 'http://127.0.0.1:8317/api/provider/codex',
|
||||||
|
Anthropic_Auth_Token: 'parent-routing-token',
|
||||||
|
ANTHROPIC_API_KEY: 'parent-api-key',
|
||||||
|
ANTHROPIC_MODEL: 'gpt-5.4',
|
||||||
|
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.4',
|
||||||
|
PATH: '/usr/bin',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = stripAnthropicRoutingEnv(input);
|
||||||
|
expect(result.anthropic_base_url).toBeUndefined();
|
||||||
|
expect(result.Anthropic_Auth_Token).toBeUndefined();
|
||||||
|
expect(result.ANTHROPIC_API_KEY).toBeUndefined();
|
||||||
|
expect(result.ANTHROPIC_MODEL).toBe('gpt-5.4');
|
||||||
|
expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.4');
|
||||||
|
expect(result.PATH).toBe('/usr/bin');
|
||||||
|
});
|
||||||
|
|
||||||
it('execClaude strips CLAUDECODE from merged env (including overrides)', () => {
|
it('execClaude strips CLAUDECODE from merged env (including overrides)', () => {
|
||||||
process.env.CLAUDECODE = 'from-parent';
|
process.env.CLAUDECODE = 'from-parent';
|
||||||
process.env.claudecode = 'from-parent-lower';
|
process.env.claudecode = 'from-parent-lower';
|
||||||
@@ -387,6 +444,46 @@ describe('CLAUDECODE environment stripping', () => {
|
|||||||
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.4');
|
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.4');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('execClaude sanitizes tmux teammate env for bridge-backed settings launches while keeping the launched child on the runtime proxy', () => {
|
||||||
|
process.env.TMUX = 'session-1';
|
||||||
|
process.env.ANTHROPIC_BASE_URL = 'http://127.0.0.1:8317/api/provider/codex';
|
||||||
|
process.env.ANTHROPIC_AUTH_TOKEN = 'parent-routing-token';
|
||||||
|
process.env.ANTHROPIC_API_KEY = 'parent-api-key';
|
||||||
|
process.env.ANTHROPIC_MODEL = 'gpt-5.4';
|
||||||
|
process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'gpt-5.4';
|
||||||
|
|
||||||
|
execClaude('claude', ['--help'], {
|
||||||
|
CCS_PROFILE_TYPE: 'settings',
|
||||||
|
CLAUDE_CONFIG_DIR: path.join(os.tmpdir(), 'ccs-settings-profile-instance'),
|
||||||
|
ANTHROPIC_BASE_URL: 'http://127.0.0.1:3456',
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'fresh-runtime-token',
|
||||||
|
ANTHROPIC_MODEL: 'gpt-5.4',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(spawnCalls.length).toBeGreaterThan(0);
|
||||||
|
const childEnv = spawnCalls[0].options?.env as NodeJS.ProcessEnv;
|
||||||
|
expect(childEnv.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:3456');
|
||||||
|
expect(childEnv.ANTHROPIC_AUTH_TOKEN).toBe('fresh-runtime-token');
|
||||||
|
|
||||||
|
const unsetBaseUrlCall = spawnSyncCalls.find(
|
||||||
|
(call) => call.command === 'tmux' && call.args.join(' ') === 'setenv -u ANTHROPIC_BASE_URL'
|
||||||
|
);
|
||||||
|
const unsetAuthTokenCall = spawnSyncCalls.find(
|
||||||
|
(call) => call.command === 'tmux' && call.args.join(' ') === 'setenv -u ANTHROPIC_AUTH_TOKEN'
|
||||||
|
);
|
||||||
|
const modelCall = spawnSyncCalls.find(
|
||||||
|
(call) =>
|
||||||
|
call.command === 'tmux' &&
|
||||||
|
call.args[0] === 'setenv' &&
|
||||||
|
call.args[1] === 'ANTHROPIC_MODEL' &&
|
||||||
|
call.args[2] === 'gpt-5.4'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(unsetBaseUrlCall).toBeDefined();
|
||||||
|
expect(unsetAuthTokenCall).toBeDefined();
|
||||||
|
expect(modelCall).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
it('headless executor spawn path strips CLAUDECODE before spawn', async () => {
|
it('headless executor spawn path strips CLAUDECODE before spawn', async () => {
|
||||||
writeConfigWithAutoUpdatePreference(false);
|
writeConfigWithAutoUpdatePreference(false);
|
||||||
process.env.CLAUDECODE = 'nested';
|
process.env.CLAUDECODE = 'nested';
|
||||||
@@ -483,6 +580,92 @@ describe('CLAUDECODE environment stripping', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('headless executor strips inherited routing env for settings-profile delegation while preserving model intent', async () => {
|
||||||
|
writeConfigWithAutoUpdatePreference(false);
|
||||||
|
const ccsDir = path.join(process.env.CCS_HOME as string, '.ccs');
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(ccsDir, 'glm.settings.json'),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
env: {
|
||||||
|
ANTHROPIC_MODEL: 'gpt-5.4',
|
||||||
|
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.4',
|
||||||
|
CLAUDE_CODE_MAX_OUTPUT_TOKENS: '12345',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
) + '\n',
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
const projectDir = path.join(ccsDir, 'project-headless-settings');
|
||||||
|
fs.mkdirSync(projectDir, { recursive: true });
|
||||||
|
process.env.CCS_CLAUDE_PATH = 'claude';
|
||||||
|
process.env.ANTHROPIC_BASE_URL = 'http://127.0.0.1:8317/api/provider/codex';
|
||||||
|
process.env.ANTHROPIC_AUTH_TOKEN = 'parent-routing-token';
|
||||||
|
process.env.ANTHROPIC_API_KEY = 'parent-api-key';
|
||||||
|
process.env.ANTHROPIC_MODEL = 'gpt-5.4';
|
||||||
|
|
||||||
|
const result = await HeadlessExecutor.execute('glm', 'latest AI chip news', {
|
||||||
|
cwd: projectDir,
|
||||||
|
permissionMode: 'default',
|
||||||
|
timeout: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(spawnCalls.length).toBeGreaterThan(0);
|
||||||
|
const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv;
|
||||||
|
expect(env.ANTHROPIC_BASE_URL).toBeUndefined();
|
||||||
|
expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined();
|
||||||
|
expect(env.ANTHROPIC_API_KEY).toBeUndefined();
|
||||||
|
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.4');
|
||||||
|
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.4');
|
||||||
|
expect(env.CLAUDE_CODE_MAX_OUTPUT_TOKENS).toBe('12345');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('headless executor rebuilds OpenAI-compatible bridge env from settings instead of inheriting stale parent routing', async () => {
|
||||||
|
writeConfigWithAutoUpdatePreference(false);
|
||||||
|
const ccsDir = path.join(process.env.CCS_HOME as string, '.ccs');
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(ccsDir, 'bridge.settings.json'),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
env: {
|
||||||
|
ANTHROPIC_BASE_URL: 'https://api.openai.com/v1',
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'settings-bridge-token',
|
||||||
|
ANTHROPIC_MODEL: 'gpt-5.4',
|
||||||
|
CLAUDE_CODE_MAX_OUTPUT_TOKENS: '12345',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
) + '\n',
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
const projectDir = path.join(ccsDir, 'project-headless-bridge');
|
||||||
|
fs.mkdirSync(projectDir, { recursive: true });
|
||||||
|
process.env.CCS_CLAUDE_PATH = 'claude';
|
||||||
|
process.env.ANTHROPIC_BASE_URL = 'http://127.0.0.1:8317/api/provider/codex';
|
||||||
|
process.env.ANTHROPIC_AUTH_TOKEN = 'parent-routing-token';
|
||||||
|
process.env.ANTHROPIC_API_KEY = 'parent-api-key';
|
||||||
|
|
||||||
|
const result = await HeadlessExecutor.execute('bridge', 'latest AI chip news', {
|
||||||
|
cwd: projectDir,
|
||||||
|
permissionMode: 'default',
|
||||||
|
timeout: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(spawnCalls.length).toBeGreaterThan(0);
|
||||||
|
const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv;
|
||||||
|
expect(env.ANTHROPIC_BASE_URL).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
|
||||||
|
expect(env.ANTHROPIC_BASE_URL).not.toBe('http://127.0.0.1:8317/api/provider/codex');
|
||||||
|
expect(env.ANTHROPIC_AUTH_TOKEN).toBeDefined();
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
|
||||||
it('headless executor prepares image-analysis MCP and suppresses the legacy hook on healthy launches', async () => {
|
it('headless executor prepares image-analysis MCP and suppresses the legacy hook on healthy launches', async () => {
|
||||||
writeConfigWithAutoUpdatePreference(false);
|
writeConfigWithAutoUpdatePreference(false);
|
||||||
const ccsDir = path.join(process.env.CCS_HOME as string, '.ccs');
|
const ccsDir = path.join(process.env.CCS_HOME as string, '.ccs');
|
||||||
@@ -519,9 +702,7 @@ describe('CLAUDECODE environment stripping', () => {
|
|||||||
args: [path.join(ccsDir, 'mcp', 'ccs-image-analysis-server.cjs')],
|
args: [path.join(ccsDir, 'mcp', 'ccs-image-analysis-server.cjs')],
|
||||||
env: {},
|
env: {},
|
||||||
});
|
});
|
||||||
expect(fs.existsSync(path.join(ccsDir, 'hooks', 'image-analyzer-transformer.cjs'))).toBe(
|
expect(fs.existsSync(path.join(ccsDir, 'hooks', 'image-analyzer-transformer.cjs'))).toBe(false);
|
||||||
false
|
|
||||||
);
|
|
||||||
expect(fs.existsSync(path.join(ccsDir, 'hooks', 'image-analysis-runtime.cjs'))).toBe(false);
|
expect(fs.existsSync(path.join(ccsDir, 'hooks', 'image-analysis-runtime.cjs'))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user