mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 18:16:08 +00:00
Use ComSpec-aware shell selection for escaped wrapper launches. Apply it to the remaining Windows Claude launch paths. Rewrite the Codex exec regression test to use scoped spies so the full suite stays isolated.
231 lines
7.7 KiB
TypeScript
231 lines
7.7 KiB
TypeScript
/**
|
|
* Shell Executor Utilities
|
|
*
|
|
* Cross-platform shell execution utilities for CCS.
|
|
*/
|
|
|
|
import { spawn, spawnSync, ChildProcess, type SpawnOptions } from 'child_process';
|
|
import { ErrorManager } from './error-manager';
|
|
import { getWebSearchHookEnv } from './websearch-manager';
|
|
import { wireChildProcessSignals } from './signal-forwarder';
|
|
import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
|
|
import SharedManager from '../management/shared-manager';
|
|
|
|
/**
|
|
* Strip ANTHROPIC_* env vars from an environment object.
|
|
* Used for account/default profiles to prevent stale proxy config from
|
|
* interfering with native Claude API routing.
|
|
*/
|
|
export function stripAnthropicEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
const result: NodeJS.ProcessEnv = {};
|
|
for (const key of Object.keys(env)) {
|
|
if (!key.startsWith('ANTHROPIC_')) {
|
|
result[key] = env[key];
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Strip Claude Code nested-session guard env var from a process environment.
|
|
*
|
|
* Note: Windows env keys are case-insensitive, so remove case-insensitively
|
|
* to avoid missing variants like `claudecode`.
|
|
*/
|
|
export function stripClaudeCodeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
const result: NodeJS.ProcessEnv = {};
|
|
for (const key of Object.keys(env)) {
|
|
if (key.toUpperCase() !== 'CLAUDECODE') {
|
|
result[key] = env[key];
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Strip Codex session-scoped env vars before launching a nested Codex process.
|
|
*
|
|
* Keep real user config such as CODEX_HOME intact. Only remove the known
|
|
* session/runtime metadata exported by the current Codex host process.
|
|
*/
|
|
export function stripCodexSessionEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
const sessionKeys = new Set(['CODEX_CI', 'CODEX_MANAGED_BY_BUN', 'CODEX_THREAD_ID']);
|
|
const result: NodeJS.ProcessEnv = {};
|
|
for (const key of Object.keys(env)) {
|
|
const upperKey = key.toUpperCase();
|
|
if (sessionKeys.has(upperKey)) {
|
|
continue;
|
|
}
|
|
result[key] = env[key];
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Resolve CCS-managed environment overrides for Claude launch.
|
|
* - preferences.auto_update: false -> DISABLE_AUTOUPDATER=1
|
|
*/
|
|
export function getClaudeLaunchEnvOverrides(): NodeJS.ProcessEnv {
|
|
try {
|
|
const config = loadOrCreateUnifiedConfig();
|
|
if (config.preferences?.auto_update === false) {
|
|
return { DISABLE_AUTOUPDATER: '1' };
|
|
}
|
|
} catch {
|
|
// Config read errors should never block Claude launch.
|
|
}
|
|
return {};
|
|
}
|
|
|
|
/**
|
|
* Escape arguments for shell execution (cross-platform)
|
|
*
|
|
* IMPORTANT: On Windows, spawn({ shell: true }) uses cmd.exe by default,
|
|
* NOT PowerShell. cmd.exe does NOT recognize single quotes as string delimiters.
|
|
* We must use double quotes for cmd.exe compatibility.
|
|
*/
|
|
export function escapeShellArg(arg: string): string {
|
|
const isWindows = process.platform === 'win32';
|
|
|
|
if (isWindows) {
|
|
// cmd.exe: Use double quotes, escape inner double quotes by doubling them
|
|
// cmd.exe interprets "" as escaped double quote inside quoted string
|
|
// Strip newlines/tabs that can break cmd.exe parsing
|
|
return (
|
|
'"' +
|
|
String(arg)
|
|
.replace(/[\r\n\t]/g, ' ') // Replace newlines/tabs with space
|
|
.replace(/%/g, '%%') // Escape percent signs
|
|
.replace(/\^/g, '^^') // Escape carets
|
|
.replace(/!/g, '^^!') // Escape exclamation marks (delayed expansion)
|
|
.replace(/"/g, '""') + // Escape quotes
|
|
'"'
|
|
);
|
|
} else {
|
|
// Unix/macOS: Double quotes with escaped inner quotes
|
|
return '"' + String(arg).replace(/"/g, '\\"') + '"';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Return the shell that matches escapeShellArg() quoting semantics.
|
|
*
|
|
* On Windows, prefer ComSpec over a bare `cmd.exe` so escaped wrapper launches
|
|
* keep the same shell contract without depending on PATH lookup.
|
|
*/
|
|
export function getWindowsEscapedCommandShell(): SpawnOptions['shell'] {
|
|
if (process.platform !== 'win32') {
|
|
return true;
|
|
}
|
|
|
|
return process.env.ComSpec || process.env.COMSPEC || 'cmd.exe';
|
|
}
|
|
|
|
/**
|
|
* Execute Claude CLI with unified spawn logic
|
|
*/
|
|
export function execClaude(
|
|
claudeCli: string,
|
|
args: string[],
|
|
envVars: NodeJS.ProcessEnv | null = null
|
|
): void {
|
|
const isWindows = process.platform === 'win32';
|
|
const isPowerShellScript = isWindows && /\.ps1$/i.test(claudeCli);
|
|
const needsShell = isWindows && /\.(cmd|bat)$/i.test(claudeCli);
|
|
|
|
// Get WebSearch hook config env vars
|
|
const webSearchEnv = getWebSearchHookEnv();
|
|
const claudeLaunchEnv = getClaudeLaunchEnvOverrides();
|
|
|
|
// For account/default profiles, strip ANTHROPIC_* from parent env to prevent
|
|
// stale proxy config (e.g., from prior CLIProxy sessions) from interfering
|
|
// with native Claude API routing. Settings-based profiles explicitly inject
|
|
// their own ANTHROPIC_* values, so they don't need this protection.
|
|
const profileType = envVars?.CCS_PROFILE_TYPE;
|
|
const baseEnv =
|
|
profileType === 'account' || profileType === 'default'
|
|
? stripAnthropicEnv(process.env)
|
|
: process.env;
|
|
|
|
// Prepare environment (merge with base env if envVars provided)
|
|
const mergedEnv = envVars
|
|
? { ...baseEnv, ...claudeLaunchEnv, ...envVars, ...webSearchEnv }
|
|
: { ...baseEnv, ...claudeLaunchEnv, ...webSearchEnv };
|
|
|
|
// Strip Claude Code nested session guard env var to allow CCS delegation
|
|
// (Claude Code v2.1.39+ sets CLAUDECODE to detect nested sessions)
|
|
const env = stripClaudeCodeEnv(mergedEnv);
|
|
|
|
if (profileType !== 'account') {
|
|
try {
|
|
new SharedManager().normalizeSharedPluginMetadataPathsLocked(env.CLAUDE_CONFIG_DIR);
|
|
} catch {
|
|
// Best-effort normalization should never block Claude launch.
|
|
}
|
|
}
|
|
|
|
// propagate key env vars to tmux session so agent team teammates
|
|
// (spawned via tmux split-window) inherit the correct config dir
|
|
if (process.env.TMUX && envVars) {
|
|
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;
|
|
if (isPowerShellScript) {
|
|
child = spawn(
|
|
'powershell.exe',
|
|
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', claudeCli, ...args],
|
|
{
|
|
stdio: 'inherit',
|
|
windowsHide: true,
|
|
env,
|
|
}
|
|
);
|
|
} else if (needsShell) {
|
|
// When shell needed: concatenate into string to avoid DEP0190 warning
|
|
const cmdString = [claudeCli, ...args].map(escapeShellArg).join(' ');
|
|
child = spawn(cmdString, {
|
|
stdio: 'inherit',
|
|
windowsHide: true,
|
|
shell: getWindowsEscapedCommandShell(),
|
|
env,
|
|
});
|
|
} else {
|
|
// When no shell needed: use array form (faster, no shell overhead)
|
|
child = spawn(claudeCli, args, {
|
|
stdio: 'inherit',
|
|
windowsHide: true,
|
|
env,
|
|
});
|
|
}
|
|
|
|
wireChildProcessSignals(child, async (err: NodeJS.ErrnoException) => {
|
|
if (err.code === 'EACCES') {
|
|
console.error(`[X] Claude CLI is not executable: ${claudeCli}`);
|
|
console.error(' Check file permissions and executable bit.');
|
|
} else if (err.code === 'ENOENT') {
|
|
if (isPowerShellScript) {
|
|
console.error('[X] PowerShell executable not found (required for .ps1 wrapper launch).');
|
|
console.error(' Ensure powershell.exe is available in PATH.');
|
|
} else if (needsShell) {
|
|
console.error('[X] Windows command shell not found for Claude wrapper launch.');
|
|
console.error(' Ensure cmd.exe is available and accessible.');
|
|
} else {
|
|
await ErrorManager.showClaudeNotFound();
|
|
}
|
|
} else {
|
|
console.error(`[X] Failed to start Claude CLI (${claudeCli}): ${err.message}`);
|
|
}
|
|
process.exit(1);
|
|
});
|
|
}
|