ai-helper-agent 1.12.3 → 1.14.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/package.json +1 -1
- package/src/auth.js +19 -14
- package/src/executor.js +2 -1
- package/src/search.js +20 -7
- package/src/security.js +56 -7
- package/src/server.js +148 -27
- package/src/skill/executor.js +1 -0
- package/src/skill/markdown-loader.js +15 -5
package/package.json
CHANGED
package/src/auth.js
CHANGED
|
@@ -42,30 +42,35 @@ function getCurrentPairCode() {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
|
-
*
|
|
46
|
-
* -
|
|
47
|
-
* -
|
|
45
|
+
* 启动配对码定时刷新(运行中持续重新评估)
|
|
46
|
+
* - 每个 tick 检查:有配对记录且最近有活跃认证(5分钟内)→ 停止刷新配对码
|
|
47
|
+
* - 否则(无配对 / 插件离线超时)→ 刷新配对码,允许新扩展配对
|
|
48
|
+
* - 这样:插件在线时不刷新配对码;插件断开超时后自动恢复刷新
|
|
48
49
|
*/
|
|
49
50
|
function startPairCodeRotation() {
|
|
50
51
|
const config = loadConfig();
|
|
51
|
-
const pairings = loadPairings();
|
|
52
|
-
const hasPairings = Object.keys(pairings).length > 0;
|
|
53
|
-
const isRecentlyActive = (Date.now() - lastAuthTime) < ACTIVE_WINDOW_MS;
|
|
54
|
-
|
|
55
|
-
// 有配对且最近有活跃连接,无需刷新配对码
|
|
56
|
-
if (hasPairings && isRecentlyActive) {
|
|
57
|
-
currentPairCode = generatePairCode();
|
|
58
|
-
console.log(`\n[Agent] 配对码: ${currentPairCode}\n`);
|
|
59
|
-
return;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
52
|
const ttl = (config.pairCodeTTL || 30) * 1000;
|
|
63
53
|
|
|
54
|
+
// 启动时立即生成一个配对码(供首次配对;启动时 lastAuthTime=0,必然需要刷新)
|
|
64
55
|
currentPairCode = generatePairCode();
|
|
65
56
|
console.log(`\n[Agent] 配对码: ${currentPairCode}\n`);
|
|
66
57
|
|
|
67
58
|
if (pairCodeTimer) clearInterval(pairCodeTimer);
|
|
68
59
|
pairCodeTimer = setInterval(() => {
|
|
60
|
+
const pairings = loadPairings();
|
|
61
|
+
const hasPairings = Object.keys(pairings).length > 0;
|
|
62
|
+
const isRecentlyActive = (Date.now() - lastAuthTime) < ACTIVE_WINDOW_MS;
|
|
63
|
+
|
|
64
|
+
// 有配对且最近有活跃连接:停止刷新配对码(插件在线,无需新配对)
|
|
65
|
+
if (hasPairings && isRecentlyActive) {
|
|
66
|
+
if (currentPairCode !== null) {
|
|
67
|
+
currentPairCode = null;
|
|
68
|
+
console.log('[Agent] 检测到活跃连接,停止刷新配对码');
|
|
69
|
+
}
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 无配对或插件离线超时:刷新配对码,允许新扩展配对
|
|
69
74
|
currentPairCode = generatePairCode();
|
|
70
75
|
console.log(`[Agent] 配对码已更新: ${currentPairCode}`);
|
|
71
76
|
}, ttl);
|
package/src/executor.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// agent/src/executor.js - 命令执行器(child_process + 流式输出)
|
|
2
2
|
import { spawn, exec } from 'child_process';
|
|
3
|
+
import { existsSync } from 'fs';
|
|
3
4
|
import crypto from 'crypto';
|
|
4
5
|
import os from 'os';
|
|
5
6
|
import { loadConfig } from './config.js';
|
|
@@ -23,7 +24,7 @@ function getShellForExec() {
|
|
|
23
24
|
];
|
|
24
25
|
for (const path of gitBashPaths) {
|
|
25
26
|
try {
|
|
26
|
-
if (
|
|
27
|
+
if (existsSync(path)) {
|
|
27
28
|
return { shell: path, args: ['-c'] };
|
|
28
29
|
}
|
|
29
30
|
} catch {}
|
package/src/search.js
CHANGED
|
@@ -231,13 +231,21 @@ export async function searchFiles(rootPath, filePattern = '*', recursive = true,
|
|
|
231
231
|
/**
|
|
232
232
|
* 使用 rg 搜索文件内容(快速)
|
|
233
233
|
*/
|
|
234
|
-
function searchContentWithRg(rootPath, pattern, filePattern, maxResults, contextLines) {
|
|
234
|
+
function searchContentWithRg(rootPath, pattern, filePattern, caseSensitive, recursive, maxResults, contextLines) {
|
|
235
235
|
return new Promise((resolve) => {
|
|
236
236
|
const args = [
|
|
237
237
|
'--no-heading', '--line-number',
|
|
238
238
|
'--max-count', String(maxResults),
|
|
239
239
|
'-C', String(contextLines)
|
|
240
240
|
];
|
|
241
|
+
// 大小写不敏感时添加 -i(与 searchContentNative 的 caseSensitive 语义对齐)
|
|
242
|
+
if (!caseSensitive) {
|
|
243
|
+
args.push('-i');
|
|
244
|
+
}
|
|
245
|
+
// 非递归搜索:仅搜索直接子文件,不进入子目录(与 fd --max-depth 1 语义一致)
|
|
246
|
+
if (!recursive) {
|
|
247
|
+
args.push('--max-depth', '1');
|
|
248
|
+
}
|
|
241
249
|
if (filePattern) {
|
|
242
250
|
args.push('--glob', filePattern);
|
|
243
251
|
}
|
|
@@ -255,10 +263,12 @@ function searchContentWithRg(rootPath, pattern, filePattern, maxResults, context
|
|
|
255
263
|
|
|
256
264
|
proc.stdout.on('data', (chunk) => stdout.push(chunk));
|
|
257
265
|
|
|
258
|
-
proc.on('close', (
|
|
266
|
+
proc.on('close', () => {
|
|
259
267
|
clearTimeout(timeout);
|
|
260
268
|
if (timedOut) return;
|
|
261
|
-
|
|
269
|
+
// 不再按退出码过滤:Windows 下 rg 可能返回退出码 2(错误)但仍带有效匹配结果,
|
|
270
|
+
// 原逻辑 `code !== 0 && code !== 1` 会把这种情况的输出一并丢弃,导致有匹配却返回 0 条。
|
|
271
|
+
// 只要 stdout 有内容就解析;stdout 为空时 parseRgOutput 自然返回 []。
|
|
262
272
|
const output = Buffer.concat(stdout).toString('utf-8').trim();
|
|
263
273
|
resolve(parseRgOutput(output, contextLines));
|
|
264
274
|
});
|
|
@@ -298,7 +308,7 @@ function parseRgOutput(output, contextLines) {
|
|
|
298
308
|
/**
|
|
299
309
|
* Node.js 原生搜索文件内容(回退方案,异步 + 分片防止阻塞事件循环)
|
|
300
310
|
*/
|
|
301
|
-
async function searchContentNative(rootPath, pattern, filePattern, caseSensitive, maxResults, contextLines) {
|
|
311
|
+
async function searchContentNative(rootPath, pattern, filePattern, caseSensitive, recursive, maxResults, contextLines) {
|
|
302
312
|
const results = [];
|
|
303
313
|
const searchPattern = caseSensitive ? pattern : pattern.toLowerCase();
|
|
304
314
|
|
|
@@ -319,6 +329,8 @@ async function searchContentNative(rootPath, pattern, filePattern, caseSensitive
|
|
|
319
329
|
if (entry.isDirectory()) {
|
|
320
330
|
if (DEFAULT_IGNORE_DIRS.has(entry.name)) continue;
|
|
321
331
|
if (entry.name.startsWith('.')) continue;
|
|
332
|
+
// 非递归搜索:跳过子目录,仅搜索当前目录下的文件
|
|
333
|
+
if (!recursive) continue;
|
|
322
334
|
await walk(fullPath);
|
|
323
335
|
} else if (entry.isFile()) {
|
|
324
336
|
if (filePattern && !matchGlob(entry.name, filePattern)) continue;
|
|
@@ -366,11 +378,12 @@ async function searchContentNative(rootPath, pattern, filePattern, caseSensitive
|
|
|
366
378
|
* @param {string} pattern - 搜索文本
|
|
367
379
|
* @param {string} [filePattern] - 可选文件类型过滤,如 "*.js"
|
|
368
380
|
* @param {boolean} caseSensitive - 是否大小写敏感
|
|
381
|
+
* @param {boolean} recursive - 是否递归搜索子目录
|
|
369
382
|
* @param {number} maxResults - 最大结果数
|
|
370
383
|
* @param {number} contextLines - 上下文行数
|
|
371
384
|
* @returns {{ success: boolean, results: Array, total: number, engine: string }}
|
|
372
385
|
*/
|
|
373
|
-
export async function searchContent(rootPath, pattern, filePattern = null, caseSensitive = false, maxResults = 100, contextLines = 2) {
|
|
386
|
+
export async function searchContent(rootPath, pattern, filePattern = null, caseSensitive = false, recursive = true, maxResults = 100, contextLines = 2) {
|
|
374
387
|
const pathCheck = await checkPath(rootPath);
|
|
375
388
|
if (!pathCheck.allowed) {
|
|
376
389
|
return { success: false, error: pathCheck.reason };
|
|
@@ -389,9 +402,9 @@ export async function searchContent(rootPath, pattern, filePattern = null, caseS
|
|
|
389
402
|
|
|
390
403
|
let results;
|
|
391
404
|
if (rgAvailable) {
|
|
392
|
-
results = await searchContentWithRg(resolved, pattern, filePattern, maxResults, contextLines);
|
|
405
|
+
results = await searchContentWithRg(resolved, pattern, filePattern, caseSensitive, recursive, maxResults, contextLines);
|
|
393
406
|
} else {
|
|
394
|
-
results = await searchContentNative(resolved, pattern, filePattern, caseSensitive, maxResults, contextLines);
|
|
407
|
+
results = await searchContentNative(resolved, pattern, filePattern, caseSensitive, recursive, maxResults, contextLines);
|
|
395
408
|
}
|
|
396
409
|
|
|
397
410
|
return {
|
package/src/security.js
CHANGED
|
@@ -8,13 +8,22 @@ const isWin32 = platform() === 'win32';
|
|
|
8
8
|
|
|
9
9
|
function normalizePathFormat(pathStr) {
|
|
10
10
|
if (!pathStr || typeof pathStr !== 'string') return pathStr;
|
|
11
|
-
|
|
11
|
+
// 1. 把异常的 "/C:/" 形式(带前导斜杠的盘符)规整为 "C:/"
|
|
12
|
+
// 仅匹配 /<字母>:/ ,不会误伤 /home/ 等普通路径段
|
|
13
|
+
pathStr = pathStr.replace(/\/([a-zA-Z]):(\/|$)/g, '$1:$2');
|
|
14
|
+
// 2. MSYS/Git Bash 风格路径:/d/Users/... → D:/Users/...
|
|
15
|
+
// 前导 / + 单字母盘符 + / ,无冒号,是 MSYS 特有的挂载点表示法
|
|
12
16
|
if (isWin32) {
|
|
13
|
-
pathStr = pathStr.replace(
|
|
14
|
-
|
|
15
|
-
|
|
17
|
+
pathStr = pathStr.replace(/^\/([a-zA-Z])\//, '$1:/');
|
|
18
|
+
}
|
|
19
|
+
// 3. 统一为正斜杠(path.resolve/normalize 在所有平台都能正确处理正斜杠并归一化到平台分隔符)
|
|
20
|
+
pathStr = pathStr.replace(/\\/g, '/');
|
|
21
|
+
// 4. Windows 盘符统一为大写:d:/ → D:/
|
|
22
|
+
// path.resolve 保留盘符原始大小写,而白名单前缀比较是大小写敏感的字符串比较,
|
|
23
|
+
// 小写盘符路径与大写盘符白名单前缀比较会失败(d:\Users\.startsWith(D:\Users\) → false)
|
|
24
|
+
if (isWin32) {
|
|
25
|
+
pathStr = pathStr.replace(/^([a-zA-Z]):/, m => m.toUpperCase());
|
|
16
26
|
}
|
|
17
|
-
|
|
18
27
|
return pathStr;
|
|
19
28
|
}
|
|
20
29
|
|
|
@@ -90,6 +99,28 @@ async function checkPath(pathStr) {
|
|
|
90
99
|
return { allowed: true, resolved: normalized };
|
|
91
100
|
}
|
|
92
101
|
|
|
102
|
+
// 0.6. 工作目录永远放行(不受 allowedPaths 配置控制)
|
|
103
|
+
// workdir 是用户当前选定的操作目录,必须始终可访问,即使未在 allowedPaths 中也不应被拦截
|
|
104
|
+
if (normalizedWorkdir) {
|
|
105
|
+
const workdirResolved = resolve(normalizedWorkdir);
|
|
106
|
+
const workdirPrefix = workdirResolved.endsWith(sep) ? workdirResolved : workdirResolved + sep;
|
|
107
|
+
if (normalized.startsWith(workdirPrefix) || normalized === workdirResolved) {
|
|
108
|
+
// realpath 硬阻止检查:防止通过 workdir 内的符号链接绕过到代理系统文件
|
|
109
|
+
try {
|
|
110
|
+
const realPath = await realpath(resolved);
|
|
111
|
+
if (isHardBlocked(realPath)) {
|
|
112
|
+
return { allowed: false, reason: '禁止访问代理系统文件' };
|
|
113
|
+
}
|
|
114
|
+
} catch (err) {
|
|
115
|
+
if (err.code !== 'ENOENT') {
|
|
116
|
+
return { allowed: false, reason: `无法解析路径: ${err.message}` };
|
|
117
|
+
}
|
|
118
|
+
// ENOENT:路径尚不存在(如待创建的文件),无法通过符号链接绕过,放行
|
|
119
|
+
}
|
|
120
|
+
return { allowed: true, resolved: normalized };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
93
124
|
const allowedPaths = config.allowedPaths.length > 0 ? config.allowedPaths.map(p => normalizePathFormat(p)) : [normalizedWorkdir];
|
|
94
125
|
|
|
95
126
|
// 先做前缀检查(快速路径,兼容 Windows/Unix)
|
|
@@ -188,6 +219,11 @@ const BLACKLIST_PATTERNS = [
|
|
|
188
219
|
// Shell 命令替换注入 — 允许 echo/printf 等纯输出命令中使用
|
|
189
220
|
/^\s*(?!(echo|printf|cat|head|tail|wc|ls|pwd|date|whoami|hostname|uname|id|env|printenv|which|type)\s).*`[^`]*`/,
|
|
190
221
|
/^\s*(?!(echo|printf|cat|head|tail|wc|ls|pwd|date|whoami|hostname|uname|id|env|printenv|which|type)\s).*\$\s*\([^)]*\)/,
|
|
222
|
+
// Windows 高危破坏性命令(大小写不敏感)
|
|
223
|
+
/^\s*format\s+[a-zA-Z]:/i, // 格式化磁盘
|
|
224
|
+
/^\s*diskpart\b/i, // 磁盘分区操作
|
|
225
|
+
/^\s*>\s*[a-zA-Z]:[\\\/][^\n]*[\\\/]Windows[\\\/]System32[\\\/]config[\\\/](SAM|SYSTEM|SECURITY|DEFAULT|NTUSER)/i, // 覆盖注册表蜂巢
|
|
226
|
+
/^\s*>\s*[a-zA-Z]:[\\\/][^\n]*[\\\/]drivers[\\\/]etc[\\\/]hosts/i, // 覆盖 hosts
|
|
191
227
|
];
|
|
192
228
|
const SCRIPT_EXTENSIONS = '(sh|bash|zsh|py|js|mjs|rb|pl|php|lua)';
|
|
193
229
|
const SCRIPT_INTERPRETERS = '(bash|sh|zsh|python3?|node|ruby|perl|php|lua)';
|
|
@@ -208,7 +244,17 @@ const GRAYLIST_PATTERNS = [
|
|
|
208
244
|
// 直接执行脚本
|
|
209
245
|
{ pattern: new RegExp(`^\\s*\\.?\\/\\S*\\.${SCRIPT_EXTENSIONS}\\b`), reason: '直接执行脚本文件' },
|
|
210
246
|
// chmod +x 授予执行权限
|
|
211
|
-
{ pattern: /^\s*chmod\s+(\+x|a\+x|u\+x|g\+x|o\+x|[0-7]*[1-7][0-
|
|
247
|
+
{ pattern: /^\s*chmod\s+(\+x|a\+x|u\+x|g\+x|o\+x|[0-7]*[1-7][0-9][0-9])\s/, reason: '修改文件执行权限' },
|
|
248
|
+
|
|
249
|
+
// Windows 命令(大小写不敏感)
|
|
250
|
+
{ pattern: /^\s*rmdir?\s+\/s\b/i, reason: '递归删除目录 (rd/rmdir /s)' },
|
|
251
|
+
{ pattern: /^\s*(del|erase)\s+\/[a-zA-Z]*f/i, reason: '强制删除文件 (del /f)' },
|
|
252
|
+
{ pattern: /^\s*icacls\b/i, reason: '修改文件权限 (icacls)' },
|
|
253
|
+
{ pattern: /^\s*takeown\b/i, reason: '夺取文件所有权 (takeown)' },
|
|
254
|
+
{ pattern: /^\s*reg\s+(add|delete|import|load|restore)\b/i, reason: '修改注册表 (reg)' },
|
|
255
|
+
{ pattern: /^\s*net\s+(user|localgroup)\b/i, reason: '用户/用户组管理 (net)' },
|
|
256
|
+
{ pattern: /^\s*sc\s+(stop|delete|config)\b/i, reason: '服务管理 (sc)' },
|
|
257
|
+
{ pattern: /^\s*taskkill\s+\/[a-zA-Z]*f/i, reason: '强制结束进程 (taskkill /f)' },
|
|
212
258
|
];
|
|
213
259
|
|
|
214
260
|
/**
|
|
@@ -235,8 +281,11 @@ function checkCommand(command, force = false) {
|
|
|
235
281
|
}
|
|
236
282
|
|
|
237
283
|
// 1.5 禁止访问核心敏感文件(通过命令也一样拦截)
|
|
284
|
+
// Windows 文件系统不区分大小写,归一化比较防止 .AI-HELPER-AGENT 等大小写变换绕过
|
|
285
|
+
const cmpTrimmed = isWin32 ? trimmed.toLowerCase() : trimmed;
|
|
238
286
|
for (const blocked of HARD_BLOCKED_PATHS) {
|
|
239
|
-
|
|
287
|
+
const cmpBlocked = isWin32 ? blocked.toLowerCase() : blocked;
|
|
288
|
+
if (cmpTrimmed.includes(cmpBlocked)) {
|
|
240
289
|
return {
|
|
241
290
|
safe: false,
|
|
242
291
|
level: 'deny',
|
package/src/server.js
CHANGED
|
@@ -3,14 +3,14 @@ import http from 'http';
|
|
|
3
3
|
import { WebSocketServer } from 'ws';
|
|
4
4
|
import { readFileSync, createWriteStream, createReadStream, statSync, existsSync } from 'fs';
|
|
5
5
|
import { readFile, writeFile, readdir, stat, unlink, rmdir, chmod, mkdir, rename } from 'fs/promises';
|
|
6
|
-
import { join, dirname, basename, resolve } from 'path';
|
|
7
|
-
import { fileURLToPath } from 'url';
|
|
6
|
+
import { join, dirname, basename, resolve, isAbsolute, relative } from 'path';
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from 'url';
|
|
8
8
|
import { ZipArchive } from 'archiver';
|
|
9
9
|
import { homedir, tmpdir } from 'os';
|
|
10
10
|
import { spawn } from 'child_process';
|
|
11
11
|
import { randomBytes } from 'crypto';
|
|
12
12
|
import os from 'os';
|
|
13
|
-
import { loadConfig } from './config.js';
|
|
13
|
+
import { loadConfig, saveConfig } from './config.js';
|
|
14
14
|
import { verifyToken, startPairCodeRotation, stopPairCodeRotation, handlePairRequest } from './auth.js';
|
|
15
15
|
import { checkPath, checkCommand } from './security.js';
|
|
16
16
|
import { moveToTrash, restoreFromTrash, listTrash, startPeriodicCleanup, stopPeriodicCleanup } from './trash.js';
|
|
@@ -50,6 +50,30 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
50
50
|
const AGENT_VERSION = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8')).version;
|
|
51
51
|
const AGENT_START_TIME = Date.now();
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* 计算多个路径的共同父目录(跨平台,正确处理 Windows 盘符)
|
|
55
|
+
* 用 path.dirname + path.relative 替代手工 split('/'),避免破坏盘符(如 /C:/Users)
|
|
56
|
+
* @param {string[]} paths - 绝对路径数组
|
|
57
|
+
* @returns {string} 共同父目录(始终为目录路径)
|
|
58
|
+
*/
|
|
59
|
+
function computeCommonParent(paths) {
|
|
60
|
+
if (!paths || paths.length === 0) return '';
|
|
61
|
+
let common = dirname(resolve(paths[0]));
|
|
62
|
+
for (let i = 1; i < paths.length; i++) {
|
|
63
|
+
const p = resolve(paths[i]);
|
|
64
|
+
// 逐层上移 common,直到 p 落在 common 之下或 common 已到根
|
|
65
|
+
while (true) {
|
|
66
|
+
const parent = dirname(common);
|
|
67
|
+
if (parent === common) break; // 已到根目录(如 / 或 C:\)
|
|
68
|
+
const rel = relative(common, p);
|
|
69
|
+
// rel 为空表示同路径;不以 '..' 开头且非绝对路径表示 p 在 common 之下
|
|
70
|
+
if (rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))) break;
|
|
71
|
+
common = parent;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return common;
|
|
75
|
+
}
|
|
76
|
+
|
|
53
77
|
/**
|
|
54
78
|
* 跨平台 ZIP 打包(使用 archiver,替换 spawn zip)
|
|
55
79
|
* @param {string[]} sourceNames - 源文件/目录名(basename)
|
|
@@ -397,6 +421,10 @@ export function startServer() {
|
|
|
397
421
|
}
|
|
398
422
|
const pathname = url.pathname;
|
|
399
423
|
|
|
424
|
+
// 每次请求重新加载配置(切换 workdir 后立即生效,避免启动快照过期)
|
|
425
|
+
// 遮蔽 startServer 外层的启动快照 config;loadConfig 有 mtime 缓存,无变化时仅一次 statSync
|
|
426
|
+
const config = loadConfig();
|
|
427
|
+
|
|
400
428
|
// ---------- 无需认证的接口 ----------
|
|
401
429
|
|
|
402
430
|
// 配对
|
|
@@ -440,6 +468,11 @@ export function startServer() {
|
|
|
440
468
|
return jsonResponse(res, 403, { success: false, error: '认证 token 无效' });
|
|
441
469
|
}
|
|
442
470
|
|
|
471
|
+
// 心跳接口(需认证):仅刷新 lastAuthTime 维持"插件在线"状态,不返回敏感信息
|
|
472
|
+
if (req.method === 'GET' && pathname === '/api/heartbeat') {
|
|
473
|
+
return jsonResponse(res, 200, { success: true, time: Date.now() });
|
|
474
|
+
}
|
|
475
|
+
|
|
443
476
|
// Agent 关闭(需认证)
|
|
444
477
|
if (req.method === 'POST' && pathname === '/api/shutdown') {
|
|
445
478
|
if (agentOperationInProgress) {
|
|
@@ -754,6 +787,114 @@ export function startServer() {
|
|
|
754
787
|
try { body = await parseBody(req); }
|
|
755
788
|
catch (err) { return jsonResponse(res, 400, { success: false, error: err.message }); }
|
|
756
789
|
|
|
790
|
+
// === 工作目录切换(需认证) ===
|
|
791
|
+
if (pathname === '/api/config/workdir' && req.method === 'POST') {
|
|
792
|
+
const newWorkdir = body.workdir;
|
|
793
|
+
if (!newWorkdir || typeof newWorkdir !== 'string') {
|
|
794
|
+
return jsonResponse(res, 400, { success: false, error: '缺少 workdir 参数' });
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// 展开 ~ 到用户主目录
|
|
798
|
+
let resolvedWorkdir = newWorkdir.trim();
|
|
799
|
+
if (resolvedWorkdir === '~') {
|
|
800
|
+
resolvedWorkdir = homedir();
|
|
801
|
+
} else if (resolvedWorkdir.startsWith('~/')) {
|
|
802
|
+
resolvedWorkdir = join(homedir(), resolvedWorkdir.slice(2));
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// 必须是绝对路径(跨平台:path.isAbsolute 覆盖 Unix /、Windows 盘符、UNC 路径)
|
|
806
|
+
if (!isAbsolute(resolvedWorkdir)) {
|
|
807
|
+
return jsonResponse(res, 400, { success: false, error: 'workdir 必须是绝对路径' });
|
|
808
|
+
}
|
|
809
|
+
resolvedWorkdir = resolve(resolvedWorkdir);
|
|
810
|
+
|
|
811
|
+
// 禁止设为 Agent 系统目录本身(防敏感文件暴露)
|
|
812
|
+
const AGENT_DIR_PATH = join(homedir(), '.ai-helper-agent');
|
|
813
|
+
if (resolvedWorkdir === AGENT_DIR_PATH) {
|
|
814
|
+
logSecurity('workdir_switch_blocked', { path: resolvedWorkdir, reason: '禁止设为 Agent 系统目录' });
|
|
815
|
+
return jsonResponse(res, 403, { success: false, error: '不能将 Agent 系统目录设为工作目录' });
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
try {
|
|
819
|
+
// 目录不存在则自动创建(mkdir -p)
|
|
820
|
+
await mkdir(resolvedWorkdir, { recursive: true });
|
|
821
|
+
|
|
822
|
+
// 重新加载最新配置(防并发修改),更新 workdir + allowedPaths(只增不减)
|
|
823
|
+
const freshConfig = loadConfig();
|
|
824
|
+
const updatedConfig = { ...freshConfig };
|
|
825
|
+
updatedConfig.workdir = resolvedWorkdir;
|
|
826
|
+
if (!Array.isArray(updatedConfig.allowedPaths)) {
|
|
827
|
+
updatedConfig.allowedPaths = [resolvedWorkdir];
|
|
828
|
+
} else if (!updatedConfig.allowedPaths.includes(resolvedWorkdir)) {
|
|
829
|
+
updatedConfig.allowedPaths = [...updatedConfig.allowedPaths, resolvedWorkdir];
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
await saveConfig(updatedConfig);
|
|
833
|
+
|
|
834
|
+
logSystem('workdir_switched', { old: freshConfig.workdir, new: resolvedWorkdir, extId });
|
|
835
|
+
return jsonResponse(res, 200, {
|
|
836
|
+
success: true,
|
|
837
|
+
workdir: resolvedWorkdir,
|
|
838
|
+
allowedPaths: updatedConfig.allowedPaths,
|
|
839
|
+
message: '工作目录已切换'
|
|
840
|
+
});
|
|
841
|
+
} catch (err) {
|
|
842
|
+
logError('config', 'workdir_switch_error', { path: resolvedWorkdir, error: err.message });
|
|
843
|
+
return jsonResponse(res, 500, { success: false, error: `切换工作目录失败: ${err.message}` });
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// === 移除允许访问的目录(需认证,不可移除当前工作目录) ===
|
|
848
|
+
if (pathname === '/api/config/allowed-paths/remove' && req.method === 'POST') {
|
|
849
|
+
const targetPath = body.path;
|
|
850
|
+
if (!targetPath || typeof targetPath !== 'string') {
|
|
851
|
+
return jsonResponse(res, 400, { success: false, error: '缺少 path 参数' });
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
let resolvedPath = targetPath.trim();
|
|
855
|
+
if (resolvedPath === '~') {
|
|
856
|
+
resolvedPath = homedir();
|
|
857
|
+
} else if (resolvedPath.startsWith('~/')) {
|
|
858
|
+
resolvedPath = join(homedir(), resolvedPath.slice(2));
|
|
859
|
+
}
|
|
860
|
+
if (!isAbsolute(resolvedPath)) {
|
|
861
|
+
return jsonResponse(res, 400, { success: false, error: 'path 必须是绝对路径' });
|
|
862
|
+
}
|
|
863
|
+
resolvedPath = resolve(resolvedPath);
|
|
864
|
+
|
|
865
|
+
try {
|
|
866
|
+
const freshConfig = loadConfig();
|
|
867
|
+
const currentWorkdir = resolve(freshConfig.workdir || '');
|
|
868
|
+
// 禁止移除当前工作目录
|
|
869
|
+
if (resolvedPath === currentWorkdir) {
|
|
870
|
+
return jsonResponse(res, 400, { success: false, error: '不能移除当前工作目录,请先切换到其它目录' });
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
const allowedPaths = Array.isArray(freshConfig.allowedPaths) ? freshConfig.allowedPaths : [];
|
|
874
|
+
// 跨平台路径比较:统一分隔符 + 大小写不敏感
|
|
875
|
+
const norm = s => s.replace(/\\/g, '/').toLowerCase();
|
|
876
|
+
const filtered = allowedPaths.filter(p => norm(p) !== norm(resolvedPath));
|
|
877
|
+
|
|
878
|
+
// 没有变化说明该路径不在列表中
|
|
879
|
+
if (filtered.length === allowedPaths.length) {
|
|
880
|
+
return jsonResponse(res, 200, { success: true, allowedPaths: filtered, message: '该目录不在允许列表中' });
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
const updatedConfig = { ...freshConfig, allowedPaths: filtered };
|
|
884
|
+
await saveConfig(updatedConfig);
|
|
885
|
+
|
|
886
|
+
logSystem('allowed_path_removed', { path: resolvedPath, extId });
|
|
887
|
+
return jsonResponse(res, 200, {
|
|
888
|
+
success: true,
|
|
889
|
+
allowedPaths: updatedConfig.allowedPaths,
|
|
890
|
+
message: '已从允许列表中移除'
|
|
891
|
+
});
|
|
892
|
+
} catch (err) {
|
|
893
|
+
logError('config', 'allowed_path_remove_error', { path: resolvedPath, error: err.message });
|
|
894
|
+
return jsonResponse(res, 500, { success: false, error: `移除允许目录失败: ${err.message}` });
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
757
898
|
// === 文件操作 ===
|
|
758
899
|
|
|
759
900
|
// 搜索文件(按文件名模式)
|
|
@@ -787,6 +928,7 @@ export function startServer() {
|
|
|
787
928
|
body.pattern,
|
|
788
929
|
body.filePattern || null,
|
|
789
930
|
body.caseSensitive || false,
|
|
931
|
+
body.recursive !== false,
|
|
790
932
|
maxResults,
|
|
791
933
|
body.contextLines !== undefined ? body.contextLines : 2
|
|
792
934
|
);
|
|
@@ -1012,19 +1154,7 @@ export function startServer() {
|
|
|
1012
1154
|
const tmpFile = join(tmpdir(), `ws-dl-${randomBytes(6).toString('hex')}.zip`);
|
|
1013
1155
|
try {
|
|
1014
1156
|
const zipArgs = resolvedPaths.map(p => basename(p));
|
|
1015
|
-
const commonParent = resolvedPaths
|
|
1016
|
-
const sep = '/';
|
|
1017
|
-
const partsA = a.replace(/\\/g, '/').split('/').filter(Boolean);
|
|
1018
|
-
const partsB = b.replace(/\\/g, '/').split('/').filter(Boolean);
|
|
1019
|
-
const common = [];
|
|
1020
|
-
for (let i = 0; i < Math.min(partsA.length, partsB.length); i++) {
|
|
1021
|
-
if (partsA[i] === partsB[i]) common.push(partsA[i]);
|
|
1022
|
-
else break;
|
|
1023
|
-
}
|
|
1024
|
-
const result = sep + common.join(sep);
|
|
1025
|
-
// Windows 盘符处理:C:/Users -> 而不是 /C:/Users
|
|
1026
|
-
return result;
|
|
1027
|
-
});
|
|
1157
|
+
const commonParent = computeCommonParent(resolvedPaths);
|
|
1028
1158
|
|
|
1029
1159
|
const zipBuffer = await createZipBuffer(zipArgs, commonParent, tmpFile);
|
|
1030
1160
|
logFs('download_multi', { paths: resolvedPaths, zipSize: zipBuffer.length });
|
|
@@ -1105,16 +1235,7 @@ export function startServer() {
|
|
|
1105
1235
|
const tmpFile = join(tmpdir(), `ws-dl-${randomBytes(6).toString('hex')}.zip`);
|
|
1106
1236
|
try {
|
|
1107
1237
|
const zipArgs = resolvedPaths.map(p => basename(p));
|
|
1108
|
-
const commonParent = resolvedPaths
|
|
1109
|
-
const partsA = a.split('/').filter(Boolean);
|
|
1110
|
-
const partsB = b.split('/').filter(Boolean);
|
|
1111
|
-
const common = [];
|
|
1112
|
-
for (let i = 0; i < Math.min(partsA.length, partsB.length); i++) {
|
|
1113
|
-
if (partsA[i] === partsB[i]) common.push(partsA[i]);
|
|
1114
|
-
else break;
|
|
1115
|
-
}
|
|
1116
|
-
return '/' + common.join('/');
|
|
1117
|
-
});
|
|
1238
|
+
const commonParent = computeCommonParent(resolvedPaths);
|
|
1118
1239
|
const zipBuffer = await createZipBuffer(zipArgs, commonParent, tmpFile);
|
|
1119
1240
|
logFs('download_multi_stream', { paths: resolvedPaths, zipSize: zipBuffer.length });
|
|
1120
1241
|
const zipName = `workspace_${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.zip`;
|
|
@@ -1349,7 +1470,7 @@ export function startServer() {
|
|
|
1349
1470
|
return jsonResponse(res, 404, { success: false, error: '文件不存在' });
|
|
1350
1471
|
}
|
|
1351
1472
|
|
|
1352
|
-
const fileUrl =
|
|
1473
|
+
const fileUrl = pathToFileURL(check.resolved).href;
|
|
1353
1474
|
const platform = os.platform();
|
|
1354
1475
|
let cmd, args, spawnOpts;
|
|
1355
1476
|
if (platform === 'darwin') {
|
package/src/skill/executor.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// skill/markdown-loader.js - Markdown Skill 加载器
|
|
2
2
|
// 扫描子目录中的 SKILL.md,解析 YAML frontmatter 和正文内容
|
|
3
|
-
import { readFileSync, readdirSync, existsSync, statSync, writeFileSync, mkdirSync, rmSync } from 'fs';
|
|
3
|
+
import { readFileSync, readdirSync, existsSync, statSync, writeFileSync, mkdirSync, rmSync, cpSync } from 'fs';
|
|
4
4
|
import { join, basename, extname, normalize, sep } from 'path';
|
|
5
5
|
import { execFile } from 'child_process';
|
|
6
6
|
import { promisify } from 'util';
|
|
@@ -359,8 +359,18 @@ export async function importMarkdownSkillFromZip(skillsDir, zipBuffer, skillName
|
|
|
359
359
|
mkdirSync(tmpDir, { recursive: true });
|
|
360
360
|
writeFileSync(tmpZip, zipBuffer);
|
|
361
361
|
|
|
362
|
-
//
|
|
363
|
-
|
|
362
|
+
// 解压到临时目录(跨平台:Windows 用 PowerShell Expand-Archive,Unix 用 unzip)
|
|
363
|
+
if (process.platform === 'win32') {
|
|
364
|
+
// PowerShell 单引号转义:路径中的单引号用两个单引号表示(tmpZip/tmpDir 由程序生成,正常不含单引号)
|
|
365
|
+
const esc = (s) => s.replace(/'/g, "''");
|
|
366
|
+
await execFileAsync('powershell.exe',
|
|
367
|
+
['-NoProfile', '-NonInteractive', '-Command',
|
|
368
|
+
`Expand-Archive -LiteralPath '${esc(tmpZip)}' -DestinationPath '${esc(tmpDir)}' -Force`],
|
|
369
|
+
{ timeout: 30000, windowsHide: true });
|
|
370
|
+
} else {
|
|
371
|
+
// execFile 数组参数,不经 shell,杜绝命令注入
|
|
372
|
+
await execFileAsync('unzip', ['-o', tmpZip, '-d', tmpDir], { timeout: 30000, windowsHide: true });
|
|
373
|
+
}
|
|
364
374
|
|
|
365
375
|
// 查找 SKILL.md
|
|
366
376
|
const entries = readdirSync(tmpDir);
|
|
@@ -413,8 +423,8 @@ export async function importMarkdownSkillFromZip(skillsDir, zipBuffer, skillName
|
|
|
413
423
|
rmSync(destDir, { recursive: true, force: true });
|
|
414
424
|
}
|
|
415
425
|
|
|
416
|
-
//
|
|
417
|
-
|
|
426
|
+
// 复制(跨平台:fs.cpSync 替代 cp -r,Windows 无 cp 命令;destDir 已被 rmSync 清空)
|
|
427
|
+
cpSync(skillDir, destDir, { recursive: true });
|
|
418
428
|
|
|
419
429
|
// 重新加载
|
|
420
430
|
const skill = loadMarkdownSkill(destDir);
|