mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 00:22:34 +00:00
496 lines
18 KiB
TypeScript
496 lines
18 KiB
TypeScript
import {
|
|
afterAll,
|
|
afterEach,
|
|
beforeAll,
|
|
beforeEach,
|
|
describe,
|
|
expect,
|
|
it,
|
|
mock,
|
|
spyOn,
|
|
} from 'bun:test';
|
|
import { EventEmitter } from 'events';
|
|
import * as childProcess from 'child_process';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
|
|
type SpawnCall = {
|
|
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 originalPlatform = process.platform;
|
|
let baselineSigintListeners: Array<(...args: unknown[]) => void> = [];
|
|
let baselineSigtermListeners: Array<(...args: unknown[]) => void> = [];
|
|
let baselineSighupListeners: Array<(...args: unknown[]) => void> = [];
|
|
let originalCcsHome: string | undefined;
|
|
let originalCcsClaudePath: string | undefined;
|
|
let originalDisableAutoUpdater: string | undefined;
|
|
let originalClaudeConfigDir: string | undefined;
|
|
const realSpawn = childProcess.spawn.bind(childProcess);
|
|
const realSpawnSync = childProcess.spawnSync.bind(childProcess);
|
|
const realExecSync = childProcess.execSync.bind(childProcess);
|
|
|
|
function createMockChild(): EventEmitter & {
|
|
stdout: EventEmitter;
|
|
stderr: EventEmitter;
|
|
exitCode: number | null;
|
|
killed: boolean;
|
|
pid: number;
|
|
unref: () => EventEmitter;
|
|
kill: () => boolean;
|
|
} {
|
|
const child = new EventEmitter() as EventEmitter & {
|
|
stdout: EventEmitter;
|
|
stderr: EventEmitter;
|
|
exitCode: number | null;
|
|
killed: boolean;
|
|
pid: number;
|
|
unref: () => EventEmitter;
|
|
kill: () => boolean;
|
|
};
|
|
|
|
child.stdout = new EventEmitter();
|
|
child.stderr = new EventEmitter();
|
|
child.exitCode = null;
|
|
child.killed = false;
|
|
child.pid = process.pid;
|
|
child.unref = () => child;
|
|
child.kill = () => {
|
|
child.killed = true;
|
|
child.exitCode = 1;
|
|
return true;
|
|
};
|
|
|
|
return child;
|
|
}
|
|
|
|
function shouldMockCommand(command: string): boolean {
|
|
const normalized = command.toLowerCase();
|
|
return normalized.includes('claude');
|
|
}
|
|
|
|
function registerChildProcessMock(): void {
|
|
mock.module('child_process', () => ({
|
|
...childProcess,
|
|
spawn: (...spawnArgs: unknown[]) => {
|
|
const command = String(spawnArgs[0] ?? '');
|
|
const maybeArgs = spawnArgs[1];
|
|
const args = Array.isArray(maybeArgs) ? (maybeArgs as string[]) : [];
|
|
const options = (Array.isArray(maybeArgs) ? spawnArgs[2] : spawnArgs[1]) as
|
|
| Record<string, unknown>
|
|
| undefined;
|
|
|
|
if (!shouldMockCommand(command)) {
|
|
return realSpawn(command, args, options as Parameters<typeof childProcess.spawn>[2]);
|
|
}
|
|
|
|
spawnCalls.push({ command, args, options });
|
|
|
|
const child = createMockChild();
|
|
setTimeout(() => child.emit('close', 0), 0);
|
|
return child;
|
|
},
|
|
spawnSync: (...spawnArgs: unknown[]) => {
|
|
const command = String(spawnArgs[0] ?? '');
|
|
const maybeArgs = spawnArgs[1];
|
|
const args = Array.isArray(maybeArgs) ? (maybeArgs as string[]) : [];
|
|
const options = (Array.isArray(maybeArgs) ? spawnArgs[2] : spawnArgs[1]) as
|
|
| Record<string, unknown>
|
|
| undefined;
|
|
|
|
return realSpawnSync(command, args, options as Parameters<typeof childProcess.spawnSync>[2]);
|
|
},
|
|
execSync: (...execArgs: unknown[]) =>
|
|
realExecSync(
|
|
execArgs[0] as Parameters<typeof childProcess.execSync>[0],
|
|
execArgs[1] as Parameters<typeof childProcess.execSync>[1]
|
|
),
|
|
}));
|
|
}
|
|
|
|
function writeConfigWithAutoUpdatePreference(enabled: boolean): void {
|
|
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-auto-update-pref-'));
|
|
process.env.CCS_HOME = tempHome;
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
fs.mkdirSync(ccsDir, { recursive: true });
|
|
const yaml = `version: 8
|
|
preferences:
|
|
auto_update: ${enabled ? 'true' : 'false'}
|
|
`;
|
|
fs.writeFileSync(path.join(ccsDir, 'config.yaml'), yaml, 'utf8');
|
|
}
|
|
|
|
function writeConfigWithWebSearchSettings(yamlBody: string): void {
|
|
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-websearch-env-'));
|
|
process.env.CCS_HOME = tempHome;
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
fs.mkdirSync(ccsDir, { recursive: true });
|
|
const yaml = `version: 8
|
|
preferences:
|
|
auto_update: true
|
|
websearch:
|
|
${yamlBody}
|
|
`;
|
|
fs.writeFileSync(path.join(ccsDir, 'config.yaml'), yaml, 'utf8');
|
|
}
|
|
|
|
let execClaude: typeof import('../../../src/utils/shell-executor').execClaude;
|
|
let stripClaudeCodeEnv: typeof import('../../../src/utils/shell-executor').stripClaudeCodeEnv;
|
|
let HeadlessExecutor: typeof import('../../../src/delegation/headless-executor').HeadlessExecutor;
|
|
let SharedManager: typeof import('../../../src/management/shared-manager').default;
|
|
|
|
beforeAll(async () => {
|
|
registerChildProcessMock();
|
|
|
|
const shellExecutor = await import('../../../src/utils/shell-executor');
|
|
execClaude = shellExecutor.execClaude;
|
|
stripClaudeCodeEnv = shellExecutor.stripClaudeCodeEnv;
|
|
|
|
const sharedManagerModule = await import('../../../src/management/shared-manager');
|
|
SharedManager = sharedManagerModule.default;
|
|
|
|
const headless = await import('../../../src/delegation/headless-executor');
|
|
HeadlessExecutor = headless.HeadlessExecutor;
|
|
});
|
|
|
|
afterAll(() => {
|
|
mock.restore();
|
|
});
|
|
|
|
describe('CLAUDECODE environment stripping', () => {
|
|
beforeEach(() => {
|
|
spawnCalls.length = 0;
|
|
process.env.CCS_QUIET = '1';
|
|
|
|
// Save original env values for restoration in afterEach
|
|
originalCcsHome = process.env.CCS_HOME;
|
|
originalCcsClaudePath = process.env.CCS_CLAUDE_PATH;
|
|
originalDisableAutoUpdater = process.env.DISABLE_AUTOUPDATER;
|
|
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
|
|
|
// Clear CCS-managed env vars that leak from host sessions
|
|
delete process.env.DISABLE_AUTOUPDATER;
|
|
delete process.env.CLAUDE_CONFIG_DIR;
|
|
|
|
baselineSigintListeners = process.listeners('SIGINT');
|
|
baselineSigtermListeners = process.listeners('SIGTERM');
|
|
baselineSighupListeners = process.listeners('SIGHUP');
|
|
});
|
|
|
|
afterEach(() => {
|
|
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
|
delete process.env.CLAUDECODE;
|
|
delete process.env.claudecode;
|
|
delete process.env.CCS_QUIET;
|
|
delete process.env.CCS_WEBSEARCH_TRACE;
|
|
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
|
|
else delete process.env.CCS_HOME;
|
|
if (originalCcsClaudePath !== undefined) process.env.CCS_CLAUDE_PATH = originalCcsClaudePath;
|
|
else delete process.env.CCS_CLAUDE_PATH;
|
|
if (originalDisableAutoUpdater !== undefined) {
|
|
process.env.DISABLE_AUTOUPDATER = originalDisableAutoUpdater;
|
|
} else {
|
|
delete process.env.DISABLE_AUTOUPDATER;
|
|
}
|
|
if (originalClaudeConfigDir !== undefined) process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
|
|
else delete process.env.CLAUDE_CONFIG_DIR;
|
|
|
|
for (const listener of process.listeners('SIGINT')) {
|
|
if (!baselineSigintListeners.includes(listener)) {
|
|
process.removeListener('SIGINT', listener as (...args: unknown[]) => void);
|
|
}
|
|
}
|
|
for (const listener of process.listeners('SIGTERM')) {
|
|
if (!baselineSigtermListeners.includes(listener)) {
|
|
process.removeListener('SIGTERM', listener as (...args: unknown[]) => void);
|
|
}
|
|
}
|
|
for (const listener of process.listeners('SIGHUP')) {
|
|
if (!baselineSighupListeners.includes(listener)) {
|
|
process.removeListener('SIGHUP', listener as (...args: unknown[]) => void);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('stripClaudeCodeEnv removes CLAUDECODE case-insensitively', () => {
|
|
const input: NodeJS.ProcessEnv = {
|
|
CLAUDECODE: 'upper',
|
|
claudecode: 'lower',
|
|
ClAuDeCoDe: 'mixed',
|
|
PATH: '/usr/bin',
|
|
};
|
|
|
|
const result = stripClaudeCodeEnv(input);
|
|
expect(Object.keys(result).map((k) => k.toUpperCase())).not.toContain('CLAUDECODE');
|
|
expect(result.PATH).toBe('/usr/bin');
|
|
});
|
|
|
|
it('execClaude strips CLAUDECODE from merged env (including overrides)', () => {
|
|
process.env.CLAUDECODE = 'from-parent';
|
|
process.env.claudecode = 'from-parent-lower';
|
|
|
|
execClaude('claude', ['--version'], {
|
|
CCS_PROFILE_TYPE: 'default',
|
|
CLAUDECODE: 'from-override',
|
|
CCS_WEBSEARCH_SKIP: '1',
|
|
});
|
|
|
|
expect(spawnCalls.length).toBeGreaterThan(0);
|
|
const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv;
|
|
expect(env).toBeDefined();
|
|
expect(Object.keys(env).map((k) => k.toUpperCase())).not.toContain('CLAUDECODE');
|
|
expect(env.CCS_WEBSEARCH_ENABLED || env.CCS_WEBSEARCH_SKIP).toBeDefined();
|
|
});
|
|
|
|
it('execClaude keeps behavior when CLAUDECODE is absent', () => {
|
|
execClaude('claude', ['--help'], { CCS_PROFILE_TYPE: 'default' });
|
|
|
|
expect(spawnCalls.length).toBeGreaterThan(0);
|
|
const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv;
|
|
expect(env).toBeDefined();
|
|
expect(Object.keys(env).map((k) => k.toUpperCase())).not.toContain('CLAUDECODE');
|
|
expect(env.CCS_PROFILE_TYPE).toBe('default');
|
|
});
|
|
|
|
it('execClaude strips CLAUDECODE on Windows shell launch path', () => {
|
|
Object.defineProperty(process, 'platform', { value: 'win32' });
|
|
process.env.CLAUDECODE = 'set';
|
|
|
|
execClaude('claude.cmd', ['--version'], { CCS_PROFILE_TYPE: 'default' });
|
|
|
|
expect(spawnCalls.length).toBeGreaterThan(0);
|
|
const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv;
|
|
expect(Object.keys(env).map((k) => k.toUpperCase())).not.toContain('CLAUDECODE');
|
|
});
|
|
|
|
it('execClaude sets DISABLE_AUTOUPDATER=1 when preferences.auto_update is false', () => {
|
|
writeConfigWithAutoUpdatePreference(false);
|
|
execClaude('claude', ['--version'], { CCS_PROFILE_TYPE: 'default' });
|
|
|
|
expect(spawnCalls.length).toBeGreaterThan(0);
|
|
const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv;
|
|
expect(env.DISABLE_AUTOUPDATER).toBe('1');
|
|
});
|
|
|
|
it('execClaude does not force DISABLE_AUTOUPDATER when preferences.auto_update is true', () => {
|
|
writeConfigWithAutoUpdatePreference(true);
|
|
execClaude('claude', ['--version'], { CCS_PROFILE_TYPE: 'default' });
|
|
|
|
expect(spawnCalls.length).toBeGreaterThan(0);
|
|
const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv;
|
|
expect(env.DISABLE_AUTOUPDATER).toBeUndefined();
|
|
});
|
|
|
|
it('execClaude overrides stale inherited WebSearch provider flags with config-derived values', () => {
|
|
writeConfigWithWebSearchSettings(` enabled: true
|
|
providers:
|
|
duckduckgo:
|
|
enabled: true
|
|
searxng:
|
|
enabled: false
|
|
url: ''
|
|
`);
|
|
process.env.CCS_WEBSEARCH_SEARXNG = '1';
|
|
process.env.CCS_WEBSEARCH_SEARXNG_URL = 'https://search.example.com';
|
|
process.env.CCS_WEBSEARCH_SKIP = '1';
|
|
|
|
execClaude('claude', ['--version'], { CCS_PROFILE_TYPE: 'settings' });
|
|
|
|
expect(spawnCalls.length).toBeGreaterThan(0);
|
|
const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv;
|
|
expect(env.CCS_WEBSEARCH_ENABLED).toBe('1');
|
|
expect(env.CCS_WEBSEARCH_SKIP).toBe('0');
|
|
expect(env.CCS_WEBSEARCH_DUCKDUCKGO).toBe('1');
|
|
expect(env.CCS_WEBSEARCH_SEARXNG).toBe('0');
|
|
});
|
|
|
|
it('execClaude normalizes shared plugin metadata before default-profile launch', () => {
|
|
const normalizeSpy = spyOn(
|
|
SharedManager.prototype,
|
|
'normalizeSharedPluginMetadataPaths'
|
|
).mockImplementation(() => {});
|
|
|
|
execClaude('claude', ['--help'], { CCS_PROFILE_TYPE: 'default' });
|
|
|
|
expect(normalizeSpy).toHaveBeenCalledWith(undefined);
|
|
});
|
|
|
|
it('execClaude normalizes shared plugin metadata using CLAUDE_CONFIG_DIR when provided', () => {
|
|
const normalizeSpy = spyOn(
|
|
SharedManager.prototype,
|
|
'normalizeSharedPluginMetadataPaths'
|
|
).mockImplementation(() => {});
|
|
const instancePath = path.join(os.tmpdir(), 'ccs-shell-executor-instance');
|
|
|
|
execClaude('claude', ['--help'], {
|
|
CCS_PROFILE_TYPE: 'settings',
|
|
CLAUDE_CONFIG_DIR: instancePath,
|
|
});
|
|
|
|
expect(normalizeSpy).toHaveBeenCalledWith(instancePath);
|
|
});
|
|
|
|
it('headless executor spawn path strips CLAUDECODE before spawn', async () => {
|
|
writeConfigWithAutoUpdatePreference(false);
|
|
process.env.CLAUDECODE = 'nested';
|
|
process.env.claudecode = 'nested-lower';
|
|
|
|
const result = await (
|
|
HeadlessExecutor as unknown as {
|
|
_spawnAndExecute: (
|
|
claudeCli: string,
|
|
args: string[],
|
|
ctx: {
|
|
cwd: string;
|
|
profile: string;
|
|
timeout: number;
|
|
resumeSession: boolean;
|
|
sessionId: string | null;
|
|
sessionMgr: {
|
|
updateSession: (...args: unknown[]) => void;
|
|
storeSession: (...args: unknown[]) => void;
|
|
cleanupExpired: () => void;
|
|
};
|
|
}
|
|
) => Promise<unknown>;
|
|
}
|
|
)._spawnAndExecute('claude', ['-p', 'test'], {
|
|
cwd: process.cwd(),
|
|
profile: 'glm',
|
|
timeout: 1000,
|
|
resumeSession: false,
|
|
sessionId: null,
|
|
sessionMgr: {
|
|
updateSession: () => {},
|
|
storeSession: () => {},
|
|
cleanupExpired: () => {},
|
|
},
|
|
});
|
|
|
|
expect(result).toBeDefined();
|
|
expect(spawnCalls.length).toBeGreaterThan(0);
|
|
const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv;
|
|
expect(Object.keys(env).map((k) => k.toUpperCase())).not.toContain('CLAUDECODE');
|
|
expect(env.DISABLE_AUTOUPDATER).toBe('1');
|
|
});
|
|
|
|
it('headless executor adds third-party WebSearch steering args and env', async () => {
|
|
writeConfigWithAutoUpdatePreference(false);
|
|
const ccsDir = path.join(process.env.CCS_HOME as string, '.ccs');
|
|
fs.writeFileSync(path.join(ccsDir, 'glm.settings.json'), '{}\n', 'utf8');
|
|
const projectDir = path.join(ccsDir, 'project');
|
|
fs.mkdirSync(path.join(projectDir, '.claude'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(projectDir, '.claude', 'settings.local.json'),
|
|
JSON.stringify(
|
|
{
|
|
permissions: {
|
|
deny: ['Bash', 'WebFetch'],
|
|
},
|
|
},
|
|
null,
|
|
2
|
|
) + '\n',
|
|
'utf8'
|
|
);
|
|
process.env.CCS_CLAUDE_PATH = 'claude';
|
|
|
|
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 launch = spawnCalls[0];
|
|
expect(launch.args).toContain('--disallowedTools');
|
|
const disallowedToolsIndex = launch.args.indexOf('--disallowedTools');
|
|
expect(disallowedToolsIndex).toBeGreaterThan(-1);
|
|
expect(launch.args[disallowedToolsIndex + 1]).toBe('Bash,WebFetch,WebSearch');
|
|
expect(launch.args).toContain('--append-system-prompt');
|
|
expect(launch.args.join(' ')).toContain(STEERING_PROMPT_SNIPPET);
|
|
const env = launch.options?.env as NodeJS.ProcessEnv;
|
|
expect(env.CCS_PROFILE_TYPE).toBe('settings');
|
|
expect(env.CCS_WEBSEARCH_ENABLED || env.CCS_WEBSEARCH_SKIP).toBeDefined();
|
|
const claudeUserConfig = JSON.parse(
|
|
fs.readFileSync(path.join(process.env.CCS_HOME as string, '.claude.json'), 'utf8')
|
|
) as {
|
|
mcpServers?: Record<string, unknown>;
|
|
};
|
|
expect(claudeUserConfig.mcpServers?.['ccs-websearch']).toEqual({
|
|
type: 'stdio',
|
|
command: 'node',
|
|
args: [path.join(ccsDir, 'mcp', 'ccs-websearch-server.cjs')],
|
|
env: {},
|
|
});
|
|
});
|
|
|
|
it('headless executor prepares image-analysis MCP and suppresses the legacy hook on healthy launches', async () => {
|
|
writeConfigWithAutoUpdatePreference(false);
|
|
const ccsDir = path.join(process.env.CCS_HOME as string, '.ccs');
|
|
const settingsPath = path.join(ccsDir, 'glm.settings.json');
|
|
fs.writeFileSync(settingsPath, '{}\n', 'utf8');
|
|
process.env.CCS_CLAUDE_PATH = 'claude';
|
|
|
|
const result = await HeadlessExecutor.execute('glm', 'describe screenshot', {
|
|
permissionMode: 'default',
|
|
timeout: 1000,
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(spawnCalls.length).toBeGreaterThan(0);
|
|
const launch = spawnCalls[0];
|
|
const env = launch.options?.env as NodeJS.ProcessEnv;
|
|
|
|
const persistedSettings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as {
|
|
hooks?: { PreToolUse?: Array<{ matcher?: string }> };
|
|
};
|
|
expect(
|
|
persistedSettings.hooks?.PreToolUse?.some((hook) => hook.matcher === 'Read') ?? false
|
|
).toBe(false);
|
|
expect(env.CCS_IMAGE_ANALYSIS_SKIP_HOOK).toBe('1');
|
|
|
|
const claudeUserConfig = JSON.parse(
|
|
fs.readFileSync(path.join(process.env.CCS_HOME as string, '.claude.json'), 'utf8')
|
|
) as {
|
|
mcpServers?: Record<string, unknown>;
|
|
};
|
|
expect(claudeUserConfig.mcpServers?.['ccs-image-analysis']).toEqual({
|
|
type: 'stdio',
|
|
command: 'node',
|
|
args: [path.join(ccsDir, 'mcp', 'ccs-image-analysis-server.cjs')],
|
|
env: {},
|
|
});
|
|
expect(fs.existsSync(path.join(ccsDir, 'hooks', 'image-analyzer-transformer.cjs'))).toBe(
|
|
false
|
|
);
|
|
expect(fs.existsSync(path.join(ccsDir, 'hooks', 'image-analysis-runtime.cjs'))).toBe(false);
|
|
});
|
|
|
|
it('headless executor propagates a WebSearch trace launch id when tracing is enabled', async () => {
|
|
writeConfigWithAutoUpdatePreference(false);
|
|
const ccsDir = path.join(process.env.CCS_HOME as string, '.ccs');
|
|
fs.writeFileSync(path.join(ccsDir, 'glm.settings.json'), '{}\n', 'utf8');
|
|
process.env.CCS_CLAUDE_PATH = 'claude';
|
|
process.env.CCS_WEBSEARCH_TRACE = '1';
|
|
|
|
const result = await HeadlessExecutor.execute('glm', 'latest AI chip news', {
|
|
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.CCS_WEBSEARCH_TRACE).toBe('1');
|
|
expect(env.CCS_WEBSEARCH_TRACE_LAUNCH_ID).toBeString();
|
|
expect(env.CCS_WEBSEARCH_TRACE_LAUNCHER).toBe('delegation.headless-executor');
|
|
});
|
|
});
|