ai-helper-agent 1.10.0 → 1.12.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/bin/agent.js +122 -0
- package/package.json +3 -1
- package/src/auth.js +45 -12
- package/src/executor.js +60 -20
- package/src/mcp/client.js +0 -9
- package/src/mcp/registry.js +11 -9
- package/src/search.js +23 -10
- package/src/security.js +3 -0
- package/src/server.js +776 -102
- package/src/skill/loader.js +1 -9
- package/src/skill/markdown-loader.js +164 -49
- package/src/skill/registry.js +3 -2
- package/src/trash.js +211 -0
package/bin/agent.js
CHANGED
|
@@ -399,6 +399,128 @@ if (command === 'start') {
|
|
|
399
399
|
console.log(`[Agent] 工作目录: ${cfg.workdir}`);
|
|
400
400
|
restartServer();
|
|
401
401
|
|
|
402
|
+
// ==================== _restart-helper(内部命令,两阶段重启包装器) ====================
|
|
403
|
+
} else if (command === '_restart-helper') {
|
|
404
|
+
// 由 /api/agent/restart 和 /api/agent/update 调用,detached 运行
|
|
405
|
+
// 职责:等待老进程退出 + 端口释放后,再以 --background 模式启动新进程
|
|
406
|
+
// 解决直接 spawn 新进程时 isRunning 检查劝退、端口 EADDRINUSE 的时序问题
|
|
407
|
+
const helperConfig = applyCliArgs({ port: 18910, host: '127.0.0.1' }, process.argv.slice(3));
|
|
408
|
+
const oldPidIdx = process.argv.indexOf('--old-pid');
|
|
409
|
+
const oldPid = oldPidIdx > -1 ? parseInt(process.argv[oldPidIdx + 1], 10) : 0;
|
|
410
|
+
const scriptIdx = process.argv.indexOf('--script');
|
|
411
|
+
const agentScript = scriptIdx > -1 ? process.argv[scriptIdx + 1] : process.argv[1];
|
|
412
|
+
|
|
413
|
+
const port = helperConfig.port;
|
|
414
|
+
const host = helperConfig.host;
|
|
415
|
+
const workdir = helperConfig.workdir || process.cwd();
|
|
416
|
+
|
|
417
|
+
const log = (msg) => console.log(`[Agent Restart Helper] ${msg}`);
|
|
418
|
+
log(`等待老进程 ${oldPid} 退出...`);
|
|
419
|
+
|
|
420
|
+
// 1. 等待老进程退出(轮询 PID 探活,process.kill(pid,0) 不发信号只检测存在性)
|
|
421
|
+
const waitForOldExit = (pid, timeoutMs = 15000) => new Promise((resolve) => {
|
|
422
|
+
if (!pid) return resolve(true);
|
|
423
|
+
const start = Date.now();
|
|
424
|
+
const timer = setInterval(() => {
|
|
425
|
+
try {
|
|
426
|
+
process.kill(pid, 0);
|
|
427
|
+
} catch {
|
|
428
|
+
clearInterval(timer);
|
|
429
|
+
resolve(true);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
if (Date.now() - start > timeoutMs) {
|
|
433
|
+
clearInterval(timer);
|
|
434
|
+
log('等待老进程退出超时,尝试强制终止...');
|
|
435
|
+
try { process.kill(pid, 'SIGKILL'); } catch {}
|
|
436
|
+
resolve(false);
|
|
437
|
+
}
|
|
438
|
+
}, 200);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
// 2. 等待端口释放(fetch /api/status 失败即视为释放)
|
|
442
|
+
const waitForPortRelease = async (host, port, timeoutMs = 10000) => {
|
|
443
|
+
const start = Date.now();
|
|
444
|
+
while (Date.now() - start < timeoutMs) {
|
|
445
|
+
try {
|
|
446
|
+
const resp = await fetch(`http://${host}:${port}/api/status`);
|
|
447
|
+
if (resp.ok) {
|
|
448
|
+
await new Promise(r => setTimeout(r, 300));
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
} catch {
|
|
452
|
+
return true;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return false;
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
(async () => {
|
|
459
|
+
await waitForOldExit(oldPid);
|
|
460
|
+
await waitForPortRelease(host, port);
|
|
461
|
+
// 额外等待,确保 socket 完全回收
|
|
462
|
+
await new Promise(r => setTimeout(r, 500));
|
|
463
|
+
|
|
464
|
+
log('老进程已退出,端口已释放,启动新进程...');
|
|
465
|
+
|
|
466
|
+
// 清理旧 PID 文件,避免新进程 isRunning 检查误判
|
|
467
|
+
removePidFile();
|
|
468
|
+
ensureAgentDir();
|
|
469
|
+
|
|
470
|
+
// 直接以前台模式启动服务进程(不经过 --background 中间层)
|
|
471
|
+
// 原因:--background 会多 spawn 一层中间进程,中间进程退出后其 PID 会被
|
|
472
|
+
// 本 helper 覆盖写入 PID 文件,导致 stop/restart 读到已死的中间进程 PID。
|
|
473
|
+
// 前台模式下 child.pid 就是服务进程 PID,与服务进程自身写入的 PID 一致。
|
|
474
|
+
// 前台 start 不会 isRunning 检查,但本 helper 已确保老进程退出 + 端口释放。
|
|
475
|
+
const child = spawn(process.execPath, [
|
|
476
|
+
...process.execArgv,
|
|
477
|
+
agentScript,
|
|
478
|
+
'start',
|
|
479
|
+
'--port', String(port),
|
|
480
|
+
'--host', host,
|
|
481
|
+
'--workdir', workdir
|
|
482
|
+
], {
|
|
483
|
+
detached: true,
|
|
484
|
+
stdio: 'ignore',
|
|
485
|
+
cwd: process.cwd(),
|
|
486
|
+
env: { ...process.env },
|
|
487
|
+
windowsHide: true
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
let spawnFailed = false;
|
|
491
|
+
child.on('error', (err) => {
|
|
492
|
+
spawnFailed = true;
|
|
493
|
+
log(`启动失败: ${err.message}`);
|
|
494
|
+
process.exit(1);
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
498
|
+
|
|
499
|
+
// 健康检查:确认服务进程已成功监听端口,避免写入已崩溃进程的 PID
|
|
500
|
+
// (若端口意外未释放导致 EADDRINUSE,服务进程会崩溃,此时不应写错误 PID)
|
|
501
|
+
let healthy = false;
|
|
502
|
+
if (!spawnFailed && child.pid) {
|
|
503
|
+
try {
|
|
504
|
+
const resp = await fetch(`http://${host}:${port}/api/status`);
|
|
505
|
+
healthy = resp.ok;
|
|
506
|
+
} catch {}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (healthy && child.pid) {
|
|
510
|
+
try {
|
|
511
|
+
writeFileSync(PID_FILE, String(child.pid));
|
|
512
|
+
} catch (err) {
|
|
513
|
+
log(`PID 文件写入失败: ${err.message}`);
|
|
514
|
+
}
|
|
515
|
+
child.unref();
|
|
516
|
+
log(`新进程已启动 (PID: ${child.pid})`);
|
|
517
|
+
} else if (!spawnFailed && child.pid) {
|
|
518
|
+
log(`警告:新进程健康检查失败,未更新 PID 文件(可能端口仍被占用或进程已崩溃)`);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
process.exit(spawnFailed ? 1 : 0);
|
|
522
|
+
})();
|
|
523
|
+
|
|
402
524
|
// ==================== status ====================
|
|
403
525
|
} else if (command === 'status') {
|
|
404
526
|
const config = readAgentConfig();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-helper-agent",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.0",
|
|
4
4
|
"description": "AI Helper 代理服务 - 为 Chrome 扩展提供本地文件读写和系统命令执行能力",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/server.js",
|
|
@@ -21,7 +21,9 @@
|
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
24
|
+
"archiver": "^8.0.0",
|
|
24
25
|
"ws": "^8.18.0",
|
|
26
|
+
"xlsx": "^0.18.5",
|
|
25
27
|
"zod": "^4.4.3"
|
|
26
28
|
},
|
|
27
29
|
"engines": {
|
package/src/auth.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import crypto from 'crypto';
|
|
3
3
|
import { loadConfig, loadPairings, savePairing } from './config.js';
|
|
4
4
|
|
|
5
|
-
//
|
|
5
|
+
// 当前有效的配对码(定时刷新 + 一次性使用)
|
|
6
6
|
let currentPairCode = null;
|
|
7
7
|
let pairCodeTimer = null;
|
|
8
8
|
|
|
@@ -12,11 +12,26 @@ let lastAuthTime = 0;
|
|
|
12
12
|
// 判定"活跃"的时间窗口:此时间内有过认证则认为仍有扩展连接
|
|
13
13
|
const ACTIVE_WINDOW_MS = 5 * 60 * 1000; // 5 分钟
|
|
14
14
|
|
|
15
|
+
// 配对码字符表:排除易混淆字符(0/O、1/I/l);6 位字母数字(≈21亿种,抗暴破)
|
|
16
|
+
const PAIR_CODE_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
|
17
|
+
const PAIR_CODE_LEN = 6;
|
|
18
|
+
|
|
19
|
+
// 配对失败锁定:连续失败 PAIR_FAIL_LIMIT 次后锁定 PAIR_LOCK_DURATION_MS 毫秒
|
|
20
|
+
let pairFailCount = 0;
|
|
21
|
+
let pairLockUntil = 0;
|
|
22
|
+
const PAIR_FAIL_LIMIT = 5;
|
|
23
|
+
const PAIR_LOCK_DURATION_MS = 60 * 1000; // 60 秒
|
|
24
|
+
|
|
15
25
|
/**
|
|
16
|
-
* 生成随机配对码(
|
|
26
|
+
* 生成随机配对码(6位字母数字,使用密码学安全的随机数,排除易混淆字符)
|
|
17
27
|
*/
|
|
18
28
|
function generatePairCode() {
|
|
19
|
-
|
|
29
|
+
const bytes = crypto.randomBytes(PAIR_CODE_LEN);
|
|
30
|
+
let code = '';
|
|
31
|
+
for (let i = 0; i < PAIR_CODE_LEN; i++) {
|
|
32
|
+
code += PAIR_CODE_CHARS[bytes[i] % PAIR_CODE_CHARS.length];
|
|
33
|
+
}
|
|
34
|
+
return code;
|
|
20
35
|
}
|
|
21
36
|
|
|
22
37
|
/**
|
|
@@ -39,21 +54,15 @@ function startPairCodeRotation() {
|
|
|
39
54
|
|
|
40
55
|
// 有配对且最近有活跃连接,无需刷新配对码
|
|
41
56
|
if (hasPairings && isRecentlyActive) {
|
|
42
|
-
console.log(`[Agent] 已有 ${Object.keys(pairings).length} 个配对扩展,最近 ${Math.round((Date.now() - lastAuthTime) / 1000)}s 前有认证活动,跳过配对码刷新`);
|
|
43
57
|
currentPairCode = generatePairCode();
|
|
44
|
-
console.log(
|
|
58
|
+
console.log(`\n[Agent] 配对码: ${currentPairCode}\n`);
|
|
45
59
|
return;
|
|
46
60
|
}
|
|
47
61
|
|
|
48
|
-
// 有配对但无活跃连接,给出提示
|
|
49
|
-
if (hasPairings && !isRecentlyActive) {
|
|
50
|
-
console.log(`[Agent] 存在配对记录但无活跃连接(超过 ${ACTIVE_WINDOW_MS / 60000} 分钟),恢复配对码刷新`);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
62
|
const ttl = (config.pairCodeTTL || 30) * 1000;
|
|
54
63
|
|
|
55
64
|
currentPairCode = generatePairCode();
|
|
56
|
-
console.log(
|
|
65
|
+
console.log(`\n[Agent] 配对码: ${currentPairCode}\n`);
|
|
57
66
|
|
|
58
67
|
if (pairCodeTimer) clearInterval(pairCodeTimer);
|
|
59
68
|
pairCodeTimer = setInterval(() => {
|
|
@@ -89,17 +98,38 @@ function verifyToken(token) {
|
|
|
89
98
|
|
|
90
99
|
/**
|
|
91
100
|
* 处理配对请求(异步,支持写入锁)
|
|
101
|
+
* 安全策略:
|
|
102
|
+
* - 失败锁定:连续失败 PAIR_FAIL_LIMIT 次后临时锁定,并立即轮换配对码使已观测码失效
|
|
103
|
+
* - 一次性使用:配对成功后立即轮换配对码,防止配对码被重复利用
|
|
92
104
|
*/
|
|
93
105
|
async function handlePairRequest(code, extensionId) {
|
|
106
|
+
// 锁定期内直接拒绝(不泄露是否处于锁定状态细节)
|
|
107
|
+
if (Date.now() < pairLockUntil) {
|
|
108
|
+
return { success: false, error: '配对尝试过于频繁,请稍后再试' };
|
|
109
|
+
}
|
|
94
110
|
if (!code || !extensionId) {
|
|
95
111
|
return { success: false, error: '缺少配对码或扩展ID' };
|
|
96
112
|
}
|
|
97
|
-
if (code !== currentPairCode) {
|
|
113
|
+
if (code.toUpperCase() !== currentPairCode) {
|
|
114
|
+
pairFailCount++;
|
|
115
|
+
if (pairFailCount >= PAIR_FAIL_LIMIT) {
|
|
116
|
+
pairLockUntil = Date.now() + PAIR_LOCK_DURATION_MS;
|
|
117
|
+
pairFailCount = 0;
|
|
118
|
+
// 锁定时立即轮换配对码,使攻击者已观测到的配对码失效
|
|
119
|
+
currentPairCode = generatePairCode();
|
|
120
|
+
console.log('[Agent] 配对失败次数过多,已临时锁定 60s 并轮换配对码');
|
|
121
|
+
}
|
|
98
122
|
return { success: false, error: '配对码无效' };
|
|
99
123
|
}
|
|
124
|
+
// 配对码正确:重置失败计数
|
|
125
|
+
pairFailCount = 0;
|
|
126
|
+
|
|
100
127
|
// 检查是否已有配对
|
|
101
128
|
const pairings = loadPairings();
|
|
102
129
|
if (pairings[extensionId]) {
|
|
130
|
+
// 一次性使用:成功后立即轮换配对码
|
|
131
|
+
currentPairCode = generatePairCode();
|
|
132
|
+
console.log('[Agent] 配对码校验通过,配对码已轮换(一次性使用)');
|
|
103
133
|
return { success: true, token: pairings[extensionId].token, message: '已配对,返回现有 token' };
|
|
104
134
|
}
|
|
105
135
|
// 生成新 token 并保存
|
|
@@ -109,6 +139,9 @@ async function handlePairRequest(code, extensionId) {
|
|
|
109
139
|
} catch (err) {
|
|
110
140
|
return { success: false, error: `配对保存失败: ${err.message}` };
|
|
111
141
|
}
|
|
142
|
+
// 一次性使用:成功后立即轮换配对码
|
|
143
|
+
currentPairCode = generatePairCode();
|
|
144
|
+
console.log('[Agent] 配对成功,配对码已轮换(一次性使用)');
|
|
112
145
|
return { success: true, token, message: '配对成功' };
|
|
113
146
|
}
|
|
114
147
|
|
package/src/executor.js
CHANGED
|
@@ -54,8 +54,25 @@ function getShellForExec() {
|
|
|
54
54
|
// 运行中的进程映射:execId → { process, wsClients: Set, timeoutId, forceKillId, stdoutBuf, stderrBuf }
|
|
55
55
|
const runningProcesses = new Map();
|
|
56
56
|
|
|
57
|
-
//
|
|
58
|
-
|
|
57
|
+
// 输出缓冲区上限(5MB),防止长时间命令内存无限增长
|
|
58
|
+
const MAX_OUTPUT_BUFFER = 5 * 1024 * 1024;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 安全追加字符串到缓冲区,超过上限时截断并标记
|
|
62
|
+
* @param {string} buf - 当前缓冲区
|
|
63
|
+
* @param {string} chunk - 要追加的字符串
|
|
64
|
+
* @param {object} truncState - 截断状态对象 { truncated: boolean }
|
|
65
|
+
* @returns {string} 更新后的缓冲区
|
|
66
|
+
*/
|
|
67
|
+
function appendWithLimit(buf, chunk, truncState) {
|
|
68
|
+
if (truncState.truncated) return buf;
|
|
69
|
+
if (buf.length + chunk.length > MAX_OUTPUT_BUFFER) {
|
|
70
|
+
truncState.truncated = true;
|
|
71
|
+
const remaining = MAX_OUTPUT_BUFFER - buf.length;
|
|
72
|
+
return buf + (remaining > 0 ? chunk.slice(0, remaining) : '');
|
|
73
|
+
}
|
|
74
|
+
return buf + chunk;
|
|
75
|
+
}
|
|
59
76
|
const completedProcesses = new Map();
|
|
60
77
|
const COMPLETED_CACHE_TTL = 30000; // 30秒后清理
|
|
61
78
|
|
|
@@ -120,6 +137,7 @@ function executeCommand(command, cwd, wsClient, onComplete, collectOutput = fals
|
|
|
120
137
|
cwd: workdir,
|
|
121
138
|
env: buildSafeEnv(),
|
|
122
139
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
140
|
+
detached: true, // 独立进程组,防止 kill 父进程后子进程成为孤儿
|
|
123
141
|
windowsHide: true
|
|
124
142
|
});
|
|
125
143
|
|
|
@@ -133,7 +151,8 @@ function executeCommand(command, cwd, wsClient, onComplete, collectOutput = fals
|
|
|
133
151
|
|
|
134
152
|
// 存储到 entry 中供 clearTimers 读取
|
|
135
153
|
// stdoutBuf/stderrBuf 始终收集,用于回放给延迟连接的 WS 客户端
|
|
136
|
-
|
|
154
|
+
// command/startTime 用于状态详情面板展示运行中进程
|
|
155
|
+
const entry = { process: proc, wsClients, stdoutBuf: '', stderrBuf: '', truncStdout: false, truncStderr: false, command, startTime: Date.now() };
|
|
137
156
|
runningProcesses.set(execId, entry);
|
|
138
157
|
|
|
139
158
|
// 超时控制
|
|
@@ -166,7 +185,12 @@ function executeCommand(command, cwd, wsClient, onComplete, collectOutput = fals
|
|
|
166
185
|
proc.stdout.on('data', (chunk) => {
|
|
167
186
|
const str = chunk.toString();
|
|
168
187
|
if (collectOutput) stdoutCollected += str;
|
|
169
|
-
entry.
|
|
188
|
+
const truncState = { truncated: entry.truncStdout };
|
|
189
|
+
entry.stdoutBuf = appendWithLimit(entry.stdoutBuf, str, truncState);
|
|
190
|
+
entry.truncStdout = truncState.truncated;
|
|
191
|
+
if (truncState.truncated) {
|
|
192
|
+
broadcast({ type: 'stdout_truncated', message: 'stdout 输出超过 5MB 上限,已截断', execId });
|
|
193
|
+
}
|
|
170
194
|
broadcast({ type: 'stdout', data: str, execId });
|
|
171
195
|
});
|
|
172
196
|
|
|
@@ -174,7 +198,12 @@ function executeCommand(command, cwd, wsClient, onComplete, collectOutput = fals
|
|
|
174
198
|
proc.stderr.on('data', (chunk) => {
|
|
175
199
|
const str = chunk.toString();
|
|
176
200
|
if (collectOutput) stderrCollected += str;
|
|
177
|
-
entry.
|
|
201
|
+
const truncState = { truncated: entry.truncStderr };
|
|
202
|
+
entry.stderrBuf = appendWithLimit(entry.stderrBuf, str, truncState);
|
|
203
|
+
entry.truncStderr = truncState.truncated;
|
|
204
|
+
if (truncState.truncated) {
|
|
205
|
+
broadcast({ type: 'stderr_truncated', message: 'stderr 输出超过 5MB 上限,已截断', execId });
|
|
206
|
+
}
|
|
178
207
|
broadcast({ type: 'stderr', data: str, execId });
|
|
179
208
|
});
|
|
180
209
|
|
|
@@ -326,9 +355,9 @@ function disconnectWsClient(execId, wsClient) {
|
|
|
326
355
|
}
|
|
327
356
|
|
|
328
357
|
/**
|
|
329
|
-
*
|
|
330
|
-
* - macOS/Linux: SIGTERM
|
|
331
|
-
* - Windows: taskkill /PID
|
|
358
|
+
* 停止正在运行的命令(跨平台兼容,递归终止全部子进程)
|
|
359
|
+
* - macOS/Linux: SIGTERM to process group(-pid)→ 5s → SIGKILL to process group
|
|
360
|
+
* - Windows: taskkill /T /PID(递归终止进程树)→ 5s → taskkill /F /T /PID
|
|
332
361
|
*/
|
|
333
362
|
function killProcess(execId) {
|
|
334
363
|
const entry = runningProcesses.get(execId);
|
|
@@ -340,18 +369,20 @@ function killProcess(execId) {
|
|
|
340
369
|
const pid = entry.process.pid;
|
|
341
370
|
|
|
342
371
|
if (isWin) {
|
|
343
|
-
// Windows:
|
|
344
|
-
exec(`taskkill /PID ${pid}`, (err) => {
|
|
372
|
+
// Windows: /T 递归终止整个进程树
|
|
373
|
+
exec(`taskkill /T /PID ${pid}`, (err) => {
|
|
345
374
|
if (err) {
|
|
346
|
-
|
|
347
|
-
exec(`taskkill /F /PID ${pid}`, () => {});
|
|
375
|
+
exec(`taskkill /F /T /PID ${pid}`, () => {});
|
|
348
376
|
}
|
|
349
377
|
});
|
|
350
378
|
} else {
|
|
351
|
-
//
|
|
379
|
+
// Unix: 负 PID 表示杀整个进程组(detached + 未调用 setsid 时有效)
|
|
352
380
|
try {
|
|
353
|
-
|
|
354
|
-
} catch {
|
|
381
|
+
process.kill(-pid, 'SIGTERM');
|
|
382
|
+
} catch {
|
|
383
|
+
// 回退到单进程
|
|
384
|
+
try { entry.process.kill('SIGTERM'); } catch {}
|
|
385
|
+
}
|
|
355
386
|
}
|
|
356
387
|
|
|
357
388
|
// 清理旧的定时器
|
|
@@ -363,14 +394,15 @@ function killProcess(execId) {
|
|
|
363
394
|
clearTimeout(entry.forceKillId);
|
|
364
395
|
}
|
|
365
396
|
|
|
366
|
-
// 5
|
|
397
|
+
// 5秒后强制杀整个进程组
|
|
367
398
|
entry.forceKillId = setTimeout(() => {
|
|
368
399
|
try {
|
|
369
400
|
if (entry.process.exitCode === null) {
|
|
370
401
|
if (isWin) {
|
|
371
|
-
exec(`taskkill /F /PID ${pid}`, () => {});
|
|
402
|
+
exec(`taskkill /F /T /PID ${pid}`, () => {});
|
|
372
403
|
} else {
|
|
373
|
-
|
|
404
|
+
try { process.kill(-pid, 'SIGKILL'); }
|
|
405
|
+
catch { try { entry.process.kill('SIGKILL'); } catch {} }
|
|
374
406
|
}
|
|
375
407
|
}
|
|
376
408
|
} catch {}
|
|
@@ -382,11 +414,19 @@ function killProcess(execId) {
|
|
|
382
414
|
|
|
383
415
|
/**
|
|
384
416
|
* 获取运行中的进程列表
|
|
417
|
+
* 返回每个进程的 execId、pid、command、startTime、duration(已运行毫秒数)
|
|
385
418
|
*/
|
|
386
419
|
function getRunningProcesses() {
|
|
420
|
+
const now = Date.now();
|
|
387
421
|
const list = [];
|
|
388
|
-
for (const [execId] of runningProcesses) {
|
|
389
|
-
list.push({
|
|
422
|
+
for (const [execId, entry] of runningProcesses) {
|
|
423
|
+
list.push({
|
|
424
|
+
execId,
|
|
425
|
+
pid: entry.process && entry.process.pid ? entry.process.pid : null,
|
|
426
|
+
command: entry.command || '',
|
|
427
|
+
startTime: entry.startTime || null,
|
|
428
|
+
duration: entry.startTime ? now - entry.startTime : 0
|
|
429
|
+
});
|
|
390
430
|
}
|
|
391
431
|
return list;
|
|
392
432
|
}
|
package/src/mcp/client.js
CHANGED
|
@@ -33,7 +33,6 @@ export class McpClient {
|
|
|
33
33
|
case TRANSPORT_SSE: {
|
|
34
34
|
const url = this.serverConfig.url;
|
|
35
35
|
if (!url) throw new Error('SSE 传输需要提供 url');
|
|
36
|
-
console.log(`[MCP:${this.serverId}] 使用 SSE 传输,URL: ${url}`);
|
|
37
36
|
const opts = {};
|
|
38
37
|
if (Object.keys(headers).length > 0) {
|
|
39
38
|
opts.requestInit = { headers };
|
|
@@ -44,7 +43,6 @@ export class McpClient {
|
|
|
44
43
|
case TRANSPORT_STREAMABLE_HTTP: {
|
|
45
44
|
const url = this.serverConfig.url;
|
|
46
45
|
if (!url) throw new Error('StreamableHTTP 传输需要提供 url');
|
|
47
|
-
console.log(`[MCP:${this.serverId}] 使用 StreamableHTTP 传输,URL: ${url}`);
|
|
48
46
|
const opts = {};
|
|
49
47
|
if (Object.keys(headers).length > 0) {
|
|
50
48
|
opts.requestInit = { headers };
|
|
@@ -55,7 +53,6 @@ export class McpClient {
|
|
|
55
53
|
case TRANSPORT_WEBSOCKET: {
|
|
56
54
|
const url = this.serverConfig.url;
|
|
57
55
|
if (!url) throw new Error('WebSocket 传输需要提供 url');
|
|
58
|
-
console.log(`[MCP:${this.serverId}] 使用 WebSocket 传输,URL: ${url}`);
|
|
59
56
|
return new WebSocketClientTransport(new URL(url));
|
|
60
57
|
}
|
|
61
58
|
|
|
@@ -63,7 +60,6 @@ export class McpClient {
|
|
|
63
60
|
default: {
|
|
64
61
|
const command = this.serverConfig.command;
|
|
65
62
|
if (!command) throw new Error('stdio 传输需要提供 command');
|
|
66
|
-
console.log(`[MCP:${this.serverId}] 使用 stdio 传输,命令: ${command}`);
|
|
67
63
|
return new StdioClientTransport({
|
|
68
64
|
command: this.serverConfig.command,
|
|
69
65
|
args: this.serverConfig.args || [],
|
|
@@ -92,12 +88,10 @@ export class McpClient {
|
|
|
92
88
|
serverInfo: this.client.getServerVersion(),
|
|
93
89
|
capabilities: this.client.getServerCapabilities()
|
|
94
90
|
};
|
|
95
|
-
console.log(`[MCP:${this.serverId}] Initialize 成功, server:`, this.serverInfo.serverInfo?.name);
|
|
96
91
|
|
|
97
92
|
const toolsResult = await this.client.listTools({});
|
|
98
93
|
if (toolsResult && toolsResult.tools) {
|
|
99
94
|
this.tools = toolsResult.tools;
|
|
100
|
-
console.log(`[MCP:${this.serverId}] 发现 ${this.tools.length} 个工具`);
|
|
101
95
|
} else {
|
|
102
96
|
this.tools = [];
|
|
103
97
|
}
|
|
@@ -105,7 +99,6 @@ export class McpClient {
|
|
|
105
99
|
this.connected = true;
|
|
106
100
|
return { success: true, toolCount: this.tools.length };
|
|
107
101
|
} catch (err) {
|
|
108
|
-
console.error(`[MCP:${this.serverId}] 连接失败:`, err.message);
|
|
109
102
|
this.disconnect();
|
|
110
103
|
return { success: false, error: err.message };
|
|
111
104
|
}
|
|
@@ -155,8 +148,6 @@ export class McpClient {
|
|
|
155
148
|
try { await this.transport.close(); } catch {}
|
|
156
149
|
this.transport = null;
|
|
157
150
|
}
|
|
158
|
-
|
|
159
|
-
console.log(`[MCP:${this.serverId}] 已断开连接`);
|
|
160
151
|
}
|
|
161
152
|
|
|
162
153
|
getStatus() {
|
package/src/mcp/registry.js
CHANGED
|
@@ -14,14 +14,10 @@ export async function initializeMcpRegistry() {
|
|
|
14
14
|
const config = loadMcpConfig();
|
|
15
15
|
let connected = 0;
|
|
16
16
|
let failed = 0;
|
|
17
|
-
|
|
18
|
-
console.log(`[MCP Registry] 初始化,共配置 ${config.servers.length} 个 MCP Server`);
|
|
17
|
+
const failedServers = [];
|
|
19
18
|
|
|
20
19
|
for (const server of config.servers) {
|
|
21
|
-
if (!server.enabled)
|
|
22
|
-
console.log(`[MCP Registry] 跳过禁用的 Server: ${server.id}`);
|
|
23
|
-
continue;
|
|
24
|
-
}
|
|
20
|
+
if (!server.enabled) continue;
|
|
25
21
|
|
|
26
22
|
const client = new McpClient(server);
|
|
27
23
|
const result = await client.connect();
|
|
@@ -29,12 +25,19 @@ export async function initializeMcpRegistry() {
|
|
|
29
25
|
clients.set(server.id, client);
|
|
30
26
|
connected++;
|
|
31
27
|
} else {
|
|
32
|
-
console.error(`[MCP Registry] 连接失败 "${server.id}":`, result.error);
|
|
33
28
|
failed++;
|
|
29
|
+
failedServers.push({ id: server.id, error: result.error });
|
|
34
30
|
}
|
|
35
31
|
}
|
|
36
32
|
|
|
37
|
-
|
|
33
|
+
if (config.servers.length > 0) {
|
|
34
|
+
if (failed > 0) {
|
|
35
|
+
const failedList = failedServers.map(fs => `${fs.id}(${fs.error})`).join(', ');
|
|
36
|
+
console.warn(`[MCP] ${connected}/${config.servers.length} 已连接,${failed} 个失败: ${failedList}`);
|
|
37
|
+
} else if (connected > 0) {
|
|
38
|
+
console.log(`[MCP] ${connected} 个 Server 已连接`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
38
41
|
return { connected, failed };
|
|
39
42
|
}
|
|
40
43
|
|
|
@@ -154,7 +157,6 @@ export async function disconnectMcpServer(serverId) {
|
|
|
154
157
|
* 关闭所有 MCP 连接(Agent 关闭时调用)
|
|
155
158
|
*/
|
|
156
159
|
export async function shutdownMcpRegistry() {
|
|
157
|
-
console.log('[MCP Registry] 关闭所有 MCP 连接...');
|
|
158
160
|
for (const [id, client] of clients) {
|
|
159
161
|
try { await client.disconnect(); } catch {}
|
|
160
162
|
}
|
package/src/search.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// agent/src/search.js - 文件搜索(fd/rg 优先 + Node.js 回退)
|
|
2
2
|
import { spawn } from 'child_process';
|
|
3
3
|
import { readdir, stat, readFile } from 'fs/promises';
|
|
4
|
+
import { statSync } from 'fs';
|
|
4
5
|
import { join, basename, extname, resolve } from 'path';
|
|
5
6
|
import { checkPath } from './security.js';
|
|
6
7
|
|
|
@@ -43,8 +44,6 @@ export async function initSearchTools() {
|
|
|
43
44
|
const [fd, rg] = await Promise.all([checkFdAvailable(), checkRgAvailable()]);
|
|
44
45
|
fdAvailable = fd;
|
|
45
46
|
rgAvailable = rg;
|
|
46
|
-
console.log(`[Agent] fd (文件搜索): ${fd ? '✅ 可用' : '❌ 未安装,使用 Node.js 回退'}`);
|
|
47
|
-
console.log(`[Agent] rg (内容搜索): ${rg ? '✅ 可用' : '❌ 未安装,使用 Node.js 回退'}`);
|
|
48
47
|
return { fd: fdAvailable, rg: rgAvailable };
|
|
49
48
|
})();
|
|
50
49
|
return searchToolsInitPromise;
|
|
@@ -81,11 +80,15 @@ function matchGlob(filename, pattern) {
|
|
|
81
80
|
}
|
|
82
81
|
|
|
83
82
|
/**
|
|
84
|
-
* 使用 fd
|
|
83
|
+
* 使用 fd 搜索文件和目录(快速)
|
|
85
84
|
*/
|
|
86
85
|
function searchWithFd(rootPath, filePattern, recursive, maxResults) {
|
|
87
86
|
return new Promise((resolve) => {
|
|
88
|
-
const args = ['--glob', filePattern, '--type', 'f', '--max-results', String(maxResults)];
|
|
87
|
+
const args = ['--glob', filePattern, '--type', 'f', '--type', 'd', '--max-results', String(maxResults)];
|
|
88
|
+
// 排除默认忽略的目录
|
|
89
|
+
for (const dir of DEFAULT_IGNORE_DIRS) {
|
|
90
|
+
args.push('--exclude', dir);
|
|
91
|
+
}
|
|
89
92
|
if (!recursive) args.push('--max-depth', '1');
|
|
90
93
|
args.push(rootPath);
|
|
91
94
|
|
|
@@ -109,11 +112,12 @@ function searchWithFd(rootPath, filePattern, recursive, maxResults) {
|
|
|
109
112
|
const output = Buffer.concat(stdout).toString('utf-8').trim();
|
|
110
113
|
const lines = output ? output.split('\n').filter(Boolean) : [];
|
|
111
114
|
const results = lines.map(p => {
|
|
115
|
+
const absPath = join(rootPath, p);
|
|
112
116
|
try {
|
|
113
|
-
const s = statSync(
|
|
114
|
-
return { path:
|
|
117
|
+
const s = statSync(absPath);
|
|
118
|
+
return { path: absPath, name: basename(p), type: s.isDirectory() ? 'directory' : 'file', size: s.size, mtime: s.mtimeMs };
|
|
115
119
|
} catch {
|
|
116
|
-
return { path:
|
|
120
|
+
return { path: absPath, name: basename(p), type: 'file', size: 0, mtime: 0 };
|
|
117
121
|
}
|
|
118
122
|
});
|
|
119
123
|
resolve(results);
|
|
@@ -131,7 +135,7 @@ function yieldEventLoop() {
|
|
|
131
135
|
}
|
|
132
136
|
|
|
133
137
|
/**
|
|
134
|
-
* Node.js
|
|
138
|
+
* Node.js 原生递归搜索文件和目录(回退方案,异步 + 分片防止阻塞事件循环)
|
|
135
139
|
*/
|
|
136
140
|
async function searchFilesNative(rootPath, filePattern, recursive, maxResults) {
|
|
137
141
|
const results = [];
|
|
@@ -154,14 +158,23 @@ async function searchFilesNative(rootPath, filePattern, recursive, maxResults) {
|
|
|
154
158
|
if (entry.isDirectory()) {
|
|
155
159
|
if (DEFAULT_IGNORE_DIRS.has(entry.name)) continue;
|
|
156
160
|
if (entry.name.startsWith('.')) continue;
|
|
161
|
+
// 目录也参与搜索匹配
|
|
162
|
+
if (matchGlob(entry.name, filePattern)) {
|
|
163
|
+
try {
|
|
164
|
+
const s = await stat(fullPath);
|
|
165
|
+
results.push({ path: fullPath, name: entry.name, type: 'directory', size: s.size, mtime: s.mtimeMs });
|
|
166
|
+
} catch {
|
|
167
|
+
results.push({ path: fullPath, name: entry.name, type: 'directory', size: 0, mtime: 0 });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
157
170
|
await walk(fullPath, depth + 1);
|
|
158
171
|
} else if (entry.isFile()) {
|
|
159
172
|
if (matchGlob(entry.name, filePattern)) {
|
|
160
173
|
try {
|
|
161
174
|
const s = await stat(fullPath);
|
|
162
|
-
results.push({ path: fullPath, name: entry.name, size: s.size, mtime: s.mtimeMs });
|
|
175
|
+
results.push({ path: fullPath, name: entry.name, type: 'file', size: s.size, mtime: s.mtimeMs });
|
|
163
176
|
} catch {
|
|
164
|
-
results.push({ path: fullPath, name: entry.name, size: 0, mtime: 0 });
|
|
177
|
+
results.push({ path: fullPath, name: entry.name, type: 'file', size: 0, mtime: 0 });
|
|
165
178
|
}
|
|
166
179
|
}
|
|
167
180
|
}
|
package/src/security.js
CHANGED
|
@@ -28,6 +28,9 @@ const HARD_BLOCKED_PATHS = [
|
|
|
28
28
|
join(AGENT_DIR, 'pairings.json'), // 配对记录
|
|
29
29
|
join(AGENT_DIR, 'mcp_servers.json'),// MCP 服务器配置
|
|
30
30
|
join(AGENT_DIR, 'logs') + sep, // 日志目录
|
|
31
|
+
join(AGENT_DIR, '.trash') + sep, // 回收站目录(通过 /api/trash 端点管理)
|
|
32
|
+
join(AGENT_DIR, 'builtin_skills_state.json'), // 内置技能状态
|
|
33
|
+
join(AGENT_DIR, 'disabled_skills.json'), // 停用技能状态
|
|
31
34
|
];
|
|
32
35
|
|
|
33
36
|
/**
|