mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 20:17:03 +00:00
fix(image-analysis): harden runtime and provisioning
This commit is contained in:
1 parent
6d1c895721
commit
77b488f8b1
21 files changed
+811
-169
No files matched your search
@@ -261,6 +261,7 @@ describe('resolveCliproxyImageAnalysisEnv', () => {
|
||||
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.env.CCS_IMAGE_ANALYSIS_RUNTIME_ALLOW_SELF_SIGNED).toBe('1');
|
||||
expect(result.warning).toBeNull();
|
||||
});
|
||||
|
||||
@@ -296,6 +297,45 @@ describe('resolveCliproxyImageAnalysisEnv', () => {
|
||||
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.env.CCS_IMAGE_ANALYSIS_RUNTIME_ALLOW_SELF_SIGNED).toBe('0');
|
||||
expect(result.warning).toBeNull();
|
||||
});
|
||||
|
||||
it('routes remote HTTPS image analysis through the local tunnel when available', async () => {
|
||||
const result = await resolveCliproxyImageAnalysisEnv(
|
||||
{
|
||||
profileName: 'orq',
|
||||
provider: 'agy',
|
||||
profileSettingsPath: '/tmp/orq.settings.json',
|
||||
proxyTarget: {
|
||||
host: 'remote.example.com',
|
||||
port: 9443,
|
||||
protocol: 'https',
|
||||
authToken: 'remote-token',
|
||||
managementKey: 'remote-management-key',
|
||||
allowSelfSigned: true,
|
||||
isRemote: true,
|
||||
},
|
||||
tunnelPort: 9911,
|
||||
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(),
|
||||
}
|
||||
);
|
||||
|
||||
expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL).toBe('http://127.0.0.1:9911');
|
||||
expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY).toBe('remote-token');
|
||||
expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_ALLOW_SELF_SIGNED).toBe('0');
|
||||
expect(result.warning).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, describe, expect, it } from 'bun:test';
|
||||
import { spawn } from 'child_process';
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import * as http from 'node:http';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
@@ -11,6 +11,11 @@ function encodeMessage(message: unknown): string {
|
||||
return `${JSON.stringify(message)}\n`;
|
||||
}
|
||||
|
||||
function encodeLegacyMessage(message: unknown): string {
|
||||
const body = JSON.stringify(message);
|
||||
return `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`;
|
||||
}
|
||||
|
||||
function collectResponses(
|
||||
child: ReturnType<typeof spawn>,
|
||||
expectedCount: number
|
||||
@@ -133,6 +138,7 @@ describe('ccs-image-analysis MCP server', () => {
|
||||
});
|
||||
|
||||
const child = spawn('node', [serverPath], {
|
||||
cwd: tempDir,
|
||||
env: {
|
||||
...process.env,
|
||||
CCS_IMAGE_ANALYSIS_ENABLED: '1',
|
||||
@@ -189,7 +195,7 @@ describe('ccs-image-analysis MCP server', () => {
|
||||
filePath: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Absolute or workspace-relative path to a local image or PDF file to analyze.',
|
||||
'Workspace-relative path, or an absolute path inside the current workspace, to a local image or PDF file to analyze.',
|
||||
},
|
||||
focus: {
|
||||
type: 'string',
|
||||
@@ -228,7 +234,10 @@ describe('ccs-image-analysis MCP server', () => {
|
||||
});
|
||||
|
||||
it('returns a structured tool error when the file does not exist', async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'ccs-image-analysis-mcp-server-missing-'));
|
||||
const missingPath = join(tempDir, 'missing-image.png');
|
||||
const child = spawn('node', [serverPath], {
|
||||
cwd: tempDir,
|
||||
env: {
|
||||
...process.env,
|
||||
CCS_IMAGE_ANALYSIS_ENABLED: '1',
|
||||
@@ -260,7 +269,7 @@ describe('ccs-image-analysis MCP server', () => {
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'ImageAnalysis',
|
||||
arguments: { filePath: join(tmpdir(), 'missing-image.png') },
|
||||
arguments: { filePath: missingPath },
|
||||
},
|
||||
})
|
||||
);
|
||||
@@ -272,7 +281,7 @@ describe('ccs-image-analysis MCP server', () => {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `ImageAnalysis could not find file: ${join(tmpdir(), 'missing-image.png')}`,
|
||||
text: `ImageAnalysis could not find file: ${missingPath}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
@@ -282,6 +291,62 @@ describe('ccs-image-analysis MCP server', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects paths outside the current workspace', async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'ccs-image-analysis-mcp-server-scope-'));
|
||||
const workspaceDir = join(tempDir, 'workspace');
|
||||
mkdirSync(workspaceDir, { recursive: true });
|
||||
const outsidePath = join(tempDir, 'outside.png');
|
||||
createTestPng(outsidePath);
|
||||
|
||||
const child = spawn('node', [serverPath], {
|
||||
cwd: workspaceDir,
|
||||
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: '../outside.png' },
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const responses = await responsesPromise;
|
||||
const toolCall = responses.find((message) => message.id === 2);
|
||||
expect(toolCall?.error).toEqual({
|
||||
code: -32602,
|
||||
message: 'ImageAnalysis only allows files inside the current workspace.',
|
||||
});
|
||||
} 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');
|
||||
@@ -322,6 +387,7 @@ describe('ccs-image-analysis MCP server', () => {
|
||||
});
|
||||
|
||||
const child = spawn('node', [serverPath], {
|
||||
cwd: tempDir,
|
||||
env: {
|
||||
...process.env,
|
||||
CCS_IMAGE_ANALYSIS_ENABLED: '1',
|
||||
@@ -377,4 +443,39 @@ describe('ccs-image-analysis MCP server', () => {
|
||||
child.kill();
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts legacy Content-Length framed requests for compatibility', async () => {
|
||||
const child = spawn('node', [serverPath], {
|
||||
env: {
|
||||
...process.env,
|
||||
CCS_IMAGE_ANALYSIS_ENABLED: '1',
|
||||
CCS_IMAGE_ANALYSIS_SKIP: '1',
|
||||
CCS_CURRENT_PROVIDER: 'agy',
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
try {
|
||||
const responsesPromise = collectResponses(child, 2);
|
||||
child.stdin.write(
|
||||
encodeLegacyMessage({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'bun-test', version: '1.0.0' },
|
||||
},
|
||||
})
|
||||
);
|
||||
child.stdin.write(encodeLegacyMessage({ jsonrpc: '2.0', id: 2, method: 'tools/list' }));
|
||||
|
||||
const responses = await responsesPromise;
|
||||
const toolsList = responses.find((message) => message.id === 2);
|
||||
expect(toolsList?.result).toEqual({ tools: [] });
|
||||
} finally {
|
||||
child.kill();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -151,8 +151,20 @@ exit 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);
|
||||
expect(launchedArgs).not.toContain(STEERING_PROMPT_SNIPPET);
|
||||
});
|
||||
|
||||
it('falls back to native Read when the ImageAnalysis MCP runtime cannot be provisioned', () => {
|
||||
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);
|
||||
expect(result.stderr).toContain('could not prepare the local ImageAnalysis tool');
|
||||
const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8');
|
||||
expect(launchedArgs).not.toContain(STEERING_PROMPT_SNIPPET);
|
||||
});
|
||||
|
||||
it('pins bridge-backed image analysis to the current CLIProxy auth token', () => {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { resolveImageAnalysisRuntimeConnection } from '../../../../src/utils/hooks';
|
||||
|
||||
describe('resolveImageAnalysisRuntimeConnection', () => {
|
||||
it('returns a direct local runtime connection for local CLIProxy targets', () => {
|
||||
const connection = resolveImageAnalysisRuntimeConnection({
|
||||
proxyTarget: {
|
||||
host: '127.0.0.1',
|
||||
port: 8317,
|
||||
protocol: 'http',
|
||||
isRemote: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(connection.baseUrl).toBe('http://127.0.0.1:8317');
|
||||
expect(connection.allowSelfSigned).toBe(false);
|
||||
expect(connection.proxyTarget.isRemote).toBe(false);
|
||||
});
|
||||
|
||||
it('returns the remote runtime base URL and TLS flag for self-signed targets', () => {
|
||||
const connection = resolveImageAnalysisRuntimeConnection({
|
||||
proxyTarget: {
|
||||
host: 'remote.example.com',
|
||||
port: 9443,
|
||||
protocol: 'https',
|
||||
authToken: 'remote-token',
|
||||
allowSelfSigned: true,
|
||||
isRemote: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(connection.baseUrl).toBe('https://remote.example.com:9443');
|
||||
expect(connection.apiKey).toBe('remote-token');
|
||||
expect(connection.allowSelfSigned).toBe(true);
|
||||
expect(connection.proxyTarget.isRemote).toBe(true);
|
||||
});
|
||||
|
||||
it('prefers the local tunnel when one is active for a remote target', () => {
|
||||
const connection = resolveImageAnalysisRuntimeConnection({
|
||||
proxyTarget: {
|
||||
host: 'remote.example.com',
|
||||
port: 9443,
|
||||
protocol: 'https',
|
||||
authToken: 'remote-token',
|
||||
allowSelfSigned: true,
|
||||
isRemote: true,
|
||||
},
|
||||
tunnelPort: 9911,
|
||||
});
|
||||
|
||||
expect(connection.baseUrl).toBe('http://127.0.0.1:9911');
|
||||
expect(connection.apiKey).toBe('remote-token');
|
||||
expect(connection.allowSelfSigned).toBe(false);
|
||||
expect(connection.proxyTarget).toMatchObject({
|
||||
host: '127.0.0.1',
|
||||
port: 9911,
|
||||
protocol: 'http',
|
||||
isRemote: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,7 @@ function createStatus(overrides: Partial<ImageAnalysisStatus> = {}): ImageAnalys
|
||||
describe('image-analysis-runtime-status', () => {
|
||||
it('falls back to native read when provider auth is missing', async () => {
|
||||
const status = await hydrateImageAnalysisRuntimeStatus(createStatus(), {
|
||||
checkRemoteProxy: async () => ({ reachable: false, error: 'not-used' }),
|
||||
getProxyTarget: () => ({
|
||||
host: '127.0.0.1',
|
||||
port: 8317,
|
||||
@@ -63,6 +64,7 @@ describe('image-analysis-runtime-status', () => {
|
||||
|
||||
it('marks an idle local proxy as launchable when auth is ready', async () => {
|
||||
const status = await hydrateImageAnalysisRuntimeStatus(createStatus(), {
|
||||
checkRemoteProxy: async () => ({ reachable: false, error: 'not-used' }),
|
||||
getProxyTarget: () => ({
|
||||
host: '127.0.0.1',
|
||||
port: 8317,
|
||||
@@ -107,7 +109,10 @@ describe('image-analysis-runtime-status', () => {
|
||||
source: 'remote',
|
||||
},
|
||||
],
|
||||
isCliproxyRunning: async () => false,
|
||||
checkRemoteProxy: async () => ({
|
||||
reachable: false,
|
||||
error: 'Remote CLIProxy target remote.example:443 is unreachable.',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(status.authReadiness).toBe('ready');
|
||||
@@ -123,6 +128,7 @@ describe('image-analysis-runtime-status', () => {
|
||||
reason: 'Profile hook is missing from the persisted settings file.',
|
||||
}),
|
||||
{
|
||||
checkRemoteProxy: async () => ({ reachable: false, error: 'not-used' }),
|
||||
getProxyTarget: () => ({
|
||||
host: '127.0.0.1',
|
||||
port: 8317,
|
||||
@@ -147,4 +153,38 @@ describe('image-analysis-runtime-status', () => {
|
||||
expect(status.effectiveRuntimeMode).toBe('cliproxy-image-analysis');
|
||||
expect(status.effectiveRuntimeReason).toContain('Profile hook is missing');
|
||||
});
|
||||
|
||||
it('marks a reachable remote proxy as remote instead of inspecting local 8317', async () => {
|
||||
const status = await hydrateImageAnalysisRuntimeStatus(createStatus(), {
|
||||
getProxyTarget: () => ({
|
||||
host: 'remote.example',
|
||||
port: 443,
|
||||
protocol: 'https',
|
||||
authToken: 'token',
|
||||
managementKey: 'secret',
|
||||
allowSelfSigned: true,
|
||||
isRemote: true,
|
||||
}),
|
||||
fetchRemoteAuthStatus: async () => [
|
||||
{
|
||||
provider: 'ghcp',
|
||||
displayName: 'GitHub Copilot (OAuth)',
|
||||
authenticated: true,
|
||||
tokenFiles: 1,
|
||||
accounts: [],
|
||||
defaultAccount: null,
|
||||
source: 'remote',
|
||||
},
|
||||
],
|
||||
checkRemoteProxy: async () => ({
|
||||
reachable: true,
|
||||
latencyMs: 42,
|
||||
}),
|
||||
isCliproxyRunning: async () => false,
|
||||
});
|
||||
|
||||
expect(status.proxyReadiness).toBe('remote');
|
||||
expect(status.proxyReason).toContain('remote.example:443');
|
||||
expect(status.effectiveRuntimeMode).toBe('cliproxy-image-analysis');
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,14 @@
|
||||
import { afterEach, describe, expect, it, mock } from 'bun:test';
|
||||
import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import * as lockfile from 'proper-lockfile';
|
||||
import {
|
||||
ensureImageAnalysisMcp,
|
||||
getImageAnalysisMcpRuntimePath,
|
||||
getImageAnalysisMcpServerName,
|
||||
getImageAnalysisMcpServerPath,
|
||||
hasImageAnalysisMcpReady,
|
||||
uninstallImageAnalysisMcp,
|
||||
} from '../../../../src/utils/image-analysis';
|
||||
|
||||
@@ -102,6 +104,7 @@ describe('ensureImageAnalysisMcp', () => {
|
||||
|
||||
expect(config.mcpServers.existing).toEqual({ command: 'uvx', args: ['some-server'] });
|
||||
expect(config.mcpServers[getImageAnalysisMcpServerName()]).toEqual(getManagedConfig());
|
||||
expect(hasImageAnalysisMcpReady(claudeUserConfigPath)).toBe(true);
|
||||
});
|
||||
|
||||
it('removes the managed MCP runtime while preserving unrelated server entries', () => {
|
||||
@@ -177,4 +180,14 @@ describe('ensureImageAnalysisMcp', () => {
|
||||
expect(fs.existsSync(getImageAnalysisMcpServerPath())).toBe(true);
|
||||
expect(fs.existsSync(getImageAnalysisMcpRuntimePath())).toBe(true);
|
||||
});
|
||||
|
||||
it('serializes ~/.claude.json updates with a file lock', () => {
|
||||
setupTempHome();
|
||||
writeEnabledConfig();
|
||||
|
||||
const lockSpy = spyOn(lockfile, 'lockSync');
|
||||
|
||||
expect(ensureImageAnalysisMcp()).toBe(true);
|
||||
expect(lockSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -171,7 +171,7 @@ describe('image-analysis routes', () => {
|
||||
expect.objectContaining({
|
||||
name: 'glm',
|
||||
target: 'claude',
|
||||
currentTargetMode: 'fallback',
|
||||
currentTargetMode: 'setup',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: 'codexProfile',
|
||||
@@ -185,9 +185,13 @@ describe('image-analysis routes', () => {
|
||||
backendCount: 2,
|
||||
bypassedProfileCount: 1,
|
||||
});
|
||||
expect(payload.runtime).toMatchObject({
|
||||
managedToolReady: false,
|
||||
sharedHookInstalled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('updates the saved config through the dashboard route', async () => {
|
||||
it('updates the saved config through the dashboard route and provisions the local runtime', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/image-analysis`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -219,6 +223,28 @@ describe('image-analysis routes', () => {
|
||||
expect(payload.config.providerModels).toMatchObject({
|
||||
gemini: 'gemini-2.5-pro',
|
||||
});
|
||||
expect(payload.runtime).toMatchObject({
|
||||
managedToolReady: true,
|
||||
sharedHookInstalled: true,
|
||||
});
|
||||
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
expect(fs.existsSync(path.join(ccsDir, 'mcp', 'ccs-image-analysis-server.cjs'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(ccsDir, 'mcp', 'image-analysis-runtime.cjs'))).toBe(true);
|
||||
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);
|
||||
|
||||
const claudeConfig = JSON.parse(
|
||||
fs.readFileSync(path.join(tempHome, '.claude.json'), 'utf8')
|
||||
) as {
|
||||
mcpServers?: Record<string, unknown>;
|
||||
};
|
||||
expect(claudeConfig.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