ai-helper-agent 1.9.2 → 1.9.4
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/package.json +1 -1
- package/src/config.js +7 -2
- package/src/mcp/client.js +8 -4
- package/src/mcp/registry.js +5 -5
- package/src/search.js +40 -23
- package/src/security.js +5 -5
- package/src/server.js +71 -31
- package/src/skill/executor.js +24 -18
- package/src/skill/loader.js +0 -1
- package/src/skill/markdown-loader.js +0 -1
- package/src/skill/registry.js +7 -5
package/package.json
CHANGED
package/src/config.js
CHANGED
|
@@ -13,8 +13,8 @@ const MEMORY_DIR = join(AGENT_DIR, 'memory');
|
|
|
13
13
|
const DEFAULTS = {
|
|
14
14
|
port: 18910,
|
|
15
15
|
host: '127.0.0.1',
|
|
16
|
-
workdir:
|
|
17
|
-
allowedPaths: [],
|
|
16
|
+
workdir: WORKSPACE_DIR,
|
|
17
|
+
allowedPaths: [WORKSPACE_DIR],
|
|
18
18
|
pairCodeTTL: 30,
|
|
19
19
|
commandTimeout: 300000,
|
|
20
20
|
fileMaxSize: 50 * 1024 * 1024
|
|
@@ -103,6 +103,11 @@ export function loadConfig() {
|
|
|
103
103
|
configCache.allowedPaths.push(SKILLS_DIR);
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
// 自动将 workspace 目录加入白名单,确保 AI 可以访问工作目录
|
|
107
|
+
if (!configCache.allowedPaths.includes(WORKSPACE_DIR)) {
|
|
108
|
+
configCache.allowedPaths.push(WORKSPACE_DIR);
|
|
109
|
+
}
|
|
110
|
+
|
|
106
111
|
return configCache;
|
|
107
112
|
}
|
|
108
113
|
|
package/src/mcp/client.js
CHANGED
|
@@ -97,8 +97,7 @@ export class McpClient {
|
|
|
97
97
|
const toolsResult = await this.client.listTools({});
|
|
98
98
|
if (toolsResult && toolsResult.tools) {
|
|
99
99
|
this.tools = toolsResult.tools;
|
|
100
|
-
console.log(`[MCP:${this.serverId}] 发现 ${this.tools.length}
|
|
101
|
-
this.tools.map(t => t.name).join(', '));
|
|
100
|
+
console.log(`[MCP:${this.serverId}] 发现 ${this.tools.length} 个工具`);
|
|
102
101
|
} else {
|
|
103
102
|
this.tools = [];
|
|
104
103
|
}
|
|
@@ -143,15 +142,20 @@ export class McpClient {
|
|
|
143
142
|
}
|
|
144
143
|
}
|
|
145
144
|
|
|
146
|
-
disconnect() {
|
|
145
|
+
async disconnect() {
|
|
147
146
|
this.connected = false;
|
|
148
147
|
this.tools = [];
|
|
149
148
|
|
|
150
149
|
if (this.client) {
|
|
151
|
-
try { this.client.close(); } catch {}
|
|
150
|
+
try { await this.client.close(); } catch {}
|
|
152
151
|
this.client = null;
|
|
153
152
|
}
|
|
154
153
|
|
|
154
|
+
if (this.transport) {
|
|
155
|
+
try { await this.transport.close(); } catch {}
|
|
156
|
+
this.transport = null;
|
|
157
|
+
}
|
|
158
|
+
|
|
155
159
|
console.log(`[MCP:${this.serverId}] 已断开连接`);
|
|
156
160
|
}
|
|
157
161
|
|
package/src/mcp/registry.js
CHANGED
|
@@ -124,7 +124,7 @@ export async function connectMcpServer(serverId) {
|
|
|
124
124
|
// 先断开已有连接
|
|
125
125
|
const existingClient = clients.get(serverId);
|
|
126
126
|
if (existingClient) {
|
|
127
|
-
existingClient.disconnect();
|
|
127
|
+
await existingClient.disconnect();
|
|
128
128
|
clients.delete(serverId);
|
|
129
129
|
}
|
|
130
130
|
|
|
@@ -140,10 +140,10 @@ export async function connectMcpServer(serverId) {
|
|
|
140
140
|
/**
|
|
141
141
|
* 断开指定 MCP Server
|
|
142
142
|
*/
|
|
143
|
-
export function disconnectMcpServer(serverId) {
|
|
143
|
+
export async function disconnectMcpServer(serverId) {
|
|
144
144
|
const client = clients.get(serverId);
|
|
145
145
|
if (client) {
|
|
146
|
-
client.disconnect();
|
|
146
|
+
await client.disconnect();
|
|
147
147
|
clients.delete(serverId);
|
|
148
148
|
return { success: true };
|
|
149
149
|
}
|
|
@@ -153,10 +153,10 @@ export function disconnectMcpServer(serverId) {
|
|
|
153
153
|
/**
|
|
154
154
|
* 关闭所有 MCP 连接(Agent 关闭时调用)
|
|
155
155
|
*/
|
|
156
|
-
export function shutdownMcpRegistry() {
|
|
156
|
+
export async function shutdownMcpRegistry() {
|
|
157
157
|
console.log('[MCP Registry] 关闭所有 MCP 连接...');
|
|
158
158
|
for (const [id, client] of clients) {
|
|
159
|
-
try { client.disconnect(); } catch {}
|
|
159
|
+
try { await client.disconnect(); } catch {}
|
|
160
160
|
}
|
|
161
161
|
clients.clear();
|
|
162
162
|
}
|
package/src/search.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// agent/src/search.js - 文件搜索(fd/rg 优先 + Node.js 回退)
|
|
2
2
|
import { spawn } from 'child_process';
|
|
3
|
-
import {
|
|
3
|
+
import { readdir, stat, readFile } from 'fs/promises';
|
|
4
4
|
import { join, basename, extname, resolve } from 'path';
|
|
5
5
|
import { checkPath } from './security.js';
|
|
6
6
|
|
|
@@ -124,19 +124,29 @@ function searchWithFd(rootPath, filePattern, recursive, maxResults) {
|
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
/**
|
|
127
|
-
*
|
|
127
|
+
* 让出事件循环,防止长时间同步操作阻塞其他请求
|
|
128
128
|
*/
|
|
129
|
-
function
|
|
129
|
+
function yieldEventLoop() {
|
|
130
|
+
return new Promise(resolve => setImmediate(resolve));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Node.js 原生递归搜索文件(回退方案,异步 + 分片防止阻塞事件循环)
|
|
135
|
+
*/
|
|
136
|
+
async function searchFilesNative(rootPath, filePattern, recursive, maxResults) {
|
|
130
137
|
const results = [];
|
|
131
138
|
|
|
132
|
-
function walk(dir, depth) {
|
|
139
|
+
async function walk(dir, depth) {
|
|
133
140
|
if (results.length >= maxResults) return;
|
|
134
141
|
if (!recursive && depth > 1) return;
|
|
135
142
|
|
|
136
143
|
let entries;
|
|
137
|
-
try { entries =
|
|
144
|
+
try { entries = await readdir(dir, { withFileTypes: true }); }
|
|
138
145
|
catch { return; }
|
|
139
146
|
|
|
147
|
+
// 每处理一个目录后让出事件循环
|
|
148
|
+
await yieldEventLoop();
|
|
149
|
+
|
|
140
150
|
for (const entry of entries) {
|
|
141
151
|
if (results.length >= maxResults) return;
|
|
142
152
|
const fullPath = join(dir, entry.name);
|
|
@@ -144,11 +154,11 @@ function searchFilesNative(rootPath, filePattern, recursive, maxResults) {
|
|
|
144
154
|
if (entry.isDirectory()) {
|
|
145
155
|
if (DEFAULT_IGNORE_DIRS.has(entry.name)) continue;
|
|
146
156
|
if (entry.name.startsWith('.')) continue;
|
|
147
|
-
walk(fullPath, depth + 1);
|
|
157
|
+
await walk(fullPath, depth + 1);
|
|
148
158
|
} else if (entry.isFile()) {
|
|
149
159
|
if (matchGlob(entry.name, filePattern)) {
|
|
150
160
|
try {
|
|
151
|
-
const s =
|
|
161
|
+
const s = await stat(fullPath);
|
|
152
162
|
results.push({ path: fullPath, name: entry.name, size: s.size, mtime: s.mtimeMs });
|
|
153
163
|
} catch {
|
|
154
164
|
results.push({ path: fullPath, name: entry.name, size: 0, mtime: 0 });
|
|
@@ -158,7 +168,7 @@ function searchFilesNative(rootPath, filePattern, recursive, maxResults) {
|
|
|
158
168
|
}
|
|
159
169
|
}
|
|
160
170
|
|
|
161
|
-
walk(rootPath, 0);
|
|
171
|
+
await walk(rootPath, 0);
|
|
162
172
|
return results;
|
|
163
173
|
}
|
|
164
174
|
|
|
@@ -171,17 +181,19 @@ function searchFilesNative(rootPath, filePattern, recursive, maxResults) {
|
|
|
171
181
|
* @returns {Array<{path, name, size, mtime}>}
|
|
172
182
|
*/
|
|
173
183
|
export async function searchFiles(rootPath, filePattern = '*', recursive = true, maxResults = 200) {
|
|
174
|
-
const pathCheck = checkPath(rootPath);
|
|
184
|
+
const pathCheck = await checkPath(rootPath);
|
|
175
185
|
if (!pathCheck.allowed) {
|
|
176
186
|
return { success: false, error: pathCheck.reason };
|
|
177
187
|
}
|
|
178
188
|
|
|
179
189
|
const resolved = pathCheck.resolved;
|
|
180
|
-
|
|
190
|
+
try {
|
|
191
|
+
await stat(resolved);
|
|
192
|
+
} catch {
|
|
181
193
|
return { success: false, error: '搜索路径不存在' };
|
|
182
194
|
}
|
|
183
195
|
|
|
184
|
-
const s =
|
|
196
|
+
const s = await stat(resolved);
|
|
185
197
|
if (!s.isDirectory()) {
|
|
186
198
|
return { success: false, error: '搜索路径不是目录' };
|
|
187
199
|
}
|
|
@@ -190,7 +202,7 @@ export async function searchFiles(rootPath, filePattern = '*', recursive = true,
|
|
|
190
202
|
if (fdAvailable) {
|
|
191
203
|
results = await searchWithFd(resolved, filePattern, recursive, maxResults);
|
|
192
204
|
} else {
|
|
193
|
-
results = searchFilesNative(resolved, filePattern, recursive, maxResults);
|
|
205
|
+
results = await searchFilesNative(resolved, filePattern, recursive, maxResults);
|
|
194
206
|
}
|
|
195
207
|
|
|
196
208
|
return {
|
|
@@ -271,19 +283,22 @@ function parseRgOutput(output, contextLines) {
|
|
|
271
283
|
}
|
|
272
284
|
|
|
273
285
|
/**
|
|
274
|
-
* Node.js
|
|
286
|
+
* Node.js 原生搜索文件内容(回退方案,异步 + 分片防止阻塞事件循环)
|
|
275
287
|
*/
|
|
276
|
-
function searchContentNative(rootPath, pattern, filePattern, caseSensitive, maxResults, contextLines) {
|
|
288
|
+
async function searchContentNative(rootPath, pattern, filePattern, caseSensitive, maxResults, contextLines) {
|
|
277
289
|
const results = [];
|
|
278
290
|
const searchPattern = caseSensitive ? pattern : pattern.toLowerCase();
|
|
279
291
|
|
|
280
|
-
function walk(dir) {
|
|
292
|
+
async function walk(dir) {
|
|
281
293
|
if (results.length >= maxResults) return;
|
|
282
294
|
|
|
283
295
|
let entries;
|
|
284
|
-
try { entries =
|
|
296
|
+
try { entries = await readdir(dir, { withFileTypes: true }); }
|
|
285
297
|
catch { return; }
|
|
286
298
|
|
|
299
|
+
// 每处理一个目录后让出事件循环
|
|
300
|
+
await yieldEventLoop();
|
|
301
|
+
|
|
287
302
|
for (const entry of entries) {
|
|
288
303
|
if (results.length >= maxResults) return;
|
|
289
304
|
const fullPath = join(dir, entry.name);
|
|
@@ -291,7 +306,7 @@ function searchContentNative(rootPath, pattern, filePattern, caseSensitive, maxR
|
|
|
291
306
|
if (entry.isDirectory()) {
|
|
292
307
|
if (DEFAULT_IGNORE_DIRS.has(entry.name)) continue;
|
|
293
308
|
if (entry.name.startsWith('.')) continue;
|
|
294
|
-
walk(fullPath);
|
|
309
|
+
await walk(fullPath);
|
|
295
310
|
} else if (entry.isFile()) {
|
|
296
311
|
if (filePattern && !matchGlob(entry.name, filePattern)) continue;
|
|
297
312
|
// 跳过压缩和映射文件
|
|
@@ -299,10 +314,10 @@ function searchContentNative(rootPath, pattern, filePattern, caseSensitive, maxR
|
|
|
299
314
|
if (entry.name.endsWith('.map')) continue;
|
|
300
315
|
|
|
301
316
|
try {
|
|
302
|
-
const s =
|
|
317
|
+
const s = await stat(fullPath);
|
|
303
318
|
if (s.size > 1024 * 1024) continue; // 跳过 1MB 以上的文件
|
|
304
319
|
|
|
305
|
-
const content =
|
|
320
|
+
const content = await readFile(fullPath, 'utf-8');
|
|
306
321
|
const fileLines = content.split('\n');
|
|
307
322
|
|
|
308
323
|
for (let i = 0; i < fileLines.length; i++) {
|
|
@@ -328,7 +343,7 @@ function searchContentNative(rootPath, pattern, filePattern, caseSensitive, maxR
|
|
|
328
343
|
}
|
|
329
344
|
}
|
|
330
345
|
|
|
331
|
-
walk(rootPath);
|
|
346
|
+
await walk(rootPath);
|
|
332
347
|
return results;
|
|
333
348
|
}
|
|
334
349
|
|
|
@@ -343,13 +358,15 @@ function searchContentNative(rootPath, pattern, filePattern, caseSensitive, maxR
|
|
|
343
358
|
* @returns {{ success: boolean, results: Array, total: number, engine: string }}
|
|
344
359
|
*/
|
|
345
360
|
export async function searchContent(rootPath, pattern, filePattern = null, caseSensitive = false, maxResults = 100, contextLines = 2) {
|
|
346
|
-
const pathCheck = checkPath(rootPath);
|
|
361
|
+
const pathCheck = await checkPath(rootPath);
|
|
347
362
|
if (!pathCheck.allowed) {
|
|
348
363
|
return { success: false, error: pathCheck.reason };
|
|
349
364
|
}
|
|
350
365
|
|
|
351
366
|
const resolved = pathCheck.resolved;
|
|
352
|
-
|
|
367
|
+
try {
|
|
368
|
+
await stat(resolved);
|
|
369
|
+
} catch {
|
|
353
370
|
return { success: false, error: '搜索路径不存在' };
|
|
354
371
|
}
|
|
355
372
|
|
|
@@ -361,7 +378,7 @@ export async function searchContent(rootPath, pattern, filePattern = null, caseS
|
|
|
361
378
|
if (rgAvailable) {
|
|
362
379
|
results = await searchContentWithRg(resolved, pattern, filePattern, maxResults, contextLines);
|
|
363
380
|
} else {
|
|
364
|
-
results = searchContentNative(resolved, pattern, filePattern, caseSensitive, maxResults, contextLines);
|
|
381
|
+
results = await searchContentNative(resolved, pattern, filePattern, caseSensitive, maxResults, contextLines);
|
|
365
382
|
}
|
|
366
383
|
|
|
367
384
|
return {
|
package/src/security.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// agent/src/security.js - 安全管控:文件沙箱 + 命令分级
|
|
2
2
|
import { resolve, normalize, sep, isAbsolute, join, dirname } from 'path';
|
|
3
|
-
import {
|
|
3
|
+
import { realpath } from 'fs/promises';
|
|
4
4
|
import { homedir, platform } from 'os';
|
|
5
5
|
import { loadConfig, MEMORY_DIR } from './config.js';
|
|
6
6
|
|
|
@@ -52,10 +52,10 @@ function isHardBlocked(normalizedPath) {
|
|
|
52
52
|
|
|
53
53
|
/**
|
|
54
54
|
* 检查路径是否在白名单内
|
|
55
|
-
* 使用
|
|
55
|
+
* 使用 realpath 防止符号链接绕过
|
|
56
56
|
* @returns {{ allowed: boolean, reason?: string, resolved?: string }}
|
|
57
57
|
*/
|
|
58
|
-
function checkPath(pathStr) {
|
|
58
|
+
async function checkPath(pathStr) {
|
|
59
59
|
if (!pathStr || typeof pathStr !== 'string') {
|
|
60
60
|
return { allowed: false, reason: '路径无效' };
|
|
61
61
|
}
|
|
@@ -106,7 +106,7 @@ function checkPath(pathStr) {
|
|
|
106
106
|
// realpath 校验:防止符号链接绕过
|
|
107
107
|
let realPath;
|
|
108
108
|
try {
|
|
109
|
-
realPath =
|
|
109
|
+
realPath = await realpath(resolved);
|
|
110
110
|
} catch (err) {
|
|
111
111
|
if (err.code === 'ENOENT') {
|
|
112
112
|
let existing = resolved;
|
|
@@ -116,7 +116,7 @@ function checkPath(pathStr) {
|
|
|
116
116
|
break;
|
|
117
117
|
}
|
|
118
118
|
try {
|
|
119
|
-
realPath =
|
|
119
|
+
realPath = await realpath(parent) + sep + normalized.slice(resolve(parent).length).replace(/^[/\\]/, '');
|
|
120
120
|
break;
|
|
121
121
|
} catch (parentErr) {
|
|
122
122
|
if (parentErr.code === 'ENOENT') {
|
package/src/server.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// agent/src/server.js - HTTP Router + WebSocket 服务器
|
|
2
2
|
import http from 'http';
|
|
3
3
|
import { WebSocketServer } from 'ws';
|
|
4
|
-
import { readFileSync
|
|
4
|
+
import { readFileSync } from 'fs';
|
|
5
|
+
import { readFile, writeFile, readdir, stat, unlink, rmdir, chmod, mkdir } from 'fs/promises';
|
|
5
6
|
import { join, dirname } from 'path';
|
|
6
7
|
import { fileURLToPath } from 'url';
|
|
7
8
|
import { homedir } from 'os';
|
|
@@ -98,6 +99,14 @@ function detectShell() {
|
|
|
98
99
|
return '/bin/bash';
|
|
99
100
|
}
|
|
100
101
|
|
|
102
|
+
/**
|
|
103
|
+
* 异步检查文件/目录是否存在
|
|
104
|
+
*/
|
|
105
|
+
async function exists(path) {
|
|
106
|
+
try { await stat(path); return true; }
|
|
107
|
+
catch { return false; }
|
|
108
|
+
}
|
|
109
|
+
|
|
101
110
|
const PLATFORM_INFO = {
|
|
102
111
|
platform: os.platform(),
|
|
103
112
|
platformName: (() => {
|
|
@@ -361,21 +370,21 @@ export function startServer() {
|
|
|
361
370
|
|
|
362
371
|
// 去重:如果文件已存在,添加后缀
|
|
363
372
|
let finalPath = destPath;
|
|
364
|
-
if (
|
|
373
|
+
if (await exists(finalPath)) {
|
|
365
374
|
const ext = safeName.lastIndexOf('.') > 0 ? safeName.substring(safeName.lastIndexOf('.')) : '';
|
|
366
375
|
const base = safeName.substring(0, safeName.length - ext.length) || safeName;
|
|
367
376
|
let counter = 1;
|
|
368
|
-
while (
|
|
377
|
+
while (await exists(finalPath)) {
|
|
369
378
|
finalPath = join(config.workdir, `${base}_${counter}${ext}`);
|
|
370
379
|
counter++;
|
|
371
380
|
}
|
|
372
381
|
}
|
|
373
382
|
|
|
374
383
|
// 确保工作目录存在
|
|
375
|
-
|
|
384
|
+
await mkdir(config.workdir, { recursive: true });
|
|
376
385
|
|
|
377
386
|
// 写入文件
|
|
378
|
-
|
|
387
|
+
await writeFile(finalPath, fileBuffer);
|
|
379
388
|
|
|
380
389
|
logFs('upload', { path: finalPath, size: fileBuffer.length, mimeType });
|
|
381
390
|
|
|
@@ -446,23 +455,23 @@ export function startServer() {
|
|
|
446
455
|
|
|
447
456
|
// 读取文件
|
|
448
457
|
if (pathname === '/api/fs/read') {
|
|
449
|
-
const check = checkPath(body.path);
|
|
458
|
+
const check = await checkPath(body.path);
|
|
450
459
|
if (!check.allowed) {
|
|
451
460
|
logSecurity('fs_read_blocked', { path: body.path, reason: check.reason });
|
|
452
461
|
return jsonResponse(res, 403, { success: false, error: check.reason });
|
|
453
462
|
}
|
|
454
|
-
if (!
|
|
455
|
-
const fstat =
|
|
463
|
+
if (!await exists(check.resolved)) return jsonResponse(res, 404, { success: false, error: '文件不存在' });
|
|
464
|
+
const fstat = await stat(check.resolved);
|
|
456
465
|
if (fstat.isDirectory()) return jsonResponse(res, 400, { success: false, error: '路径是目录' });
|
|
457
466
|
if (fstat.size > maxSize) return jsonResponse(res, 400, { success: false, error: `文件过大 (${fstat.size} > ${maxSize})` });
|
|
458
|
-
const content =
|
|
467
|
+
const content = await readFile(check.resolved, 'utf-8');
|
|
459
468
|
logFs('read', { path: check.resolved, size: fstat.size });
|
|
460
469
|
return jsonResponse(res, 200, { success: true, content, size: fstat.size, path: check.resolved });
|
|
461
470
|
}
|
|
462
471
|
|
|
463
472
|
// 写入文件
|
|
464
473
|
if (pathname === '/api/fs/write') {
|
|
465
|
-
const check = checkPath(body.path);
|
|
474
|
+
const check = await checkPath(body.path);
|
|
466
475
|
if (!check.allowed) {
|
|
467
476
|
logSecurity('fs_write_blocked', { path: body.path, reason: check.reason });
|
|
468
477
|
return jsonResponse(res, 403, { success: false, error: check.reason });
|
|
@@ -471,8 +480,8 @@ export function startServer() {
|
|
|
471
480
|
const buf = Buffer.from(rawContent, 'utf-8');
|
|
472
481
|
if (buf.length > maxSize) return jsonResponse(res, 400, { success: false, error: `内容过大 (${buf.length} > ${maxSize})` });
|
|
473
482
|
// 确保父目录存在
|
|
474
|
-
|
|
475
|
-
|
|
483
|
+
await mkdir(dirname(check.resolved), { recursive: true });
|
|
484
|
+
await writeFile(check.resolved, rawContent, 'utf-8');
|
|
476
485
|
|
|
477
486
|
// 如果写入的是脚本文件,剥离执行权限防止直接运行
|
|
478
487
|
const SCRIPT_EXT_RE = /\.(sh|bash|zsh|py|js|mjs|rb|pl|php|lua)$/i;
|
|
@@ -480,7 +489,7 @@ export function startServer() {
|
|
|
480
489
|
const hasShebang = rawContent.startsWith('#!');
|
|
481
490
|
if (isScriptExt || hasShebang) {
|
|
482
491
|
try {
|
|
483
|
-
|
|
492
|
+
await chmod(check.resolved, 0o644);
|
|
484
493
|
} catch {}
|
|
485
494
|
}
|
|
486
495
|
|
|
@@ -491,39 +500,40 @@ export function startServer() {
|
|
|
491
500
|
// 列出目录
|
|
492
501
|
if (pathname === '/api/fs/list') {
|
|
493
502
|
const dirPath = body.path || '.';
|
|
494
|
-
const check = checkPath(dirPath);
|
|
503
|
+
const check = await checkPath(dirPath);
|
|
495
504
|
if (!check.allowed) {
|
|
496
505
|
logSecurity('fs_list_blocked', { path: dirPath, reason: check.reason });
|
|
497
506
|
return jsonResponse(res, 403, { success: false, error: check.reason });
|
|
498
507
|
}
|
|
499
|
-
if (!
|
|
500
|
-
const dstat =
|
|
508
|
+
if (!await exists(check.resolved)) return jsonResponse(res, 404, { success: false, error: '目录不存在' });
|
|
509
|
+
const dstat = await stat(check.resolved);
|
|
501
510
|
if (!dstat.isDirectory()) return jsonResponse(res, 400, { success: false, error: '路径不是目录' });
|
|
502
|
-
const
|
|
511
|
+
const names = await readdir(check.resolved);
|
|
512
|
+
const entries = await Promise.all(names.map(async (name) => {
|
|
503
513
|
const fullPath = join(check.resolved, name);
|
|
504
514
|
try {
|
|
505
|
-
const s =
|
|
515
|
+
const s = await stat(fullPath);
|
|
506
516
|
return { name, type: s.isDirectory() ? 'directory' : 'file', size: s.size, mtime: s.mtimeMs };
|
|
507
517
|
} catch { return { name, type: 'unknown', size: 0, mtime: 0 }; }
|
|
508
|
-
});
|
|
518
|
+
}));
|
|
509
519
|
logFs('list', { path: check.resolved, entryCount: entries.length });
|
|
510
520
|
return jsonResponse(res, 200, { success: true, entries, path: check.resolved });
|
|
511
521
|
}
|
|
512
522
|
|
|
513
523
|
// 删除文件/目录
|
|
514
524
|
if (pathname === '/api/fs/delete') {
|
|
515
|
-
const check = checkPath(body.path);
|
|
525
|
+
const check = await checkPath(body.path);
|
|
516
526
|
if (!check.allowed) {
|
|
517
527
|
logSecurity('fs_delete_blocked', { path: body.path, reason: check.reason });
|
|
518
528
|
return jsonResponse(res, 403, { success: false, error: check.reason });
|
|
519
529
|
}
|
|
520
|
-
if (!
|
|
521
|
-
const fstat2 =
|
|
530
|
+
if (!await exists(check.resolved)) return jsonResponse(res, 404, { success: false, error: '文件/目录不存在' });
|
|
531
|
+
const fstat2 = await stat(check.resolved);
|
|
522
532
|
const isDir = fstat2.isDirectory();
|
|
523
533
|
if (isDir) {
|
|
524
|
-
|
|
534
|
+
await rmdir(check.resolved, { recursive: true });
|
|
525
535
|
} else {
|
|
526
|
-
|
|
536
|
+
await unlink(check.resolved);
|
|
527
537
|
}
|
|
528
538
|
logFs('delete', { path: check.resolved, type: isDir ? 'directory' : 'file', size: fstat2.size });
|
|
529
539
|
return jsonResponse(res, 200, { success: true, path: check.resolved });
|
|
@@ -535,12 +545,12 @@ export function startServer() {
|
|
|
535
545
|
if (!targetPath || typeof targetPath !== 'string') {
|
|
536
546
|
return jsonResponse(res, 400, { success: false, error: '缺少 path 参数' });
|
|
537
547
|
}
|
|
538
|
-
const check = checkPath(targetPath);
|
|
548
|
+
const check = await checkPath(targetPath);
|
|
539
549
|
if (!check.allowed) {
|
|
540
550
|
logSecurity('browser_open_blocked', { path: targetPath, reason: check.reason });
|
|
541
551
|
return jsonResponse(res, 403, { success: false, error: check.reason });
|
|
542
552
|
}
|
|
543
|
-
if (!
|
|
553
|
+
if (!await exists(check.resolved)) {
|
|
544
554
|
return jsonResponse(res, 404, { success: false, error: '文件不存在' });
|
|
545
555
|
}
|
|
546
556
|
|
|
@@ -575,7 +585,7 @@ export function startServer() {
|
|
|
575
585
|
|
|
576
586
|
// 校验 cwd
|
|
577
587
|
let resolvedCwd = cwd || config.workdir;
|
|
578
|
-
const cwdCheck = checkPath(resolvedCwd);
|
|
588
|
+
const cwdCheck = await checkPath(resolvedCwd);
|
|
579
589
|
if (!cwdCheck.allowed) {
|
|
580
590
|
logSecurity('exec_cwd_blocked', { command, cwd: resolvedCwd, reason: cwdCheck.reason });
|
|
581
591
|
return jsonResponse(res, 403, { success: false, error: '执行目录校验失败: ' + cwdCheck.reason });
|
|
@@ -932,7 +942,7 @@ export function startServer() {
|
|
|
932
942
|
return jsonResponse(res, 404, { success: false, error: `资源 "${resource}" 不存在` });
|
|
933
943
|
}
|
|
934
944
|
try {
|
|
935
|
-
const content =
|
|
945
|
+
const content = await readFile(resInfo.path, 'utf-8');
|
|
936
946
|
return jsonResponse(res, 200, { success: true, name: resource, content, size: resInfo.size });
|
|
937
947
|
} catch (err) {
|
|
938
948
|
return jsonResponse(res, 500, { success: false, error: `读取资源失败: ${err.message}` });
|
|
@@ -956,6 +966,14 @@ export function startServer() {
|
|
|
956
966
|
jsonResponse(res, 404, { success: false, error: '未知的 API 路径' });
|
|
957
967
|
}
|
|
958
968
|
|
|
969
|
+
// 追踪所有活跃 socket 连接,用于优雅关闭时强制断开
|
|
970
|
+
const activeSockets = new Set();
|
|
971
|
+
|
|
972
|
+
server.on('connection', (socket) => {
|
|
973
|
+
activeSockets.add(socket);
|
|
974
|
+
socket.on('close', () => activeSockets.delete(socket));
|
|
975
|
+
});
|
|
976
|
+
|
|
959
977
|
// ==================== WebSocket Server ====================
|
|
960
978
|
const wss = new WebSocketServer({ noServer: true });
|
|
961
979
|
|
|
@@ -990,6 +1008,9 @@ export function startServer() {
|
|
|
990
1008
|
const added = addWsClient(execId, ws);
|
|
991
1009
|
if (!added) {
|
|
992
1010
|
ws.send(JSON.stringify({ type: 'error', error: '进程不存在或已结束', execId }));
|
|
1011
|
+
// 进程已不存在,发送错误后关闭 WebSocket,避免客户端空等
|
|
1012
|
+
ws.close();
|
|
1013
|
+
return;
|
|
993
1014
|
}
|
|
994
1015
|
// 客户端断开时,从监听列表中移除;所有客户端都断开后取消超时
|
|
995
1016
|
ws.on('close', () => {
|
|
@@ -1032,11 +1053,19 @@ export function startServer() {
|
|
|
1032
1053
|
}
|
|
1033
1054
|
});
|
|
1034
1055
|
|
|
1035
|
-
// 优雅关闭(异步 +
|
|
1056
|
+
// 优雅关闭(异步 + 防并发 + 超时兜底)
|
|
1036
1057
|
async function shutdown() {
|
|
1037
1058
|
if (shuttingDown) return;
|
|
1038
1059
|
shuttingDown = true;
|
|
1039
1060
|
console.log('[Agent] 正在关闭...');
|
|
1061
|
+
|
|
1062
|
+
// 超时兜底:10 秒后强制退出,防止 server.close() 等待连接挂起
|
|
1063
|
+
const forceExitTimer = setTimeout(() => {
|
|
1064
|
+
console.log('[Agent] 优雅关闭超时,强制退出');
|
|
1065
|
+
process.exit(1);
|
|
1066
|
+
}, 10000);
|
|
1067
|
+
forceExitTimer.unref();
|
|
1068
|
+
|
|
1040
1069
|
stopPairCodeRotation();
|
|
1041
1070
|
|
|
1042
1071
|
// 终止所有运行中的进程
|
|
@@ -1045,7 +1074,17 @@ export function startServer() {
|
|
|
1045
1074
|
}
|
|
1046
1075
|
|
|
1047
1076
|
// 关闭所有 MCP 连接
|
|
1048
|
-
try { shutdownMcpRegistry(); } catch {}
|
|
1077
|
+
try { await shutdownMcpRegistry(); } catch {}
|
|
1078
|
+
|
|
1079
|
+
// 强制断开所有 WebSocket 客户端
|
|
1080
|
+
wss.clients.forEach((client) => {
|
|
1081
|
+
try { client.terminate(); } catch {}
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
// 强制销毁所有活跃 HTTP socket,避免 server.close() 挂起等待
|
|
1085
|
+
for (const socket of activeSockets) {
|
|
1086
|
+
try { socket.destroy(); } catch {}
|
|
1087
|
+
}
|
|
1049
1088
|
|
|
1050
1089
|
// 等待 server 和 wss 关闭
|
|
1051
1090
|
await Promise.all([
|
|
@@ -1054,8 +1093,9 @@ export function startServer() {
|
|
|
1054
1093
|
]);
|
|
1055
1094
|
|
|
1056
1095
|
// 清理 PID 文件
|
|
1057
|
-
try { if (
|
|
1096
|
+
try { if (await exists(PID_FILE)) await unlink(PID_FILE); } catch {}
|
|
1058
1097
|
|
|
1098
|
+
clearTimeout(forceExitTimer);
|
|
1059
1099
|
logSystem('server_stop', { reason: 'shutdown' });
|
|
1060
1100
|
process.exit(0);
|
|
1061
1101
|
}
|
package/src/skill/executor.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// skill/executor.js - Skill 执行引擎
|
|
2
2
|
// 解析 Skill 定义的步骤 DAG,按拓扑排序执行,支持并行
|
|
3
3
|
import { render } from './template.js';
|
|
4
|
-
import {
|
|
4
|
+
import { readFile, writeFile, readdir, stat } from 'fs/promises';
|
|
5
5
|
import { checkPath, checkCommand } from '../security.js';
|
|
6
6
|
import { join } from 'path';
|
|
7
7
|
|
|
@@ -48,28 +48,27 @@ async function executeToolCall(toolName, args) {
|
|
|
48
48
|
try {
|
|
49
49
|
switch (toolName) {
|
|
50
50
|
case 'agent_read_file': {
|
|
51
|
-
const check = checkPath(args.path);
|
|
51
|
+
const check = await checkPath(args.path);
|
|
52
52
|
if (!check.allowed) return { success: false, error: check.reason };
|
|
53
|
-
const content =
|
|
53
|
+
const content = await readFile(check.resolved, 'utf-8');
|
|
54
54
|
return { success: true, content, size: content.length, path: check.resolved };
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
case 'agent_write_file': {
|
|
58
|
-
const check = checkPath(args.path);
|
|
58
|
+
const check = await checkPath(args.path);
|
|
59
59
|
if (!check.allowed) return { success: false, error: check.reason };
|
|
60
|
-
|
|
61
|
-
writeFileSync(check.resolved, String(args.content || ''), 'utf-8');
|
|
60
|
+
await writeFile(check.resolved, String(args.content || ''), 'utf-8');
|
|
62
61
|
return { success: true, message: `文件已写入: ${check.resolved}` };
|
|
63
62
|
}
|
|
64
63
|
|
|
65
64
|
case 'agent_list_dir': {
|
|
66
|
-
const check = checkPath(args.path || '.');
|
|
65
|
+
const check = await checkPath(args.path || '.');
|
|
67
66
|
if (!check.allowed) return { success: false, error: check.reason };
|
|
68
|
-
const
|
|
69
|
-
const entries =
|
|
70
|
-
const s =
|
|
67
|
+
const names = await readdir(check.resolved);
|
|
68
|
+
const entries = await Promise.all(names.map(async (name) => {
|
|
69
|
+
const s = await stat(join(check.resolved, name));
|
|
71
70
|
return { name, type: s.isDirectory() ? 'directory' : 'file', size: s.size };
|
|
72
|
-
});
|
|
71
|
+
}));
|
|
73
72
|
return { success: true, entries, path: check.resolved };
|
|
74
73
|
}
|
|
75
74
|
|
|
@@ -82,14 +81,21 @@ async function executeToolCall(toolName, args) {
|
|
|
82
81
|
if (cmdCheck.level === 'confirm') {
|
|
83
82
|
return { success: false, error: `需要用户确认的命令: ${cmdCheck.reason}` };
|
|
84
83
|
}
|
|
85
|
-
const {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
84
|
+
const { exec } = await import('child_process');
|
|
85
|
+
return new Promise((resolve) => {
|
|
86
|
+
exec(args.command, {
|
|
87
|
+
cwd: args.cwd || process.cwd(),
|
|
88
|
+
encoding: 'utf-8',
|
|
89
|
+
timeout: 30000,
|
|
90
|
+
maxBuffer: 10 * 1024 * 1024
|
|
91
|
+
}, (error, stdout, stderr) => {
|
|
92
|
+
if (error) {
|
|
93
|
+
resolve({ success: false, error: error.message, stdout, stderr });
|
|
94
|
+
} else {
|
|
95
|
+
resolve({ success: true, stdout, exitCode: 0, stderr });
|
|
96
|
+
}
|
|
97
|
+
});
|
|
91
98
|
});
|
|
92
|
-
return { success: true, stdout: result, exitCode: 0 };
|
|
93
99
|
}
|
|
94
100
|
|
|
95
101
|
case 'agent_search_files': {
|
package/src/skill/loader.js
CHANGED
|
@@ -197,7 +197,6 @@ export function loadAllMarkdownSkills(skillsDir) {
|
|
|
197
197
|
const skill = loadMarkdownSkill(entryPath);
|
|
198
198
|
if (skill) {
|
|
199
199
|
skills.push(skill);
|
|
200
|
-
console.log(`[Markdown Loader] 加载 Agent Skill: "${skill.name}" v${skill.version} (${skill.resources.length} 个资源)`);
|
|
201
200
|
}
|
|
202
201
|
} catch { /* skip */ }
|
|
203
202
|
}
|
package/src/skill/registry.js
CHANGED
|
@@ -75,12 +75,10 @@ export function initializeSkillRegistry() {
|
|
|
75
75
|
skills.clear();
|
|
76
76
|
|
|
77
77
|
// 0. 种子写入 skill-creator 到文件系统(首次运行或版本升级时)
|
|
78
|
-
|
|
79
|
-
console.log(`[Skill Registry] seedSkillCreator 返回: ${seeded}`);
|
|
78
|
+
seedSkillCreator();
|
|
80
79
|
|
|
81
80
|
// 1. 加载文件系统中的技能
|
|
82
81
|
const loaded = loadAllSkills();
|
|
83
|
-
console.log(`[Skill Registry] loadAllSkills 返回 ${loaded.length} 个技能: ${loaded.map(s => s.name).join(', ') || '(无)'}`);
|
|
84
82
|
|
|
85
83
|
for (const skill of loaded) {
|
|
86
84
|
skills.set(skill.name, skill);
|
|
@@ -91,7 +89,6 @@ export function initializeSkillRegistry() {
|
|
|
91
89
|
for (const skill of builtinSkills) {
|
|
92
90
|
if (!skills.has(skill.name)) {
|
|
93
91
|
skills.set(skill.name, skill);
|
|
94
|
-
console.log(`[Skill Registry] 注册内置 Skill: "${skill.name}"`);
|
|
95
92
|
}
|
|
96
93
|
}
|
|
97
94
|
|
|
@@ -212,7 +209,12 @@ export function getAgentSkillPrompts() {
|
|
|
212
209
|
const parts = [];
|
|
213
210
|
parts.push('## 可用技能 (Skills)');
|
|
214
211
|
parts.push('');
|
|
215
|
-
parts.push('
|
|
212
|
+
parts.push('以下是可用的 Agent Skill(AI 能力扩展)列表。当用户需求与某个技能高度匹配时,使用 `agent_skill_load` 工具加载该技能的完整说明,然后根据说明自主完成任务。');
|
|
213
|
+
parts.push('');
|
|
214
|
+
parts.push('**使用策略**:');
|
|
215
|
+
parts.push('- 仅在用户需求明确匹配技能描述时才加载,不要为一般性的编程/技术问题加载技能');
|
|
216
|
+
parts.push('- 一次只加载一个技能,加载后根据 SKILL.md 说明执行');
|
|
217
|
+
parts.push('- 技能加载会消耗较多 token,避免无故加载');
|
|
216
218
|
parts.push('');
|
|
217
219
|
|
|
218
220
|
for (const skill of agentSkills) {
|