@kennyzhao/cgm 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DEPLOY-Linux.md +75 -0
- package/LICENSE +21 -0
- package/README.en.md +220 -0
- package/README.md +220 -2
- package/cli.mjs +68 -0
- package/install.cmd +5 -0
- package/install.ps1 +55 -0
- package/install.sh +66 -0
- package/mcp-config.json +8 -0
- package/mcp.mjs +487 -0
- package/package.json +35 -4
- package/public/img/add-provider.png +0 -0
- package/public/img/index.png +0 -0
- package/public/img/repo-show.png +0 -0
- package/public/img/setting.png +0 -0
- package/public/index.html +892 -0
- package/server.mjs +1144 -0
- package/verify-mcp.mjs +207 -0
package/verify-mcp.mjs
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// verify-mcp.mjs — MCP 协议合规 + 鉴权/安全回归脚本(ad-hoc,CI 可用)
|
|
2
|
+
//
|
|
3
|
+
// 用法:node verify-mcp.mjs (默认自起临时实例,端口 8872;临时 DATA_DIR,不碰真实数据)
|
|
4
|
+
// 覆盖三部分:
|
|
5
|
+
// A. MCP 协议合规(默认实例,回环免鉴权):initialize/serverInfo、notifications 202、ping result{}、
|
|
6
|
+
// tools/list、CORS 预检 204 + Allow-Headers 含 MCP-Protocol-Version。
|
|
7
|
+
// B. 鉴权与安全(独立实例 LOCAL_NO_AUTH=0 + ADMIN_TOKEN):未授权 401、Bearer 200、
|
|
8
|
+
// settings/providers token 脱敏且 PUT 掩码不覆盖、双 SSE 客户端隔离、请求体超限 413、
|
|
9
|
+
// 跨源 Origin 401 且不回 ACAO。
|
|
10
|
+
// C. 默认令牌与并发互斥:HOST=0.0.0.0 无 env → 首启自动生成令牌(auth.json)正常启动;
|
|
11
|
+
// 同仓 analyze+remove 并发 → 仓锁串行,不交叉、注册表不损坏。
|
|
12
|
+
// 不依赖 codegraph(search_code 只测参数校验路径;并发测试用本地 git fixture,codegraph 缺失时 analyze 快速失败,不影响断言)。
|
|
13
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
14
|
+
import { promises as fs } from 'node:fs';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
18
|
+
|
|
19
|
+
const ROOT = path.join(os.tmpdir(), 'cgm-verify-' + Date.now());
|
|
20
|
+
const PORT = parseInt(process.env.VERIFY_PORT || '8872', 10);
|
|
21
|
+
const PORT_AUTH = PORT + 1, PORT_EXPOSED = PORT + 2;
|
|
22
|
+
let fail = 0;
|
|
23
|
+
const check = (n, c, x = '') => { console.log(`${c ? 'PASS' : 'FAIL'} | ${n}${x ? ' | ' + x : ''}`); if (!c) fail++; };
|
|
24
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
25
|
+
|
|
26
|
+
function startServer(env) {
|
|
27
|
+
const c = spawn(process.execPath, [path.resolve('server.mjs')], { cwd: path.resolve('.'), env: { ...process.env, ...env } });
|
|
28
|
+
c.stdout.on('data', () => {}); c.stderr.on('data', () => {});
|
|
29
|
+
return c;
|
|
30
|
+
}
|
|
31
|
+
async function waitReady(base) {
|
|
32
|
+
for (let i = 0; i < 50; i++) {
|
|
33
|
+
try { const r = await fetch(base + '/mcp', { method: 'OPTIONS' }); if (r.status < 500) return true; } catch {}
|
|
34
|
+
await sleep(200);
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
const postJson = (url, obj, headers = {}) => fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(obj) });
|
|
39
|
+
// SSE 收集器:连接 /mcp 并持续累积流文本,stop() 断开
|
|
40
|
+
async function sseCollect(base) {
|
|
41
|
+
const ctrl = new AbortController();
|
|
42
|
+
const res = await fetch(base + '/mcp', { signal: ctrl.signal });
|
|
43
|
+
let text = '';
|
|
44
|
+
(async () => { try { const dec = new TextDecoder(); const reader = res.body.getReader(); while (true) { const { done, value } = await reader.read(); if (done) break; text += dec.decode(value, { stream: true }); } } catch {} })();
|
|
45
|
+
return { stop: () => ctrl.abort(), text: () => text };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function main() {
|
|
49
|
+
/* ---------- 实例 A:默认回环免鉴权(协议 + 脱敏 + SSE 隔离 + 413 + 并发互斥) ---------- */
|
|
50
|
+
const DATA = path.join(ROOT, 'data');
|
|
51
|
+
const sA = startServer({ PORT: String(PORT), DATA_DIR: DATA });
|
|
52
|
+
const B = `http://127.0.0.1:${PORT}/mcp`;
|
|
53
|
+
const H = `http://127.0.0.1:${PORT}`;
|
|
54
|
+
check('服务启动并监听 /mcp', await waitReady(B));
|
|
55
|
+
|
|
56
|
+
// 1) CORS 预检(浏览器直连 Inspector 的关键)
|
|
57
|
+
const opt = await fetch(B, {
|
|
58
|
+
method: 'OPTIONS',
|
|
59
|
+
headers: { Origin: `http://localhost:${PORT}`, 'Access-Control-Request-Method': 'POST', 'Access-Control-Request-Headers': 'content-type,mcp-protocol-version,authorization' },
|
|
60
|
+
});
|
|
61
|
+
check('OPTIONS 预检 204', opt.status === 204, 'status=' + opt.status);
|
|
62
|
+
check('CORS Allow-Origin *', opt.headers.get('access-control-allow-origin') === '*');
|
|
63
|
+
check('CORS Allow-Headers 含 MCP-Protocol-Version', /mcp-protocol-version/i.test(opt.headers.get('access-control-allow-headers') || ''));
|
|
64
|
+
|
|
65
|
+
// 2) 握手
|
|
66
|
+
const init = await postJson(B, { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'verify', version: '1' } } });
|
|
67
|
+
const initBody = await init.json();
|
|
68
|
+
check('initialize → 200', init.status === 200);
|
|
69
|
+
check('initialize 含 serverInfo', initBody.result?.serverInfo?.name === 'codegraph-manager');
|
|
70
|
+
check('initialize 协议版本 2024-11-05', initBody.result?.protocolVersion === '2024-11-05');
|
|
71
|
+
|
|
72
|
+
// 3) 通知(规范:202,非404)
|
|
73
|
+
const notif = await postJson(B, { jsonrpc: '2.0', method: 'notifications/initialized' });
|
|
74
|
+
check('notifications/initialized → 202', notif.status === 202, 'status=' + notif.status);
|
|
75
|
+
|
|
76
|
+
// 4) 心跳(规范:result{}, 非404)
|
|
77
|
+
const ping = await postJson(B, { jsonrpc: '2.0', id: 2, method: 'ping' });
|
|
78
|
+
const pingBody = await ping.json();
|
|
79
|
+
check('ping → 200', ping.status === 200);
|
|
80
|
+
check('ping → result:{}', JSON.stringify(pingBody.result) === '{}');
|
|
81
|
+
|
|
82
|
+
// 5) 工具(search_code 只测参数校验路径,不 spawn codegraph)
|
|
83
|
+
const tools = await postJson(B, { jsonrpc: '2.0', id: 3, method: 'tools/list', params: {} });
|
|
84
|
+
const toolsBody = await tools.json();
|
|
85
|
+
const names = (toolsBody.result?.tools || []).map((t) => t.name);
|
|
86
|
+
check('tools/list → 4 工具', names.length === 4, names.join(','));
|
|
87
|
+
['query_repository', 'list_repositories', 'search_repositories', 'search_code'].forEach((t) => check('含工具 ' + t, names.includes(t)));
|
|
88
|
+
|
|
89
|
+
const call = await postJson(B, { jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'search_code', arguments: {} } });
|
|
90
|
+
const callBody = await call.json();
|
|
91
|
+
check('search_code 无 query → 友好提示', (callBody.result?.content?.[0]?.text || '').includes('请提供检索语句'));
|
|
92
|
+
check('search_code 缺参 → isError', callBody.result?.isError === true);
|
|
93
|
+
|
|
94
|
+
// 6) 跨源请求拒绝:非同源 Origin → 401 且不回 Access-Control-Allow-Origin
|
|
95
|
+
const xorigin = await fetch(H + '/api/repos', { headers: { Origin: 'http://evil.example.com' } });
|
|
96
|
+
check('跨源 Origin → 401', xorigin.status === 401, 'status=' + xorigin.status);
|
|
97
|
+
check('跨源响应不回 ACAO', !xorigin.headers.get('access-control-allow-origin'));
|
|
98
|
+
|
|
99
|
+
// 7) settings/providers token 脱敏 + PUT 掩码不覆盖
|
|
100
|
+
const SECRET = 'ghp_verify_secret_abcd1234';
|
|
101
|
+
const addP = await postJson(H + '/api/providers', { type: 'github', name: 'verify-gh', token: SECRET });
|
|
102
|
+
check('添加 provider → 201', addP.status === 201, 'status=' + addP.status);
|
|
103
|
+
const pid = (await addP.json()).id;
|
|
104
|
+
const sDto = await (await fetch(H + '/api/settings')).json();
|
|
105
|
+
check('GET /api/settings 不含 token 明文', !JSON.stringify(sDto).includes(SECRET));
|
|
106
|
+
check('GET /api/settings 掩码+末4位', (sDto.providers || []).some((p) => String(p.token).endsWith('1234') && String(p.token).includes('••••')));
|
|
107
|
+
const provs = await (await fetch(H + '/api/providers')).json();
|
|
108
|
+
check('GET /api/providers token → ***', provs.find((p) => p.id === pid)?.token === '***');
|
|
109
|
+
await fetch(H + '/api/providers/' + pid, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'renamed', token: '••••1234' }) });
|
|
110
|
+
const raw = new DatabaseSync(path.join(DATA, 'cgm.db')).prepare("SELECT data FROM kv WHERE k='settings'").get().data;
|
|
111
|
+
check('PUT 掩码不覆盖真实 token(落盘仍加密非掩码)', !raw.includes('••••1234') && !raw.includes(SECRET) && raw.includes('"enc:'));
|
|
112
|
+
|
|
113
|
+
// 8) 双 SSE 客户端隔离:工具结果只经 HTTP 返回发起方,SSE 流不出现 event: message
|
|
114
|
+
const sseA = await sseCollect(H), sseB = await sseCollect(H);
|
|
115
|
+
await sleep(300);
|
|
116
|
+
const tc = await postJson(B, { jsonrpc: '2.0', id: 10, method: 'tools/call', params: { name: 'list_repositories', arguments: {} } });
|
|
117
|
+
check('tools/call(list_repositories) → 200', tc.status === 200);
|
|
118
|
+
await sleep(800); sseA.stop(); sseB.stop();
|
|
119
|
+
check('SSE-A 未收到 event: message', !sseA.text().includes('event: message'));
|
|
120
|
+
check('SSE-B 未收到 event: message', !sseB.text().includes('event: message'));
|
|
121
|
+
|
|
122
|
+
// 9) 请求体超限 → 413
|
|
123
|
+
const big = await postJson(B, { jsonrpc: '2.0', id: 11, method: 'tools/list', params: {}, pad: 'x'.repeat(1024 * 1024 + 1024) });
|
|
124
|
+
check('超限请求体 → 413', big.status === 413, 'status=' + big.status);
|
|
125
|
+
|
|
126
|
+
// 10) 同仓 analyze+remove 并发 → 仓锁串行,注册表最终一致
|
|
127
|
+
const fx = path.join(ROOT, 'fixture-repo');
|
|
128
|
+
await fs.mkdir(fx, { recursive: true });
|
|
129
|
+
const g = (args) => spawnSync('git', args, { cwd: fx });
|
|
130
|
+
g(['init', '-b', 'main']);
|
|
131
|
+
await fs.writeFile(path.join(fx, 'a.js'), 'const a=1;\n');
|
|
132
|
+
g(['add', '-A']); g(['-c', 'user.name=v', '-c', 'user.email=v@v', 'commit', '-m', 'init']);
|
|
133
|
+
const addR = await postJson(H + '/api/repos/local', { path: fx, name: 'verify-fixture' });
|
|
134
|
+
const fid = (await addR.json()).id;
|
|
135
|
+
check('注册本地 fixture 仓库 → 201', addR.status === 201, 'status=' + addR.status);
|
|
136
|
+
const [op1, op2] = await Promise.all([
|
|
137
|
+
fetch(`${H}/api/repos/${fid}/analyze`, { method: 'POST' }),
|
|
138
|
+
fetch(`${H}/api/repos/${fid}/remove`, { method: 'POST' }),
|
|
139
|
+
]);
|
|
140
|
+
check('并发 analyze/remove 均受理 → 200', op1.status === 200 && op2.status === 200);
|
|
141
|
+
let removed = false;
|
|
142
|
+
for (let i = 0; i < 80 && !removed; i++) { const list = await (await fetch(H + '/api/repos')).json(); removed = !list.some((r) => r.id === fid); if (!removed) await sleep(300); }
|
|
143
|
+
check('并发操作后仓库最终被移除(串行完成,无交叉卡死)', removed);
|
|
144
|
+
const hz = await (await fetch(H + '/healthz')).json();
|
|
145
|
+
check('并发操作后 healthz 正常', hz.ok === true);
|
|
146
|
+
let jsonOk = true, rowCount = -1;
|
|
147
|
+
try {
|
|
148
|
+
const rows = new DatabaseSync(path.join(DATA, 'cgm.db')).prepare('SELECT data FROM repos').all();
|
|
149
|
+
rowCount = rows.length;
|
|
150
|
+
for (const r of rows) JSON.parse(r.data); // 每行载荷可解析 = 注册表未损坏
|
|
151
|
+
} catch { jsonOk = false; }
|
|
152
|
+
check('SQLite repos 表未损坏(并发操作后行数一致且可解析)', jsonOk && rowCount === 0, 'rows=' + rowCount);
|
|
153
|
+
sA.kill('SIGTERM');
|
|
154
|
+
|
|
155
|
+
/* ---------- 实例 B:LOCAL_NO_AUTH=0 + ADMIN_TOKEN(鉴权强制) ---------- */
|
|
156
|
+
const TOKEN = 'verify-admin-token-xyz';
|
|
157
|
+
const sB = startServer({ PORT: String(PORT_AUTH), DATA_DIR: path.join(ROOT, 'data-auth'), LOCAL_NO_AUTH: '0', ADMIN_TOKEN: TOKEN });
|
|
158
|
+
const BA = `http://127.0.0.1:${PORT_AUTH}`;
|
|
159
|
+
check('鉴权实例启动', await waitReady(BA + '/mcp'));
|
|
160
|
+
const a401 = await postJson(BA + '/mcp', { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} });
|
|
161
|
+
check('无 token tools/list → 401', a401.status === 401, 'status=' + a401.status);
|
|
162
|
+
const api401 = await fetch(BA + '/api/repos');
|
|
163
|
+
check('无 token /api/repos → 401', api401.status === 401, 'status=' + api401.status);
|
|
164
|
+
const auth = { Authorization: 'Bearer ' + TOKEN };
|
|
165
|
+
const a200 = await postJson(BA + '/mcp', { jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }, auth);
|
|
166
|
+
check('带 Bearer tools/list → 200', a200.status === 200, 'status=' + a200.status);
|
|
167
|
+
const aInit = await postJson(BA + '/mcp', { jsonrpc: '2.0', id: 3, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {} } }, auth);
|
|
168
|
+
check('带 Bearer initialize → 200 + serverInfo', aInit.status === 200 && (await aInit.json()).result?.serverInfo?.name === 'codegraph-manager');
|
|
169
|
+
const aNotif = await postJson(BA + '/mcp', { jsonrpc: '2.0', method: 'notifications/initialized' }, auth);
|
|
170
|
+
check('带 Bearer notifications → 202', aNotif.status === 202, 'status=' + aNotif.status);
|
|
171
|
+
const api200 = await fetch(BA + '/api/repos', { headers: auth });
|
|
172
|
+
check('带 Bearer /api/repos → 200', api200.status === 200, 'status=' + api200.status);
|
|
173
|
+
sB.kill('SIGTERM');
|
|
174
|
+
|
|
175
|
+
/* ---------- 实例 C:HOST=0.0.0.0 无 env → 自动生成令牌,正常启动 ---------- */
|
|
176
|
+
const DATA3 = path.join(ROOT, 'data-exposed');
|
|
177
|
+
const sC = startServer({ PORT: String(PORT_EXPOSED), DATA_DIR: DATA3, HOST: '0.0.0.0' });
|
|
178
|
+
check('对外监听(无 env)可启动(自动令牌兜底)', await waitReady(`http://127.0.0.1:${PORT_EXPOSED}/mcp`));
|
|
179
|
+
let authJson = null; try { authJson = JSON.parse(await fs.readFile(path.join(DATA3, 'auth.json'), 'utf8')); } catch {}
|
|
180
|
+
check('auth.json 已生成随机令牌', typeof authJson?.adminToken === 'string' && authJson.adminToken.length >= 24);
|
|
181
|
+
check('对外实例 healthz → 200', (await fetch(`http://127.0.0.1:${PORT_EXPOSED}/healthz`)).status === 200);
|
|
182
|
+
sC.kill('SIGTERM');
|
|
183
|
+
|
|
184
|
+
/* ---------- 实例 D:旧 settings.json/repos.json 自动迁移 → SQLite ---------- */
|
|
185
|
+
const DATA4 = path.join(ROOT, 'data-migrate');
|
|
186
|
+
await fs.mkdir(DATA4, { recursive: true });
|
|
187
|
+
await fs.writeFile(path.join(DATA4, 'repos.json'), JSON.stringify([{ id: 'seed-1', name: 'seed-repo', status: 'registered', worktrees: [], spaceIds: [] }]));
|
|
188
|
+
await fs.writeFile(path.join(DATA4, 'settings.json'), JSON.stringify({ providers: [{ id: 'p1', type: 'github', name: 'gh', url: '', token: 'plain-token-xyz' }], cloneBaseDir: '' }));
|
|
189
|
+
const sD = startServer({ PORT: String(PORT_EXPOSED + 1), DATA_DIR: DATA4 });
|
|
190
|
+
const B4 = `http://127.0.0.1:${PORT_EXPOSED + 1}`;
|
|
191
|
+
check('迁移实例启动', await waitReady(B4 + '/mcp'));
|
|
192
|
+
const list4 = await (await fetch(B4 + '/api/repos')).json();
|
|
193
|
+
check('旧 repos.json 迁移:仓库在列', list4.some((r) => r.id === 'seed-1'), `n=${list4.length}`);
|
|
194
|
+
const st4 = await (await fetch(B4 + '/api/settings')).json();
|
|
195
|
+
check('旧 settings.json 迁移:provider 在列且已加密', st4.providers?.some((p) => p.id === 'p1'));
|
|
196
|
+
check('迁移后 API 不泄露旧明文 token', !JSON.stringify(st4).includes('plain-token-xyz'));
|
|
197
|
+
const db4 = new DatabaseSync(path.join(DATA4, 'cgm.db'));
|
|
198
|
+
check('SQLite repos 表 1 行', db4.prepare('SELECT COUNT(*) AS n FROM repos').get().n === 1);
|
|
199
|
+
check('旧 JSON 已改名 .migrated-* 保留', !!(await fs.readdir(DATA4)).find((f) => f.startsWith('repos.json.migrated-')) && !!(await fs.readdir(DATA4)).find((f) => f.startsWith('settings.json.migrated-')));
|
|
200
|
+
sD.kill('SIGTERM');
|
|
201
|
+
|
|
202
|
+
console.log(`\n==== verify-mcp:${fail === 0 ? 'ALL PASS ✅' : fail + ' FAIL ❌'} ====`);
|
|
203
|
+
process.exit(fail === 0 ? 0 : 1);
|
|
204
|
+
}
|
|
205
|
+
main().catch(async (e) => { console.error('VERIFY ERROR', e.message); process.exit(1); }).finally(async () => {
|
|
206
|
+
try { await fs.rm(ROOT, { recursive: true, force: true }); } catch {}
|
|
207
|
+
});
|