test(image-analysis): cover runtime auth and fallback

This commit is contained in:
Tam Nhu Tran
2026-04-02 15:44:13 -04:00
parent 813b2dd4d0
commit f379a4001f
15 changed files with 1077 additions and 52 deletions
+84 -40
View File
@@ -17,7 +17,7 @@
*/
import { describe, it, expect, beforeAll, afterAll } from 'bun:test';
import { spawnSync } from 'child_process';
import { spawn, spawnSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as http from 'http';
@@ -163,6 +163,56 @@ function invokeHook(
};
}
function invokeHookAsync(
input: object,
env: Record<string, string> = {}
): Promise<{ code: number; stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
const child = spawn('node', [HOOK_PATH], {
env: {
...process.env,
CCS_CLIPROXY_API_KEY: CLIPROXY_API_KEY,
CCS_CLIPROXY_PORT: String(MOCK_PORT),
CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: DEFAULT_PROVIDER_MODELS,
CCS_CURRENT_PROVIDER: DEFAULT_PROVIDER,
...env,
},
stdio: ['pipe', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
const timer = setTimeout(() => {
child.kill('SIGKILL');
reject(new Error('Hook timed out'));
}, 10000);
child.stdout.on('data', (chunk) => {
stdout += chunk.toString();
});
child.stderr.on('data', (chunk) => {
stderr += chunk.toString();
});
child.on('error', (error) => {
clearTimeout(timer);
reject(error);
});
child.on('close', (code) => {
clearTimeout(timer);
resolve({
code: code ?? -1,
stdout,
stderr,
});
});
child.stdin.end(JSON.stringify(input));
});
}
/**
* Create a minimal valid PNG file (1x1 red pixel)
*/
@@ -272,15 +322,8 @@ function createTestTextFile(filepath: string, content: string): void {
* Create a large file exceeding 10MB
*/
function createLargeFile(filepath: string, sizeMB: number): void {
const bufferSize = 1024 * 1024; // 1MB
const totalBuffers = sizeMB;
const buffer = Buffer.alloc(bufferSize, 'A');
const stream = fs.createWriteStream(filepath);
for (let i = 0; i < totalBuffers; i++) {
stream.write(buffer);
}
stream.end();
const totalBytes = sizeMB * 1024 * 1024;
fs.writeFileSync(filepath, Buffer.alloc(totalBytes, 'A'));
}
// ============================================================================
@@ -498,12 +541,12 @@ describe('Image Analyzer Hook', () => {
// ==========================================================================
describe('File Size Limits', () => {
it('should reject files larger than 10MB', () => {
it('should reject files larger than 10MB', async () => {
// Create 11MB file
const largePath = path.join(TEST_DIR, 'large-test.png');
createLargeFile(largePath, 11);
const result = invokeHook(
const result = await invokeHookAsync(
{
tool_name: 'Read',
tool_input: { file_path: largePath },
@@ -533,7 +576,7 @@ describe('Image Analyzer Hook', () => {
it('should block when CLIProxy is unavailable to prevent context overflow', async () => {
// Force hook to use a port that's definitely not running
const result = invokeHook(
const result = await invokeHookAsync(
{
tool_name: 'Read',
tool_input: { file_path: testPngPath },
@@ -553,7 +596,7 @@ describe('Image Analyzer Hook', () => {
expect(output.hookSpecificOutput.permissionDecisionReason).toContain('CLIProxy unavailable');
});
it('should analyze PNG via mock CLIProxy and return analysis', () => {
it('should analyze PNG via mock CLIProxy and return analysis', async () => {
resetMockState();
mockResponse = {
content:
@@ -561,7 +604,7 @@ describe('Image Analyzer Hook', () => {
statusCode: 200,
};
const result = invokeHook(
const result = await invokeHookAsync(
{
tool_name: 'Read',
tool_input: { file_path: testPngPath },
@@ -577,14 +620,14 @@ describe('Image Analyzer Hook', () => {
expect(output.hookSpecificOutput.permissionDecisionReason).toContain('red square');
});
it('should analyze JPEG via mock CLIProxy', () => {
it('should analyze JPEG via mock CLIProxy', async () => {
resetMockState();
mockResponse = {
content: 'A minimalist white image, possibly a blank canvas or placeholder.',
statusCode: 200,
};
const result = invokeHook(
const result = await invokeHookAsync(
{
tool_name: 'Read',
tool_input: { file_path: testJpegPath },
@@ -598,10 +641,10 @@ describe('Image Analyzer Hook', () => {
expect(output.hookSpecificOutput.permissionDecisionReason).toContain('white image');
});
it('should include API key in request header', () => {
it('should include API key in request header', async () => {
resetMockState();
invokeHook(
await invokeHookAsync(
{
tool_name: 'Read',
tool_input: { file_path: testPngPath },
@@ -614,10 +657,10 @@ describe('Image Analyzer Hook', () => {
expect(lastRequest?.headers['x-api-key']).toBe(CLIPROXY_API_KEY);
});
it('should send correct request format to CLIProxy', () => {
it('should send correct request format to CLIProxy', async () => {
resetMockState();
invokeHook(
await invokeHookAsync(
{
tool_name: 'Read',
tool_input: { file_path: testPngPath },
@@ -627,13 +670,14 @@ describe('Image Analyzer Hook', () => {
CCS_PROFILE_TYPE: 'cliproxy',
CCS_CURRENT_PROVIDER: 'agy',
CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy:gemini-3-1-flash-preview',
CCS_IMAGE_ANALYSIS_RUNTIME_PATH: '/api/provider/agy',
}
);
// Verify request format
expect(lastRequest).not.toBeNull();
expect(lastRequest?.method).toBe('POST');
expect(lastRequest?.path).toBe('/v1/messages');
expect(lastRequest?.path).toBe('/api/provider/agy/v1/messages');
const body = lastRequest?.body as {
model: string;
@@ -664,10 +708,10 @@ describe('Image Analyzer Hook', () => {
expect(imageContent?.source?.data).toBeDefined();
});
it('should use correct media type for JPEG', () => {
it('should use correct media type for JPEG', async () => {
resetMockState();
invokeHook(
await invokeHookAsync(
{
tool_name: 'Read',
tool_input: { file_path: testJpegPath },
@@ -687,10 +731,10 @@ describe('Image Analyzer Hook', () => {
expect(imageContent?.source?.media_type).toBe('image/jpeg');
});
it('should respect debug mode and output debug messages', () => {
it('should respect debug mode and output debug messages', async () => {
resetMockState();
const result = invokeHook(
const result = await invokeHookAsync(
{
tool_name: 'Read',
tool_input: { file_path: testPngPath },
@@ -703,14 +747,14 @@ describe('Image Analyzer Hook', () => {
expect(result.stderr).toContain('Starting image analysis');
});
it('should handle API error response gracefully (pass through)', () => {
it('should handle API error response gracefully (pass through)', async () => {
resetMockState();
mockResponse = {
content: '',
statusCode: 500,
};
const result = invokeHook(
const result = await invokeHookAsync(
{
tool_name: 'Read',
tool_input: { file_path: testPngPath },
@@ -726,10 +770,10 @@ describe('Image Analyzer Hook', () => {
expect(output.hookSpecificOutput.permissionDecisionReason).toContain('Error');
});
it('should use model from provider_models mapping', () => {
it('should use model from provider_models mapping', async () => {
resetMockState();
invokeHook(
await invokeHookAsync(
{
tool_name: 'Read',
tool_input: { file_path: testPngPath },
@@ -747,8 +791,8 @@ describe('Image Analyzer Hook', () => {
expect(body.model).toBe('gpt-5.1-codex-mini'); // Model from provider_models
});
it('should skip when provider is not in provider_models', () => {
const result = invokeHook(
it('should skip when provider is not in provider_models', async () => {
const result = await invokeHookAsync(
{
tool_name: 'Read',
tool_input: { file_path: testPngPath },
@@ -770,10 +814,10 @@ describe('Image Analyzer Hook', () => {
// ==========================================================================
describe('Output Format Validation', () => {
it('should output valid JSON structure on success', () => {
it('should output valid JSON structure on success', async () => {
resetMockState();
const result = invokeHook(
const result = await invokeHookAsync(
{
tool_name: 'Read',
tool_input: { file_path: testPngPath },
@@ -794,10 +838,10 @@ describe('Image Analyzer Hook', () => {
expect(output.hookSpecificOutput.permissionDecisionReason).toBeDefined();
});
it('should include filename in output', () => {
it('should include filename in output', async () => {
resetMockState();
const result = invokeHook(
const result = await invokeHookAsync(
{
tool_name: 'Read',
tool_input: { file_path: testPngPath },
@@ -809,10 +853,10 @@ describe('Image Analyzer Hook', () => {
expect(output.hookSpecificOutput.permissionDecisionReason).toContain('test-image.png');
});
it('should include model name in output', () => {
it('should include model name in output', async () => {
resetMockState();
const result = invokeHook(
const result = await invokeHookAsync(
{
tool_name: 'Read',
tool_input: { file_path: testPngPath },
@@ -826,7 +870,7 @@ describe('Image Analyzer Hook', () => {
);
});
it('should output valid JSON structure on file read error', () => {
it('should output valid JSON structure on file read error', async () => {
// Create and immediately delete file to trigger error
const errorPath = path.join(TEST_DIR, 'error-test.png');
createTestPng(errorPath);
@@ -834,7 +878,7 @@ describe('Image Analyzer Hook', () => {
// Make file unreadable (simulate permission error)
fs.chmodSync(errorPath, 0o000);
const result = invokeHook(
const result = await invokeHookAsync(
{
tool_name: 'Read',
tool_input: { file_path: errorPath },
@@ -171,15 +171,22 @@ describe('profile lifecycle service', () => {
fs.writeFileSync(path.join(ccsDir, 'config.json'), JSON.stringify({ profiles: {} }, null, 2) + '\n');
fs.writeFileSync(path.join(ccsDir, 'config.yaml'), 'version: 12\nwebsearch:\n enabled: false\n', 'utf8');
const copyFileSpy = spyOn(fs, 'copyFileSync').mockImplementation(() => {
throw new Error('copy should not run when WebSearch is disabled');
const originalCopyFileSync = fs.copyFileSync.bind(fs);
const copyFileSpy = spyOn(fs, 'copyFileSync').mockImplementation((source, destination) => {
const sourcePath = String(source);
const destinationPath = String(destination);
if (sourcePath.includes('websearch') || destinationPath.includes('websearch')) {
throw new Error('websearch copy should not run when WebSearch is disabled');
}
return originalCopyFileSync(source, destination);
});
const result = await runInScopedCcsDir(() => registerApiProfileOrphans({ names: ['extra'] }));
expect(copyFileSpy).not.toHaveBeenCalled();
expect(copyFileSpy).toHaveBeenCalled();
expect(result.registered).toEqual(['extra']);
expect(result.skipped).toEqual([]);
expect(fs.existsSync(path.join(ccsDir, 'hooks', 'websearch-transformer.cjs'))).toBe(false);
});
it('registers malformed orphan settings when force bypasses validation', async () => {
@@ -130,8 +130,14 @@ describe('profile-writer Anthropic direct', () => {
'utf8'
);
const copyFileSpy = spyOn(fs, 'copyFileSync').mockImplementation(() => {
throw new Error('copy should not run when WebSearch is disabled');
const originalCopyFileSync = fs.copyFileSync.bind(fs);
const copyFileSpy = spyOn(fs, 'copyFileSync').mockImplementation((source, destination) => {
const sourcePath = String(source);
const destinationPath = String(destination);
if (sourcePath.includes('websearch') || destinationPath.includes('websearch')) {
throw new Error('websearch copy should not run when WebSearch is disabled');
}
return originalCopyFileSync(source, destination);
});
const result = createApiProfile(
@@ -142,9 +148,12 @@ describe('profile-writer Anthropic direct', () => {
);
expect(result.success).toBe(true);
expect(copyFileSpy).not.toHaveBeenCalled();
expect(copyFileSpy).toHaveBeenCalled();
expect(fs.existsSync(path.join(tempHome, '.ccs', 'disabled-websearch.settings.json'))).toBe(
true
);
expect(fs.existsSync(path.join(tempHome, '.ccs', 'hooks', 'websearch-transformer.cjs'))).toBe(
false
);
});
});
@@ -258,6 +258,44 @@ describe('resolveCliproxyImageAnalysisEnv', () => {
expect(result.env.CCS_CURRENT_PROVIDER).toBe('agy');
expect(result.env.CCS_IMAGE_ANALYSIS_SKIP).toBe('0');
expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL).toBe('https://remote.example.com:9443');
expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_PATH).toBe('/api/provider/agy');
expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY).toBe('remote-token');
expect(result.warning).toBeNull();
});
it('pins local cliproxy image analysis to the resolved local API key', async () => {
const result = await resolveCliproxyImageAnalysisEnv(
{
profileName: 'orq',
provider: 'agy',
profileSettingsPath: '/tmp/orq.settings.json',
proxyTarget: {
host: '127.0.0.1',
port: 8317,
protocol: 'http',
isRemote: false,
},
proxyReachable: true,
},
{
getImageAnalysisHookEnv: () => ({
CCS_IMAGE_ANALYSIS_ENABLED: '1',
CCS_IMAGE_ANALYSIS_TIMEOUT: '60',
CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy:gemini-2.5-pro',
CCS_CURRENT_PROVIDER: 'agy',
CCS_IMAGE_ANALYSIS_SKIP: '0',
}),
hasImageAnalysisProfileHook: () => true,
hasImageAnalyzerHook: () => true,
resolveImageAnalysisRuntimeStatus: async () => createImageAnalysisStatus(),
getLocalRuntimeApiKey: () => 'local-runtime-token',
}
);
expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL).toBe('http://127.0.0.1:8317');
expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_PATH).toBe('/api/provider/agy');
expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY).toBe('local-runtime-token');
expect(result.warning).toBeNull();
});
});
@@ -136,11 +136,15 @@ describe('generateCopilotEnv', () => {
port: 8317,
};
},
getLocalRuntimeApiKey: () => 'local-runtime-token',
});
expect(ensureCalls).toBe(1);
expect(result.env.CCS_CURRENT_PROVIDER).toBe('ghcp');
expect(result.env.CCS_IMAGE_ANALYSIS_SKIP).toBe('0');
expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL).toBe('http://127.0.0.1:8317');
expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_PATH).toBe('/api/provider/ghcp');
expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY).toBe('local-runtime-token');
expect(result.warning).toBeNull();
});
});
@@ -0,0 +1,380 @@
import { afterEach, describe, expect, it } from 'bun:test';
import { spawn } from 'child_process';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import * as http from 'node:http';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const serverPath = join(process.cwd(), 'lib', 'mcp', 'ccs-image-analysis-server.cjs');
function encodeMessage(message: unknown): string {
return `${JSON.stringify(message)}\n`;
}
function collectResponses(
child: ReturnType<typeof spawn>,
expectedCount: number
): Promise<Array<Record<string, unknown>>> {
return new Promise((resolve, reject) => {
let buffer = Buffer.alloc(0);
const responses: Array<Record<string, unknown>> = [];
const timer = setTimeout(() => reject(new Error('Timed out waiting for MCP responses')), 5000);
function tryParse(): void {
while (true) {
const newlineIndex = buffer.indexOf('\n');
if (newlineIndex === -1) {
return;
}
const body = buffer.slice(0, newlineIndex).toString('utf8').replace(/\r$/, '').trim();
buffer = buffer.slice(newlineIndex + 1);
if (!body) {
continue;
}
responses.push(JSON.parse(body) as Record<string, unknown>);
if (responses.length >= expectedCount) {
clearTimeout(timer);
resolve(responses);
return;
}
}
}
child.stdout.on('data', (chunk: Buffer) => {
buffer = Buffer.concat([buffer, chunk]);
try {
tryParse();
} catch (error) {
clearTimeout(timer);
reject(error);
}
});
child.on('error', (error) => {
clearTimeout(timer);
reject(error);
});
});
}
function createTestPng(filePath: string): void {
const png = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44,
0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90,
0x77, 0x53, 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xf8,
0xcf, 0xc0, 0x00, 0x00, 0x01, 0x01, 0x01, 0x00, 0x18, 0xdd, 0x8d, 0xb4, 0x00, 0x00, 0x00,
0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
]);
writeFileSync(filePath, png);
}
function createTestPdf(filePath: string): void {
writeFileSync(filePath, '%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\n%%EOF\n', 'utf8');
}
describe('ccs-image-analysis MCP server', () => {
let tempDir = '';
let imagePath = '';
let mockServer: http.Server | null = null;
afterEach(async () => {
await new Promise<void>((resolve) => {
mockServer?.close(() => resolve());
if (!mockServer) resolve();
});
mockServer = null;
if (tempDir) {
rmSync(tempDir, { recursive: true, force: true });
tempDir = '';
imagePath = '';
}
});
it('lists the ImageAnalysis tool and posts directly to the provider-scoped CLIProxy route', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'ccs-image-analysis-mcp-server-'));
imagePath = join(tempDir, 'screen.png');
createTestPng(imagePath);
let receivedRequest: { path?: string; apiKey?: string; body?: unknown } | null = null;
const mockPort = await new Promise<number>((resolve, reject) => {
mockServer = http.createServer((req, res) => {
let body = '';
req.on('data', (chunk) => {
body += chunk.toString();
});
req.on('end', () => {
receivedRequest = {
path: req.url || '/',
apiKey: req.headers['x-api-key'] as string,
body: body ? JSON.parse(body) : null,
};
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
content: [{ type: 'text', text: 'The screenshot shows a red pixel debug fixture.' }],
})
);
});
});
mockServer.once('error', reject);
mockServer.listen(0, '127.0.0.1', () => {
const address = mockServer?.address();
if (!address || typeof address === 'string') {
reject(new Error('Failed to resolve mock server port'));
return;
}
resolve(address.port);
});
});
const child = spawn('node', [serverPath], {
env: {
...process.env,
CCS_IMAGE_ANALYSIS_ENABLED: '1',
CCS_IMAGE_ANALYSIS_SKIP: '0',
CCS_CURRENT_PROVIDER: 'agy',
CCS_IMAGE_ANALYSIS_MODEL: 'gemini-3-1-flash-preview',
CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL: `http://127.0.0.1:${mockPort}`,
CCS_IMAGE_ANALYSIS_RUNTIME_PATH: '/api/provider/agy',
CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY: 'test-api-key',
},
stdio: ['pipe', 'pipe', 'pipe'],
});
try {
const responsesPromise = collectResponses(child, 3);
child.stdin.write(
encodeMessage({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'bun-test', version: '1.0.0' },
},
})
);
child.stdin.write(encodeMessage({ jsonrpc: '2.0', id: 2, method: 'tools/list' }));
child.stdin.write(
encodeMessage({
jsonrpc: '2.0',
id: 3,
method: 'tools/call',
params: {
name: 'ImageAnalysis',
arguments: { filePath: imagePath, focus: 'Describe the visible issue' },
},
})
);
const responses = await responsesPromise;
const toolsList = responses.find((message) => message.id === 2);
const toolCall = responses.find((message) => message.id === 3);
expect(toolsList?.result).toEqual({
tools: [
{
name: 'ImageAnalysis',
description:
'Analyze a local image or PDF file with CCS provider-backed vision. Prefer this tool over Read for image and PDF paths. Use Read for text, code, and other plain files.',
inputSchema: {
type: 'object',
properties: {
filePath: {
type: 'string',
description:
'Absolute or workspace-relative path to a local image or PDF file to analyze.',
},
focus: {
type: 'string',
description:
'Optional question or area of focus, for example "explain the error dialog" or "transcribe the visible text".',
},
template: {
type: 'string',
enum: ['default', 'screenshot', 'document'],
description:
'Optional prompt template override. Use screenshot for UI captures, document for PDFs/docs, or default for general images.',
},
},
required: ['filePath'],
additionalProperties: false,
},
},
],
});
expect(receivedRequest?.path).toBe('/api/provider/agy/v1/messages');
expect(receivedRequest?.apiKey).toBe('test-api-key');
expect(
(((receivedRequest?.body as { messages: Array<{ content: Array<{ text?: string }> }> })
?.messages[0]?.content[0] || {}) as { text?: string }).text
).toContain('Specific focus');
expect(toolCall?.result).toBeDefined();
expect(
((toolCall?.result as { content: Array<{ text: string }> }).content[0] || {}).text
).toContain('red pixel debug fixture');
expect(
((toolCall?.result as { content: Array<{ text: string }> }).content[0] || {}).text
).toContain('Model: gemini-3-1-flash-preview');
} finally {
child.kill();
}
});
it('returns a structured tool error when the file does not exist', async () => {
const child = spawn('node', [serverPath], {
env: {
...process.env,
CCS_IMAGE_ANALYSIS_ENABLED: '1',
CCS_IMAGE_ANALYSIS_SKIP: '0',
CCS_CURRENT_PROVIDER: 'agy',
CCS_IMAGE_ANALYSIS_MODEL: 'gemini-3-1-flash-preview',
},
stdio: ['pipe', 'pipe', 'pipe'],
});
try {
const responsesPromise = collectResponses(child, 2);
child.stdin.write(
encodeMessage({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'bun-test', version: '1.0.0' },
},
})
);
child.stdin.write(
encodeMessage({
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: {
name: 'ImageAnalysis',
arguments: { filePath: join(tmpdir(), 'missing-image.png') },
},
})
);
const responses = await responsesPromise;
const toolCall = responses.find((message) => message.id === 2);
expect(toolCall?.result).toEqual({
content: [
{
type: 'text',
text: `ImageAnalysis could not find file: ${join(tmpdir(), 'missing-image.png')}`,
},
],
isError: true,
});
} finally {
child.kill();
}
});
it('sends PDF files as document blocks to the provider-scoped route', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'ccs-image-analysis-mcp-server-pdf-'));
const pdfPath = join(tempDir, 'manual.pdf');
createTestPdf(pdfPath);
let receivedRequest: { path?: string; apiKey?: string; body?: unknown } | null = null;
const mockPort = await new Promise<number>((resolve, reject) => {
mockServer = http.createServer((req, res) => {
let body = '';
req.on('data', (chunk) => {
body += chunk.toString();
});
req.on('end', () => {
receivedRequest = {
path: req.url || '/',
apiKey: req.headers['x-api-key'] as string,
body: body ? JSON.parse(body) : null,
};
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
content: [{ type: 'text', text: 'The PDF contains a one-page fixture.' }],
})
);
});
});
mockServer.once('error', reject);
mockServer.listen(0, '127.0.0.1', () => {
const address = mockServer?.address();
if (!address || typeof address === 'string') {
reject(new Error('Failed to resolve mock server port'));
return;
}
resolve(address.port);
});
});
const child = spawn('node', [serverPath], {
env: {
...process.env,
CCS_IMAGE_ANALYSIS_ENABLED: '1',
CCS_IMAGE_ANALYSIS_SKIP: '0',
CCS_CURRENT_PROVIDER: 'agy',
CCS_IMAGE_ANALYSIS_MODEL: 'gemini-3-1-flash-preview',
CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL: `http://127.0.0.1:${mockPort}`,
CCS_IMAGE_ANALYSIS_RUNTIME_PATH: '/api/provider/agy',
CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY: 'test-api-key',
},
stdio: ['pipe', 'pipe', 'pipe'],
});
try {
const responsesPromise = collectResponses(child, 2);
child.stdin.write(
encodeMessage({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'bun-test', version: '1.0.0' },
},
})
);
child.stdin.write(
encodeMessage({
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: {
name: 'ImageAnalysis',
arguments: { filePath: pdfPath },
},
})
);
const responses = await responsesPromise;
const requestBody = receivedRequest?.body as {
messages: Array<{ content: Array<{ type: string; source?: { media_type?: string } }> }>;
};
expect(receivedRequest?.path).toBe('/api/provider/agy/v1/messages');
expect(receivedRequest?.apiKey).toBe('test-api-key');
expect(requestBody.messages[0]?.content[1]?.type).toBe('document');
expect(requestBody.messages[0]?.content[1]?.source?.media_type).toBe('application/pdf');
expect(((responses[1]?.result as { content: Array<{ text: string }> }).content[0] || {}).text).toContain(
'one-page fixture'
);
} finally {
child.kill();
}
});
});
@@ -0,0 +1,198 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
const STEERING_PROMPT_SNIPPET = 'prefer the CCS MCP tool ImageAnalysis instead of Read';
interface RunResult {
status: number | null;
stdout: string;
stderr: string;
}
function runCcs(args: string[], env: NodeJS.ProcessEnv): RunResult {
const ccsEntry = path.join(process.cwd(), 'src', 'ccs.ts');
const result = spawnSync(process.execPath, [ccsEntry, ...args], {
encoding: 'utf8',
env,
timeout: 20000,
});
return {
status: result.status,
stdout: result.stdout || '',
stderr: result.stderr || '',
};
}
describe('settings profile ImageAnalysis launch', () => {
let tmpHome = '';
let ccsDir = '';
let settingsPath = '';
let fakeClaudePath = '';
let claudeArgsLogPath = '';
let claudeEnvLogPath = '';
let baseEnv: NodeJS.ProcessEnv;
beforeEach(() => {
if (process.platform === 'win32') {
return;
}
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-image-analysis-launch-'));
ccsDir = path.join(tmpHome, '.ccs');
settingsPath = path.join(ccsDir, 'glm.settings.json');
fakeClaudePath = path.join(tmpHome, 'fake-claude.sh');
claudeArgsLogPath = path.join(tmpHome, 'claude-args.txt');
claudeEnvLogPath = path.join(tmpHome, 'claude-env.txt');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'config.json'),
JSON.stringify({ profiles: { glm: settingsPath } }, null, 2) + '\n'
);
fs.writeFileSync(
path.join(ccsDir, 'config.yaml'),
[
'version: 12',
'websearch:',
' enabled: false',
'image_analysis:',
' enabled: true',
' timeout: 60',
' fallback_backend: agy',
' provider_models:',
' agy: gemini-3-1-flash-preview',
'cliproxy:',
' auth:',
' api_key: current-token',
'',
].join('\n'),
'utf8'
);
fs.writeFileSync(
settingsPath,
JSON.stringify(
{
env: {
ANTHROPIC_BASE_URL: 'https://api.z.ai/api/provider/agy',
ANTHROPIC_AUTH_TOKEN: 'stale-token',
ANTHROPIC_MODEL: 'glm-5',
},
},
null,
2
) + '\n'
);
fs.writeFileSync(
fakeClaudePath,
`#!/bin/sh
printf "%s\n" "$@" > "${claudeArgsLogPath}"
{
printf "runtimeApiKey=%s\n" "$CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY"
printf "runtimeBaseUrl=%s\n" "$CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL"
printf "runtimePath=%s\n" "$CCS_IMAGE_ANALYSIS_RUNTIME_PATH"
} > "${claudeEnvLogPath}"
exit 0
`,
{ encoding: 'utf8', mode: 0o755 }
);
fs.chmodSync(fakeClaudePath, 0o755);
baseEnv = {
...process.env,
CI: '1',
NO_COLOR: '1',
CCS_HOME: tmpHome,
CCS_CLAUDE_PATH: fakeClaudePath,
CCS_DEBUG: '1',
};
});
afterEach(() => {
if (process.platform === 'win32') {
return;
}
fs.rmSync(tmpHome, { recursive: true, force: true });
});
it('keeps launch non-fatal when the shared Read-hook fallback cannot be prepared', () => {
if (process.platform === 'win32') return;
fs.writeFileSync(path.join(ccsDir, 'hooks'), 'not-a-directory', 'utf8');
const result = runCcs(['glm', 'smoke'], baseEnv);
expect(result.status).toBe(0);
expect(result.stderr).not.toContain('could not prepare the local ImageAnalysis tool');
expect(fs.existsSync(claudeArgsLogPath)).toBe(true);
const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8');
expect(launchedArgs).toContain('--append-system-prompt');
expect(launchedArgs).toContain(STEERING_PROMPT_SNIPPET);
});
it('keeps launch non-fatal when Image Analysis is disabled', () => {
if (process.platform === 'win32') return;
fs.writeFileSync(
path.join(ccsDir, 'config.yaml'),
'version: 12\nwebsearch:\n enabled: false\nimage_analysis:\n enabled: false\n',
'utf8'
);
fs.writeFileSync(path.join(ccsDir, 'hooks'), 'not-a-directory', 'utf8');
const result = runCcs(['glm', 'smoke'], baseEnv);
expect(result.status).toBe(0);
expect(result.stderr).not.toContain('could not prepare the local ImageAnalysis tool');
expect(fs.existsSync(claudeArgsLogPath)).toBe(true);
const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8');
expect(launchedArgs).toContain('--append-system-prompt');
expect(launchedArgs).toContain(STEERING_PROMPT_SNIPPET);
});
it('pins bridge-backed image analysis to the current CLIProxy auth token', () => {
if (process.platform === 'win32') return;
const result = runCcs(['glm', 'smoke'], baseEnv);
expect(result.status).toBe(0);
expect(fs.existsSync(claudeEnvLogPath)).toBe(true);
const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8');
expect(launchedEnv).toContain('runtimeApiKey=current-token');
expect(launchedEnv).not.toContain('stale-token');
expect(launchedEnv).toContain('runtimePath=/api/provider/agy');
});
it('pins direct settings image analysis to the current local CLIProxy auth token', () => {
if (process.platform === 'win32') return;
fs.writeFileSync(
settingsPath,
JSON.stringify(
{
env: {
ANTHROPIC_BASE_URL: 'https://api.z.ai/v1',
ANTHROPIC_AUTH_TOKEN: 'stale-token',
ANTHROPIC_MODEL: 'glm-5',
},
},
null,
2
) + '\n'
);
const result = runCcs(['glm', 'smoke'], baseEnv);
expect(result.status).toBe(0);
expect(fs.existsSync(claudeEnvLogPath)).toBe(true);
const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8');
expect(launchedEnv).toContain('runtimeApiKey=current-token');
expect(launchedEnv).not.toContain('stale-token');
expect(launchedEnv).toContain('runtimePath=/api/provider/');
});
});
@@ -384,6 +384,41 @@ describe('CLAUDECODE environment stripping', () => {
});
});
it('headless executor prepares image-analysis MCP and compatibility hook fallback', 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 persistedSettings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as {
hooks?: { PreToolUse?: Array<{ matcher?: string }> };
};
expect(persistedSettings.hooks?.PreToolUse?.some((hook) => hook.matcher === 'Read')).toBe(true);
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(true);
expect(fs.existsSync(path.join(ccsDir, 'hooks', 'image-analysis-runtime.cjs'))).toBe(true);
});
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');
@@ -116,7 +116,7 @@ describe('image-analysis-runtime-status', () => {
expect(status.effectiveRuntimeReason).toContain('remote.example:443');
});
it('keeps hook-missing on native read even when auth and proxy are ready', async () => {
it('keeps hook-missing as a degraded fallback state while primary runtime stays active', async () => {
const status = await hydrateImageAnalysisRuntimeStatus(
createStatus({
status: 'hook-missing',
@@ -144,7 +144,7 @@ describe('image-analysis-runtime-status', () => {
expect(status.authReadiness).toBe('ready');
expect(status.proxyReadiness).toBe('ready');
expect(status.effectiveRuntimeMode).toBe('native-read');
expect(status.effectiveRuntimeMode).toBe('cliproxy-image-analysis');
expect(status.effectiveRuntimeReason).toContain('Profile hook is missing');
});
});
@@ -0,0 +1,64 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
hasImageAnalyzerHook,
installImageAnalyzerHook,
} from '../../../../src/utils/hooks/image-analyzer-hook-installer';
import { prepareImageAnalysisFallbackHook } from '../../../../src/utils/hooks';
describe('image-analyzer-hook-installer', () => {
let tempHome = '';
let originalCcsHome: string | undefined;
let runtimePath = '';
let bundledRuntimePath = '';
beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-image-analyzer-hook-'));
originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tempHome;
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'config.yaml'),
'version: 12\nimage_analysis:\n enabled: true\n',
'utf8'
);
runtimePath = path.join(ccsDir, 'hooks', 'image-analysis-runtime.cjs');
bundledRuntimePath = path.join(process.cwd(), 'lib', 'hooks', 'image-analysis-runtime.cjs');
});
afterEach(() => {
if (originalCcsHome === undefined) {
delete process.env.CCS_HOME;
} else {
process.env.CCS_HOME = originalCcsHome;
}
if (tempHome) {
fs.rmSync(tempHome, { recursive: true, force: true });
}
});
it('treats a missing runtime artifact as not ready and repairs it on prepare', () => {
expect(installImageAnalyzerHook()).toBe(true);
fs.unlinkSync(runtimePath);
expect(hasImageAnalyzerHook()).toBe(false);
expect(prepareImageAnalysisFallbackHook()).toBe(true);
expect(fs.existsSync(runtimePath)).toBe(true);
expect(hasImageAnalyzerHook()).toBe(true);
});
it('refreshes stale runtime content during fallback hook preparation', () => {
expect(installImageAnalyzerHook()).toBe(true);
fs.writeFileSync(runtimePath, 'stale runtime\n', 'utf8');
expect(hasImageAnalyzerHook()).toBe(false);
expect(prepareImageAnalysisFallbackHook()).toBe(true);
expect(fs.readFileSync(runtimePath, 'utf8')).toBe(fs.readFileSync(bundledRuntimePath, 'utf8'));
});
});
@@ -67,4 +67,23 @@ describe('image-analyzer-profile-hook-injector', () => {
expect(fs.existsSync(defaultSettingsPath)).toBe(false);
expect(persisted.hooks?.PreToolUse?.some((hook) => hook.matcher === 'Read')).toBe(true);
});
it('skips copilot hook persistence when the shared fallback hook is unavailable', () => {
const settingsPath = path.join(tempHome, '.ccs', 'copilot.settings.json');
writeJson(settingsPath, {});
const ensured = ensureProfileHooks({
profileName: 'copilot',
profileType: 'copilot',
settingsPath,
sharedHookInstalled: false,
});
const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as {
hooks?: { PreToolUse?: Array<{ matcher?: string }> };
};
expect(ensured).toBe(false);
expect(persisted.hooks).toBeUndefined();
});
});
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'bun:test';
import {
appendThirdPartyImageAnalysisToolArgs,
getImageAnalysisSteeringPrompt,
} from '../../../../src/utils/image-analysis';
describe('appendThirdPartyImageAnalysisToolArgs', () => {
it('appends the steering prompt for image analysis', () => {
const args = appendThirdPartyImageAnalysisToolArgs(['-p', 'describe the screenshot']);
expect(args).toEqual(['-p', 'describe the screenshot', '--append-system-prompt', getImageAnalysisSteeringPrompt()]);
});
it('does not duplicate the steering prompt when already present', () => {
const steeringPrompt = getImageAnalysisSteeringPrompt();
const args = appendThirdPartyImageAnalysisToolArgs([
'-p',
'describe the screenshot',
'--append-system-prompt',
steeringPrompt,
]);
expect(args.filter((arg) => arg === steeringPrompt)).toHaveLength(1);
expect(args.filter((arg) => arg === '--append-system-prompt')).toHaveLength(1);
});
it('preserves trailing arguments after --', () => {
const args = appendThirdPartyImageAnalysisToolArgs(['-p', 'describe', '--', 'extra']);
expect(args).toEqual([
'-p',
'describe',
'--append-system-prompt',
getImageAnalysisSteeringPrompt(),
'--',
'extra',
]);
});
});
@@ -0,0 +1,180 @@
import { afterEach, describe, expect, it, mock } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
ensureImageAnalysisMcp,
getImageAnalysisMcpRuntimePath,
getImageAnalysisMcpServerName,
getImageAnalysisMcpServerPath,
uninstallImageAnalysisMcp,
} from '../../../../src/utils/image-analysis';
describe('ensureImageAnalysisMcp', () => {
let tempHome: string | undefined;
let originalCcsHome: string | undefined;
function setupTempHome(): string {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-image-analysis-mcp-'));
originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tempHome;
return tempHome;
}
function getCcsDir(): string {
if (!tempHome) {
throw new Error('tempHome not initialized');
}
return path.join(tempHome, '.ccs');
}
function writeEnabledConfig(): void {
const ccsDir = getCcsDir();
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'config.yaml'),
[
'version: 12',
'image_analysis:',
' enabled: true',
' timeout: 60',
' fallback_backend: agy',
' provider_models:',
' agy: gemini-3-1-flash-preview',
'',
].join('\n'),
'utf8'
);
}
function getManagedConfig() {
return {
type: 'stdio',
command: 'node',
args: [getImageAnalysisMcpServerPath()],
env: {},
};
}
afterEach(() => {
mock.restore();
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
if (tempHome && fs.existsSync(tempHome)) {
fs.rmSync(tempHome, { recursive: true, force: true });
}
tempHome = undefined;
originalCcsHome = undefined;
});
it('installs the MCP server and preserves existing user mcpServers entries', () => {
setupTempHome();
writeEnabledConfig();
const claudeUserConfigPath = path.join(tempHome as string, '.claude.json');
fs.writeFileSync(
claudeUserConfigPath,
JSON.stringify(
{
mcpServers: {
existing: { command: 'uvx', args: ['some-server'] },
},
},
null,
2
) + '\n',
'utf8'
);
expect(ensureImageAnalysisMcp()).toBe(true);
expect(fs.existsSync(getImageAnalysisMcpServerPath())).toBe(true);
expect(fs.existsSync(getImageAnalysisMcpRuntimePath())).toBe(true);
const config = JSON.parse(fs.readFileSync(claudeUserConfigPath, 'utf8')) as {
mcpServers: Record<string, unknown>;
};
expect(config.mcpServers.existing).toEqual({ command: 'uvx', args: ['some-server'] });
expect(config.mcpServers[getImageAnalysisMcpServerName()]).toEqual(getManagedConfig());
});
it('removes the managed MCP runtime while preserving unrelated server entries', () => {
setupTempHome();
writeEnabledConfig();
const claudeUserConfigPath = path.join(tempHome as string, '.claude.json');
fs.writeFileSync(
claudeUserConfigPath,
JSON.stringify(
{
mcpServers: {
existing: { command: 'uvx', args: ['some-server'] },
},
},
null,
2
) + '\n',
'utf8'
);
expect(ensureImageAnalysisMcp()).toBe(true);
const instancePath = path.join(tempHome as string, '.ccs', 'instances', 'work');
fs.mkdirSync(instancePath, { recursive: true });
fs.writeFileSync(
path.join(instancePath, '.claude.json'),
JSON.stringify(
{
mcpServers: {
existing: { command: 'uvx', args: ['instance-server'] },
[getImageAnalysisMcpServerName()]: { command: 'node', args: ['/tmp/override.cjs'] },
},
otherKey: 'keep-me',
},
null,
2
) + '\n',
'utf8'
);
expect(uninstallImageAnalysisMcp()).toBe(true);
expect(fs.existsSync(getImageAnalysisMcpServerPath())).toBe(false);
expect(fs.existsSync(getImageAnalysisMcpRuntimePath())).toBe(false);
const globalConfig = JSON.parse(fs.readFileSync(claudeUserConfigPath, 'utf8')) as {
mcpServers: Record<string, { command: string; args: string[] }>;
};
expect(globalConfig.mcpServers).toEqual({
existing: { command: 'uvx', args: ['some-server'] },
});
const instanceConfig = JSON.parse(
fs.readFileSync(path.join(instancePath, '.claude.json'), 'utf8')
) as {
otherKey: string;
mcpServers: Record<string, { command: string; args: string[] }>;
};
expect(instanceConfig.otherKey).toBe('keep-me');
expect(instanceConfig.mcpServers).toEqual({
existing: { command: 'uvx', args: ['instance-server'] },
});
});
it('installs the first-class MCP runtime even when the legacy hooks path is unusable', () => {
setupTempHome();
writeEnabledConfig();
const hooksPath = path.join(getCcsDir(), 'hooks');
fs.writeFileSync(hooksPath, 'not-a-directory', 'utf8');
expect(ensureImageAnalysisMcp()).toBe(true);
expect(fs.existsSync(getImageAnalysisMcpServerPath())).toBe(true);
expect(fs.existsSync(getImageAnalysisMcpRuntimePath())).toBe(true);
});
});
@@ -171,7 +171,7 @@ describe('image-analysis routes', () => {
expect.objectContaining({
name: 'glm',
target: 'claude',
currentTargetMode: 'setup',
currentTargetMode: 'fallback',
}),
expect.objectContaining({
name: 'codexProfile',
@@ -12,9 +12,17 @@ function writeJson(filePath: string, value: Record<string, unknown>): void {
}
function installSharedHook(tempHome: string): string {
const hookPath = path.join(tempHome, '.ccs', 'hooks', 'image-analyzer-transformer.cjs');
fs.mkdirSync(path.dirname(hookPath), { recursive: true });
fs.writeFileSync(hookPath, '#!/usr/bin/env node\n', 'utf8');
const hooksDir = path.join(tempHome, '.ccs', 'hooks');
fs.mkdirSync(hooksDir, { recursive: true });
for (const fileName of ['image-analyzer-transformer.cjs', 'image-analysis-runtime.cjs']) {
fs.copyFileSync(
path.join(process.cwd(), 'lib', 'hooks', fileName),
path.join(hooksDir, fileName)
);
}
const hookPath = path.join(hooksDir, 'image-analyzer-transformer.cjs');
return hookPath;
}