mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 02:15:36 +00:00
feat(image-analysis): make MCP-first provisioning self-healing
This commit is contained in:
1 parent
15ef3ac9a8
commit
64ecd8b3dc
19 files changed
+818
-136
No files matched your search
@@ -0,0 +1,126 @@
|
||||
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 { getImageAnalysisConfig } from '../../../../src/config/unified-config-loader';
|
||||
import { fixImageAnalysisConfig } from '../../../../src/management/checks/image-analysis-check';
|
||||
|
||||
describe('image-analysis-check', () => {
|
||||
let tempHome = '';
|
||||
let originalCcsHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-image-analysis-check-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(path.join(ccsDir, 'instances', 'demo'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
[
|
||||
'version: 12',
|
||||
'image_analysis:',
|
||||
' enabled: true',
|
||||
' timeout: 5',
|
||||
' provider_models: {}',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'glm.settings.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'https://proxy.example/api/provider/gemini',
|
||||
ANTHROPIC_AUTH_TOKEN: 'glm-token',
|
||||
},
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: 'Read',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'node "/home/kai/.ccs/hooks/image-analyzer-transformer.cjs"',
|
||||
timeout: 65000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + '\n'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'instances', 'demo', '.claude.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
custom: {
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: ['custom-server.cjs'],
|
||||
env: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + '\n'
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome === undefined) {
|
||||
delete process.env.CCS_HOME;
|
||||
} else {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
}
|
||||
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('repairs invalid config, removes stale hooks, and syncs managed MCP entries into instances', async () => {
|
||||
const fixed = await fixImageAnalysisConfig();
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
|
||||
expect(fixed).toBe(true);
|
||||
|
||||
const config = getImageAnalysisConfig();
|
||||
expect(config.timeout).toBe(60);
|
||||
expect(Object.keys(config.provider_models).length).toBeGreaterThan(0);
|
||||
|
||||
const repairedSettings = JSON.parse(
|
||||
fs.readFileSync(path.join(ccsDir, 'glm.settings.json'), 'utf8')
|
||||
) as {
|
||||
hooks?: { PreToolUse?: Array<{ matcher?: string }> };
|
||||
};
|
||||
expect(repairedSettings.hooks?.PreToolUse?.some((hook) => hook.matcher === 'Read') ?? false).toBe(
|
||||
false
|
||||
);
|
||||
|
||||
const globalClaudeConfig = JSON.parse(
|
||||
fs.readFileSync(path.join(tempHome, '.claude.json'), 'utf8')
|
||||
) as {
|
||||
mcpServers?: Record<string, unknown>;
|
||||
};
|
||||
expect(globalClaudeConfig.mcpServers?.['ccs-image-analysis']).toBeDefined();
|
||||
|
||||
const instanceClaudeConfig = JSON.parse(
|
||||
fs.readFileSync(path.join(ccsDir, 'instances', 'demo', '.claude.json'), 'utf8')
|
||||
) as {
|
||||
mcpServers?: Record<string, unknown>;
|
||||
};
|
||||
expect(instanceClaudeConfig.mcpServers?.custom).toEqual({
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: ['custom-server.cjs'],
|
||||
env: {},
|
||||
});
|
||||
expect(instanceClaudeConfig.mcpServers?.['ccs-image-analysis']).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -92,6 +92,9 @@ describe('settings profile ImageAnalysis launch', () => {
|
||||
`#!/bin/sh
|
||||
printf "%s\n" "$@" > "${claudeArgsLogPath}"
|
||||
{
|
||||
printf "currentProvider=%s\n" "$CCS_CURRENT_PROVIDER"
|
||||
printf "skip=%s\n" "$CCS_IMAGE_ANALYSIS_SKIP"
|
||||
printf "skipHook=%s\n" "$CCS_IMAGE_ANALYSIS_SKIP_HOOK"
|
||||
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"
|
||||
@@ -167,6 +170,73 @@ exit 0
|
||||
expect(launchedArgs).not.toContain(STEERING_PROMPT_SNIPPET);
|
||||
});
|
||||
|
||||
it('suppresses stale CCS image hooks during a healthy MCP-first launch', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
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',
|
||||
},
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: 'Read',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'node "/home/kai/.ccs/hooks/image-analyzer-transformer.cjs"',
|
||||
timeout: 65000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + '\n'
|
||||
);
|
||||
|
||||
const result = runCcs(['glm', 'smoke'], baseEnv);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8');
|
||||
const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8');
|
||||
const persistedSettings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as {
|
||||
hooks?: { PreToolUse?: Array<{ matcher?: string }> };
|
||||
};
|
||||
|
||||
expect(launchedArgs).toContain(STEERING_PROMPT_SNIPPET);
|
||||
expect(launchedEnv).toContain('skipHook=1');
|
||||
expect(
|
||||
persistedSettings.hooks?.PreToolUse?.some((hook) => hook.matcher === 'Read') ?? false
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the legacy hook available when MCP provisioning fails', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
fs.writeFileSync(path.join(tmpHome, '.claude.json'), '{not-json', 'utf8');
|
||||
|
||||
const result = runCcs(['glm', 'smoke'], baseEnv);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8');
|
||||
const persistedSettings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as {
|
||||
hooks?: { PreToolUse?: Array<{ matcher?: string }> };
|
||||
};
|
||||
|
||||
expect(launchedEnv).not.toContain('skipHook=1');
|
||||
expect(persistedSettings.hooks?.PreToolUse?.some((hook) => hook.matcher === 'Read')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('pins bridge-backed image analysis to the current CLIProxy auth token', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
|
||||
@@ -384,7 +384,7 @@ describe('CLAUDECODE environment stripping', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('headless executor prepares image-analysis MCP and compatibility hook fallback', async () => {
|
||||
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');
|
||||
@@ -398,11 +398,16 @@ describe('CLAUDECODE environment stripping', () => {
|
||||
|
||||
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')).toBe(true);
|
||||
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')
|
||||
@@ -415,8 +420,10 @@ describe('CLAUDECODE environment stripping', () => {
|
||||
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);
|
||||
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 () => {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
deduplicateCcsImageAnalyzerHooks,
|
||||
isCcsImageAnalyzerHook,
|
||||
removeCcsImageAnalyzerHooks,
|
||||
} from '../../../../src/utils/hooks/image-analyzer-hook-utils';
|
||||
|
||||
describe('image-analyzer-hook-utils', () => {
|
||||
it('detects CCS-managed image hooks across current and legacy path variants', () => {
|
||||
expect(
|
||||
isCcsImageAnalyzerHook({
|
||||
matcher: 'Read',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'node "/Users/kaitran/.ccs/hooks/image-analyzer-transformer.cjs"',
|
||||
},
|
||||
],
|
||||
})
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isCcsImageAnalyzerHook({
|
||||
matcher: 'Read',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'node "/home/kai/.ccs/hooks/image-analyzer-transformer.cjs"',
|
||||
},
|
||||
],
|
||||
})
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isCcsImageAnalyzerHook({
|
||||
matcher: 'Read',
|
||||
hooks: [{ type: 'command', command: 'node "/tmp/custom-read-hook.cjs"' }],
|
||||
})
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
isCcsImageAnalyzerHook({
|
||||
matcher: 'Read',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'node "/Users/kaitran/.ccs/hooks/image-analyzer-transformer-custom.cjs"',
|
||||
},
|
||||
],
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('deduplicates only CCS-managed image hooks', () => {
|
||||
const settings = {
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: 'Read',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'node "/Users/kaitran/.ccs/hooks/image-analyzer-transformer.cjs"',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
matcher: 'Read',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'node "/home/kai/.ccs/hooks/image-analyzer-transformer.cjs"',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
matcher: 'Read',
|
||||
hooks: [{ type: 'command', command: 'node "/tmp/custom-read-hook.cjs"' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
} satisfies Record<string, unknown>;
|
||||
|
||||
expect(deduplicateCcsImageAnalyzerHooks(settings)).toBe(true);
|
||||
expect((settings.hooks.PreToolUse as unknown[])).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('removes only CCS-managed image hooks and preserves unrelated hooks', () => {
|
||||
const settings = {
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: 'Read',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'node "/Users/kaitran/.ccs/hooks/image-analyzer-transformer.cjs"',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
matcher: 'Read',
|
||||
hooks: [{ type: 'command', command: 'node "/tmp/custom-read-hook.cjs"' }],
|
||||
},
|
||||
{
|
||||
matcher: 'WebSearch',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'node "/Users/kaitran/.ccs/hooks/websearch-transformer.cjs"',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
} satisfies Record<string, unknown>;
|
||||
|
||||
expect(removeCcsImageAnalyzerHooks(settings)).toBe(true);
|
||||
expect(settings.hooks.PreToolUse).toEqual([
|
||||
{
|
||||
matcher: 'Read',
|
||||
hooks: [{ type: 'command', command: 'node "/tmp/custom-read-hook.cjs"' }],
|
||||
},
|
||||
{
|
||||
matcher: 'WebSearch',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'node "/Users/kaitran/.ccs/hooks/websearch-transformer.cjs"',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -192,6 +192,54 @@ describe('image-analysis routes', () => {
|
||||
});
|
||||
|
||||
it('updates the saved config through the dashboard route and provisions the local runtime', async () => {
|
||||
const staleSettingsPath = path.join(tempHome, '.ccs', 'legacy.settings.json');
|
||||
fs.writeFileSync(
|
||||
staleSettingsPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'https://proxy.example/api/provider/gemini',
|
||||
ANTHROPIC_AUTH_TOKEN: 'legacy-token',
|
||||
},
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: 'Read',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'node "/home/kai/.ccs/hooks/image-analyzer-transformer.cjs"',
|
||||
timeout: 65000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
const instanceDir = path.join(tempHome, '.ccs', 'instances', 'demo');
|
||||
fs.mkdirSync(instanceDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(instanceDir, '.claude.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
custom: {
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: ['custom-server.cjs'],
|
||||
env: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/image-analysis`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -245,6 +293,31 @@ describe('image-analysis routes', () => {
|
||||
args: [path.join(ccsDir, 'mcp', 'ccs-image-analysis-server.cjs')],
|
||||
env: {},
|
||||
});
|
||||
|
||||
const repairedSettings = JSON.parse(fs.readFileSync(staleSettingsPath, 'utf8')) as {
|
||||
hooks?: { PreToolUse?: Array<{ matcher?: string }> };
|
||||
};
|
||||
expect(repairedSettings.hooks?.PreToolUse?.some((hook) => hook.matcher === 'Read') ?? false).toBe(
|
||||
false
|
||||
);
|
||||
|
||||
const instanceConfig = JSON.parse(
|
||||
fs.readFileSync(path.join(instanceDir, '.claude.json'), 'utf8')
|
||||
) as {
|
||||
mcpServers?: Record<string, unknown>;
|
||||
};
|
||||
expect(instanceConfig.mcpServers?.custom).toEqual({
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: ['custom-server.cjs'],
|
||||
env: {},
|
||||
});
|
||||
expect(instanceConfig.mcpServers?.['ccs-image-analysis']).toEqual({
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: [path.join(ccsDir, 'mcp', 'ccs-image-analysis-server.cjs')],
|
||||
env: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects profile mappings that point to a missing backend with a client error', async () => {
|
||||
|
||||
Reference in new issue
Block a user