ai-helper-agent 1.12.2 → 1.13.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 +5 -6
- package/src/security.js +48 -10
- package/src/server.js +99 -28
- package/src/skill/executor.js +64 -39
- package/src/skill/loader.js +7 -3
- package/src/skill/markdown-loader.js +15 -5
- package/src/skill/registry.js +2 -2
- package/src/skill/skill-creator-seed.js +10 -8
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 {}
|
|
@@ -370,10 +371,8 @@ function killProcess(execId) {
|
|
|
370
371
|
|
|
371
372
|
if (isWin) {
|
|
372
373
|
// Windows: /T 递归终止整个进程树
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
exec(`taskkill /F /T /PID ${pid}`, () => {});
|
|
376
|
-
}
|
|
374
|
+
spawn('taskkill', ['/T', '/PID', String(pid)], { windowsHide: true }).on('error', () => {
|
|
375
|
+
spawn('taskkill', ['/F', '/T', '/PID', String(pid)], { windowsHide: true });
|
|
377
376
|
});
|
|
378
377
|
} else {
|
|
379
378
|
// Unix: 负 PID 表示杀整个进程组(detached + 未调用 setsid 时有效)
|
|
@@ -399,7 +398,7 @@ function killProcess(execId) {
|
|
|
399
398
|
try {
|
|
400
399
|
if (entry.process.exitCode === null) {
|
|
401
400
|
if (isWin) {
|
|
402
|
-
|
|
401
|
+
spawn('taskkill', ['/F', '/T', '/PID', String(pid)], { windowsHide: true });
|
|
403
402
|
} else {
|
|
404
403
|
try { process.kill(-pid, 'SIGKILL'); }
|
|
405
404
|
catch { try { entry.process.kill('SIGKILL'); } catch {} }
|
package/src/security.js
CHANGED
|
@@ -8,14 +8,12 @@ const isWin32 = platform() === 'win32';
|
|
|
8
8
|
|
|
9
9
|
function normalizePathFormat(pathStr) {
|
|
10
10
|
if (!pathStr || typeof pathStr !== 'string') return pathStr;
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
return pathStr;
|
|
11
|
+
// 防御性修复:把异常的 "/C:/" 形式(带前导斜杠的盘符)规整为 "C:/"
|
|
12
|
+
// 仅匹配 /<字母>:/ ,不会误伤 /home/ 等普通路径段
|
|
13
|
+
pathStr = pathStr.replace(/\/([a-zA-Z]):(\/|$)/g, '$1:$2');
|
|
14
|
+
// 统一为正斜杠(path.resolve/normalize 在所有平台都能正确处理正斜杠并归一化到平台分隔符)
|
|
15
|
+
// 不再做盘符相关的激进转换,避免把 /home/user 误转为 home:\user
|
|
16
|
+
return pathStr.replace(/\\/g, '/');
|
|
19
17
|
}
|
|
20
18
|
|
|
21
19
|
// ==================== 硬阻止目录(任何情况下都不可访问) ====================
|
|
@@ -90,6 +88,28 @@ async function checkPath(pathStr) {
|
|
|
90
88
|
return { allowed: true, resolved: normalized };
|
|
91
89
|
}
|
|
92
90
|
|
|
91
|
+
// 0.6. 工作目录永远放行(不受 allowedPaths 配置控制)
|
|
92
|
+
// workdir 是用户当前选定的操作目录,必须始终可访问,即使未在 allowedPaths 中也不应被拦截
|
|
93
|
+
if (normalizedWorkdir) {
|
|
94
|
+
const workdirResolved = resolve(normalizedWorkdir);
|
|
95
|
+
const workdirPrefix = workdirResolved.endsWith(sep) ? workdirResolved : workdirResolved + sep;
|
|
96
|
+
if (normalized.startsWith(workdirPrefix) || normalized === workdirResolved) {
|
|
97
|
+
// realpath 硬阻止检查:防止通过 workdir 内的符号链接绕过到代理系统文件
|
|
98
|
+
try {
|
|
99
|
+
const realPath = await realpath(resolved);
|
|
100
|
+
if (isHardBlocked(realPath)) {
|
|
101
|
+
return { allowed: false, reason: '禁止访问代理系统文件' };
|
|
102
|
+
}
|
|
103
|
+
} catch (err) {
|
|
104
|
+
if (err.code !== 'ENOENT') {
|
|
105
|
+
return { allowed: false, reason: `无法解析路径: ${err.message}` };
|
|
106
|
+
}
|
|
107
|
+
// ENOENT:路径尚不存在(如待创建的文件),无法通过符号链接绕过,放行
|
|
108
|
+
}
|
|
109
|
+
return { allowed: true, resolved: normalized };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
93
113
|
const allowedPaths = config.allowedPaths.length > 0 ? config.allowedPaths.map(p => normalizePathFormat(p)) : [normalizedWorkdir];
|
|
94
114
|
|
|
95
115
|
// 先做前缀检查(快速路径,兼容 Windows/Unix)
|
|
@@ -188,6 +208,11 @@ const BLACKLIST_PATTERNS = [
|
|
|
188
208
|
// Shell 命令替换注入 — 允许 echo/printf 等纯输出命令中使用
|
|
189
209
|
/^\s*(?!(echo|printf|cat|head|tail|wc|ls|pwd|date|whoami|hostname|uname|id|env|printenv|which|type)\s).*`[^`]*`/,
|
|
190
210
|
/^\s*(?!(echo|printf|cat|head|tail|wc|ls|pwd|date|whoami|hostname|uname|id|env|printenv|which|type)\s).*\$\s*\([^)]*\)/,
|
|
211
|
+
// Windows 高危破坏性命令(大小写不敏感)
|
|
212
|
+
/^\s*format\s+[a-zA-Z]:/i, // 格式化磁盘
|
|
213
|
+
/^\s*diskpart\b/i, // 磁盘分区操作
|
|
214
|
+
/^\s*>\s*[a-zA-Z]:[\\\/][^\n]*[\\\/]Windows[\\\/]System32[\\\/]config[\\\/](SAM|SYSTEM|SECURITY|DEFAULT|NTUSER)/i, // 覆盖注册表蜂巢
|
|
215
|
+
/^\s*>\s*[a-zA-Z]:[\\\/][^\n]*[\\\/]drivers[\\\/]etc[\\\/]hosts/i, // 覆盖 hosts
|
|
191
216
|
];
|
|
192
217
|
const SCRIPT_EXTENSIONS = '(sh|bash|zsh|py|js|mjs|rb|pl|php|lua)';
|
|
193
218
|
const SCRIPT_INTERPRETERS = '(bash|sh|zsh|python3?|node|ruby|perl|php|lua)';
|
|
@@ -208,7 +233,17 @@ const GRAYLIST_PATTERNS = [
|
|
|
208
233
|
// 直接执行脚本
|
|
209
234
|
{ pattern: new RegExp(`^\\s*\\.?\\/\\S*\\.${SCRIPT_EXTENSIONS}\\b`), reason: '直接执行脚本文件' },
|
|
210
235
|
// chmod +x 授予执行权限
|
|
211
|
-
{ pattern: /^\s*chmod\s+(\+x|a\+x|u\+x|g\+x|o\+x|[0-7]*[1-7][0-
|
|
236
|
+
{ pattern: /^\s*chmod\s+(\+x|a\+x|u\+x|g\+x|o\+x|[0-7]*[1-7][0-9][0-9])\s/, reason: '修改文件执行权限' },
|
|
237
|
+
|
|
238
|
+
// Windows 命令(大小写不敏感)
|
|
239
|
+
{ pattern: /^\s*rmdir?\s+\/s\b/i, reason: '递归删除目录 (rd/rmdir /s)' },
|
|
240
|
+
{ pattern: /^\s*(del|erase)\s+\/[a-zA-Z]*f/i, reason: '强制删除文件 (del /f)' },
|
|
241
|
+
{ pattern: /^\s*icacls\b/i, reason: '修改文件权限 (icacls)' },
|
|
242
|
+
{ pattern: /^\s*takeown\b/i, reason: '夺取文件所有权 (takeown)' },
|
|
243
|
+
{ pattern: /^\s*reg\s+(add|delete|import|load|restore)\b/i, reason: '修改注册表 (reg)' },
|
|
244
|
+
{ pattern: /^\s*net\s+(user|localgroup)\b/i, reason: '用户/用户组管理 (net)' },
|
|
245
|
+
{ pattern: /^\s*sc\s+(stop|delete|config)\b/i, reason: '服务管理 (sc)' },
|
|
246
|
+
{ pattern: /^\s*taskkill\s+\/[a-zA-Z]*f/i, reason: '强制结束进程 (taskkill /f)' },
|
|
212
247
|
];
|
|
213
248
|
|
|
214
249
|
/**
|
|
@@ -235,8 +270,11 @@ function checkCommand(command, force = false) {
|
|
|
235
270
|
}
|
|
236
271
|
|
|
237
272
|
// 1.5 禁止访问核心敏感文件(通过命令也一样拦截)
|
|
273
|
+
// Windows 文件系统不区分大小写,归一化比较防止 .AI-HELPER-AGENT 等大小写变换绕过
|
|
274
|
+
const cmpTrimmed = isWin32 ? trimmed.toLowerCase() : trimmed;
|
|
238
275
|
for (const blocked of HARD_BLOCKED_PATHS) {
|
|
239
|
-
|
|
276
|
+
const cmpBlocked = isWin32 ? blocked.toLowerCase() : blocked;
|
|
277
|
+
if (cmpTrimmed.includes(cmpBlocked)) {
|
|
240
278
|
return {
|
|
241
279
|
safe: false,
|
|
242
280
|
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
|
// 配对
|
|
@@ -420,6 +448,7 @@ export function startServer() {
|
|
|
420
448
|
version: AGENT_VERSION,
|
|
421
449
|
running: true,
|
|
422
450
|
platform: PLATFORM_INFO.platform,
|
|
451
|
+
platformName: PLATFORM_INFO.platformName,
|
|
423
452
|
arch: PLATFORM_INFO.arch,
|
|
424
453
|
nodeVersion: PLATFORM_INFO.nodeVersion,
|
|
425
454
|
searchTools: getSearchToolsAvailable()
|
|
@@ -439,6 +468,11 @@ export function startServer() {
|
|
|
439
468
|
return jsonResponse(res, 403, { success: false, error: '认证 token 无效' });
|
|
440
469
|
}
|
|
441
470
|
|
|
471
|
+
// 心跳接口(需认证):仅刷新 lastAuthTime 维持"插件在线"状态,不返回敏感信息
|
|
472
|
+
if (req.method === 'GET' && pathname === '/api/heartbeat') {
|
|
473
|
+
return jsonResponse(res, 200, { success: true, time: Date.now() });
|
|
474
|
+
}
|
|
475
|
+
|
|
442
476
|
// Agent 关闭(需认证)
|
|
443
477
|
if (req.method === 'POST' && pathname === '/api/shutdown') {
|
|
444
478
|
if (agentOperationInProgress) {
|
|
@@ -537,7 +571,8 @@ export function startServer() {
|
|
|
537
571
|
const result = await new Promise((resolvePromise) => {
|
|
538
572
|
const npm = spawn('npm', ['install', '-g', 'ai-helper-agent@latest', '--no-audit', '--no-fund'], {
|
|
539
573
|
shell: true,
|
|
540
|
-
env: { ...process.env }
|
|
574
|
+
env: { ...process.env },
|
|
575
|
+
windowsHide: true
|
|
541
576
|
});
|
|
542
577
|
npm.stdout.on('data', d => npmOutput.push(d.toString().trim()));
|
|
543
578
|
npm.stderr.on('data', d => npmOutput.push(d.toString().trim()));
|
|
@@ -752,6 +787,63 @@ export function startServer() {
|
|
|
752
787
|
try { body = await parseBody(req); }
|
|
753
788
|
catch (err) { return jsonResponse(res, 400, { success: false, error: err.message }); }
|
|
754
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
|
+
|
|
755
847
|
// === 文件操作 ===
|
|
756
848
|
|
|
757
849
|
// 搜索文件(按文件名模式)
|
|
@@ -1010,19 +1102,7 @@ export function startServer() {
|
|
|
1010
1102
|
const tmpFile = join(tmpdir(), `ws-dl-${randomBytes(6).toString('hex')}.zip`);
|
|
1011
1103
|
try {
|
|
1012
1104
|
const zipArgs = resolvedPaths.map(p => basename(p));
|
|
1013
|
-
const commonParent = resolvedPaths
|
|
1014
|
-
const sep = '/';
|
|
1015
|
-
const partsA = a.replace(/\\/g, '/').split('/').filter(Boolean);
|
|
1016
|
-
const partsB = b.replace(/\\/g, '/').split('/').filter(Boolean);
|
|
1017
|
-
const common = [];
|
|
1018
|
-
for (let i = 0; i < Math.min(partsA.length, partsB.length); i++) {
|
|
1019
|
-
if (partsA[i] === partsB[i]) common.push(partsA[i]);
|
|
1020
|
-
else break;
|
|
1021
|
-
}
|
|
1022
|
-
const result = sep + common.join(sep);
|
|
1023
|
-
// Windows 盘符处理:C:/Users -> 而不是 /C:/Users
|
|
1024
|
-
return result;
|
|
1025
|
-
});
|
|
1105
|
+
const commonParent = computeCommonParent(resolvedPaths);
|
|
1026
1106
|
|
|
1027
1107
|
const zipBuffer = await createZipBuffer(zipArgs, commonParent, tmpFile);
|
|
1028
1108
|
logFs('download_multi', { paths: resolvedPaths, zipSize: zipBuffer.length });
|
|
@@ -1103,16 +1183,7 @@ export function startServer() {
|
|
|
1103
1183
|
const tmpFile = join(tmpdir(), `ws-dl-${randomBytes(6).toString('hex')}.zip`);
|
|
1104
1184
|
try {
|
|
1105
1185
|
const zipArgs = resolvedPaths.map(p => basename(p));
|
|
1106
|
-
const commonParent = resolvedPaths
|
|
1107
|
-
const partsA = a.split('/').filter(Boolean);
|
|
1108
|
-
const partsB = b.split('/').filter(Boolean);
|
|
1109
|
-
const common = [];
|
|
1110
|
-
for (let i = 0; i < Math.min(partsA.length, partsB.length); i++) {
|
|
1111
|
-
if (partsA[i] === partsB[i]) common.push(partsA[i]);
|
|
1112
|
-
else break;
|
|
1113
|
-
}
|
|
1114
|
-
return '/' + common.join('/');
|
|
1115
|
-
});
|
|
1186
|
+
const commonParent = computeCommonParent(resolvedPaths);
|
|
1116
1187
|
const zipBuffer = await createZipBuffer(zipArgs, commonParent, tmpFile);
|
|
1117
1188
|
logFs('download_multi_stream', { paths: resolvedPaths, zipSize: zipBuffer.length });
|
|
1118
1189
|
const zipName = `workspace_${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.zip`;
|
|
@@ -1347,7 +1418,7 @@ export function startServer() {
|
|
|
1347
1418
|
return jsonResponse(res, 404, { success: false, error: '文件不存在' });
|
|
1348
1419
|
}
|
|
1349
1420
|
|
|
1350
|
-
const fileUrl =
|
|
1421
|
+
const fileUrl = pathToFileURL(check.resolved).href;
|
|
1351
1422
|
const platform = os.platform();
|
|
1352
1423
|
let cmd, args, spawnOpts;
|
|
1353
1424
|
if (platform === 'darwin') {
|
package/src/skill/executor.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// skill/executor.js - Skill 执行引擎
|
|
2
2
|
// 解析 Skill 定义的步骤 DAG,按拓扑排序执行,支持并行
|
|
3
3
|
import { render } from './template.js';
|
|
4
|
-
import { readFile, writeFile, readdir, stat } from 'fs/promises';
|
|
4
|
+
import { readFile, writeFile, readdir, stat, unlink } from 'fs/promises';
|
|
5
5
|
import { checkPath, checkCommand } from '../security.js';
|
|
6
|
-
import { join } from 'path';
|
|
6
|
+
import { join, basename } from 'path';
|
|
7
7
|
|
|
8
8
|
// 执行上下文(当前活跃的执行任务)
|
|
9
9
|
const activeExecutions = new Map(); // execId → { skill, status, steps, results }
|
|
@@ -47,32 +47,53 @@ async function executeToolCall(toolName, args) {
|
|
|
47
47
|
// 映射 Skill 步骤中的 tool 到实际 Agent API
|
|
48
48
|
try {
|
|
49
49
|
switch (toolName) {
|
|
50
|
-
case '
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
50
|
+
case 'agent_file':
|
|
51
|
+
case 'agent_read_file':
|
|
52
|
+
case 'agent_write_file':
|
|
53
|
+
case 'agent_list_dir':
|
|
54
|
+
case 'agent_delete_file':
|
|
55
|
+
case 'agent_download_file': {
|
|
56
|
+
const action = args.action || (
|
|
57
|
+
toolName === 'agent_read_file' ? 'read' :
|
|
58
|
+
toolName === 'agent_write_file' ? 'write' :
|
|
59
|
+
toolName === 'agent_list_dir' ? 'list' :
|
|
60
|
+
toolName === 'agent_delete_file' ? 'delete' :
|
|
61
|
+
toolName === 'agent_download_file' ? 'download' :
|
|
62
|
+
(args.content ? 'write' : 'read')
|
|
63
|
+
);
|
|
58
64
|
const check = await checkPath(args.path);
|
|
59
65
|
if (!check.allowed) return { success: false, error: check.reason };
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
66
|
+
switch (action) {
|
|
67
|
+
case 'read': {
|
|
68
|
+
const content = await readFile(check.resolved, 'utf-8');
|
|
69
|
+
return { success: true, content, size: content.length, path: check.resolved };
|
|
70
|
+
}
|
|
71
|
+
case 'write': {
|
|
72
|
+
await writeFile(check.resolved, String(args.content || ''), 'utf-8');
|
|
73
|
+
return { success: true, message: `文件已写入: ${check.resolved}` };
|
|
74
|
+
}
|
|
75
|
+
case 'list': {
|
|
76
|
+
const names = await readdir(check.resolved);
|
|
77
|
+
const entries = await Promise.all(names.map(async (name) => {
|
|
78
|
+
const s = await stat(join(check.resolved, name));
|
|
79
|
+
return { name, type: s.isDirectory() ? 'directory' : 'file', size: s.size };
|
|
80
|
+
}));
|
|
81
|
+
return { success: true, entries, path: check.resolved };
|
|
82
|
+
}
|
|
83
|
+
case 'delete': {
|
|
84
|
+
await unlink(check.resolved);
|
|
85
|
+
return { success: true, message: `已删除: ${check.resolved}` };
|
|
86
|
+
}
|
|
87
|
+
case 'download': {
|
|
88
|
+
const content = await readFile(check.resolved);
|
|
89
|
+
return { success: true, content, path: check.resolved, filename: basename(check.resolved) };
|
|
90
|
+
}
|
|
91
|
+
default:
|
|
92
|
+
return { success: false, error: `未知 action: ${action}` };
|
|
93
|
+
}
|
|
73
94
|
}
|
|
74
95
|
|
|
75
|
-
case '
|
|
96
|
+
case 'agent_exec': {
|
|
76
97
|
const cmdCheck = checkCommand(args.command, false);
|
|
77
98
|
if (cmdCheck.level === 'deny') {
|
|
78
99
|
return { success: false, error: cmdCheck.reason };
|
|
@@ -87,7 +108,8 @@ async function executeToolCall(toolName, args) {
|
|
|
87
108
|
cwd: args.cwd || process.cwd(),
|
|
88
109
|
encoding: 'utf-8',
|
|
89
110
|
timeout: 30000,
|
|
90
|
-
maxBuffer: 10 * 1024 * 1024
|
|
111
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
112
|
+
windowsHide: true
|
|
91
113
|
}, (error, stdout, stderr) => {
|
|
92
114
|
if (error) {
|
|
93
115
|
resolve({ success: false, error: error.message, stdout, stderr });
|
|
@@ -98,8 +120,23 @@ async function executeToolCall(toolName, args) {
|
|
|
98
120
|
});
|
|
99
121
|
}
|
|
100
122
|
|
|
101
|
-
case '
|
|
102
|
-
|
|
123
|
+
case 'agent_search':
|
|
124
|
+
case 'agent_search_files':
|
|
125
|
+
case 'agent_search_content': {
|
|
126
|
+
const searchType = args.searchType || (
|
|
127
|
+
toolName === 'agent_search_content' ? 'content' : 'file'
|
|
128
|
+
);
|
|
129
|
+
const { searchFiles, searchContent } = await import('../search.js');
|
|
130
|
+
if (searchType === 'content') {
|
|
131
|
+
return await searchContent(
|
|
132
|
+
args.path || '.',
|
|
133
|
+
args.pattern,
|
|
134
|
+
args.filePattern || null,
|
|
135
|
+
args.caseSensitive || false,
|
|
136
|
+
Math.min(args.maxResults || 100, 5000),
|
|
137
|
+
args.contextLines || 2
|
|
138
|
+
);
|
|
139
|
+
}
|
|
103
140
|
return await searchFiles(
|
|
104
141
|
args.path || '.',
|
|
105
142
|
args.pattern || '*',
|
|
@@ -108,18 +145,6 @@ async function executeToolCall(toolName, args) {
|
|
|
108
145
|
);
|
|
109
146
|
}
|
|
110
147
|
|
|
111
|
-
case 'agent_search_content': {
|
|
112
|
-
const { searchContent } = await import('../search.js');
|
|
113
|
-
return await searchContent(
|
|
114
|
-
args.path || '.',
|
|
115
|
-
args.pattern,
|
|
116
|
-
args.filePattern || null,
|
|
117
|
-
args.caseSensitive || false,
|
|
118
|
-
Math.min(args.maxResults || 100, 5000),
|
|
119
|
-
args.contextLines || 2
|
|
120
|
-
);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
148
|
default:
|
|
124
149
|
return { success: false, error: `未知工具: ${toolName}` };
|
|
125
150
|
}
|
package/src/skill/loader.js
CHANGED
|
@@ -256,19 +256,23 @@ export function deleteSkillFile(name, type) {
|
|
|
256
256
|
/**
|
|
257
257
|
* 种子 skill-creator 内置技能到文件系统
|
|
258
258
|
* 首次运行时将 skill-creator 的 SKILL.md 写入 ~/.ai-helper-agent/skills/skill-creator/
|
|
259
|
-
*
|
|
259
|
+
* 当种子版本号高于已安装版本时自动更新
|
|
260
260
|
* @returns {boolean} 是否执行了种子写入
|
|
261
261
|
*/
|
|
262
262
|
export function seedSkillCreator() {
|
|
263
263
|
const skillDir = join(SKILLS_DIR, SKILL_CREATOR_DIR);
|
|
264
264
|
const skillMdPath = join(skillDir, 'SKILL.md');
|
|
265
265
|
|
|
266
|
+
// 从种子内容中提取目标版本号
|
|
267
|
+
const seedVersionMatch = SKILL_CREATOR_SEED_MD.match(/^version:\s*([\d.]+)/m);
|
|
268
|
+
const seedVersion = seedVersionMatch ? seedVersionMatch[1] : '0.0.0';
|
|
269
|
+
|
|
266
270
|
if (existsSync(skillMdPath)) {
|
|
267
271
|
try {
|
|
268
272
|
const content = readFileSync(skillMdPath, 'utf-8');
|
|
269
273
|
const versionMatch = content.match(/^version:\s*([\d.]+)/m);
|
|
270
|
-
const
|
|
271
|
-
if (
|
|
274
|
+
const installedVersion = versionMatch ? versionMatch[1] : '0.0.0';
|
|
275
|
+
if (installedVersion === seedVersion) {
|
|
272
276
|
return false;
|
|
273
277
|
}
|
|
274
278
|
} catch { /* 读取失败则覆盖 */ }
|
|
@@ -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);
|
package/src/skill/registry.js
CHANGED
|
@@ -194,7 +194,7 @@ export async function runSkill(name, params = {}, onStepUpdate) {
|
|
|
194
194
|
/**
|
|
195
195
|
* 获取所有启用的 Agent Skill 的轻量列表
|
|
196
196
|
* 仅包含名称和描述,用于注入到 AI System Prompt 中。
|
|
197
|
-
* AI 需要使用
|
|
197
|
+
* AI 需要使用 agent_skill 工具按需加载完整内容。
|
|
198
198
|
* @returns {string} - 格式化的技能列表文本
|
|
199
199
|
*/
|
|
200
200
|
export function getAgentSkillPrompts() {
|
|
@@ -210,7 +210,7 @@ export function getAgentSkillPrompts() {
|
|
|
210
210
|
const parts = [];
|
|
211
211
|
parts.push('## 可用技能 (Skills)');
|
|
212
212
|
parts.push('');
|
|
213
|
-
parts.push('以下是可用的 Agent Skill(AI 能力扩展)列表。当用户需求与某个技能高度匹配时,使用 `
|
|
213
|
+
parts.push('以下是可用的 Agent Skill(AI 能力扩展)列表。当用户需求与某个技能高度匹配时,使用 `agent_skill` 工具(action: load)加载该技能的完整说明,然后根据说明自主完成任务。');
|
|
214
214
|
parts.push('');
|
|
215
215
|
parts.push('**使用策略**:');
|
|
216
216
|
parts.push('- 仅在用户需求明确匹配技能描述时才加载,不要为一般性的编程/技术问题加载技能');
|
|
@@ -8,7 +8,7 @@ export const SKILL_CREATOR_SEED_MD = `---
|
|
|
8
8
|
name: skill-creator
|
|
9
9
|
description: "创建新技能的元技能。当用户要求创建技能、沉淀技能、新增技能、或任何创建/添加技能的意图时,必须立即调用此技能。"
|
|
10
10
|
enabled: true
|
|
11
|
-
version: 1.1
|
|
11
|
+
version: 1.2.1
|
|
12
12
|
---
|
|
13
13
|
|
|
14
14
|
# Skill Creator
|
|
@@ -99,18 +99,20 @@ enabled: true
|
|
|
99
99
|
- 示例(正例和反例)
|
|
100
100
|
- 注意事项(容易踩的坑)
|
|
101
101
|
|
|
102
|
+
> 💡 **多轮对话沉淀技巧**:当对话涉及多轮工具调用(ReAct 循环)时,先调用 \`exec_log\` 工具(scope: full_session)提取完整的执行日志。从**成功路径**中提炼经过验证的有效操作步骤,从**失败教训**中提炼注意事项和避坑指南,从**反思建议**中提炼优化方案。这能确保沉淀的技能内容覆盖完整的操作过程,而不仅仅是最终结果。
|
|
103
|
+
|
|
102
104
|
4. **创建技能目录**:
|
|
103
|
-
- 使用 \`
|
|
105
|
+
- 使用 \`agent_exec\` 创建技能子目录。
|
|
104
106
|
- **macOS/Linux**:\`mkdir -p ~/.ai-helper-agent/skills/<skill-name>\`
|
|
105
107
|
- **Windows (cmd)**:\`mkdir %USERPROFILE%\\.ai-helper-agent\\skills\\<skill-name>\`
|
|
106
108
|
- **Windows (PowerShell)**:\`New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\\.ai-helper-agent\\skills\\<skill-name>"\`
|
|
107
|
-
- 如果 \`mkdir\` 报错,尝试不带参数或使用 \`
|
|
109
|
+
- 如果 \`mkdir\` 报错,尝试不带参数或使用 \`agent_file\`(action: write)在目标路径写入一个临时文件,让系统自动创建父目录。
|
|
108
110
|
|
|
109
|
-
5. **写入 SKILL.md**:使用 \`
|
|
111
|
+
5. **写入 SKILL.md**:使用 \`agent_file\`(action: write)工具将内容写入目标文件。
|
|
110
112
|
|
|
111
|
-
> ⚠️ **关键限制**:\`
|
|
113
|
+
> ⚠️ **关键限制**:\`agent_file\` 不接受 \`~\` 或相对路径,**必须传入绝对路径**。
|
|
112
114
|
|
|
113
|
-
**推荐做法**:先用 \`
|
|
115
|
+
**推荐做法**:先用 \`agent_exec\` 获取用户主目录的绝对路径,再拼接完整路径。
|
|
114
116
|
|
|
115
117
|
6. **告知用户**:创建完成后,告知用户技能已就绪,说明触发关键词。建议用户在工具箱页面点击「重新加载」使技能生效。
|
|
116
118
|
|
|
@@ -119,10 +121,10 @@ enabled: true
|
|
|
119
121
|
当用户要求更新/完善已有技能时:
|
|
120
122
|
|
|
121
123
|
1. **确认技能名称**:与用户确认要更新的技能名称
|
|
122
|
-
2. **读取现有内容**:使用 \`
|
|
124
|
+
2. **读取现有内容**:使用 \`agent_file\`(action: read)读取目标 SKILL.md(路径规则同创建流程,需使用绝对路径,如 \`/Users/<你的用户名>/.ai-helper-agent/skills/<skill-name>/SKILL.md\`)
|
|
123
125
|
3. **分析更新需求**:结合当前对话上下文,识别需要更新的部分
|
|
124
126
|
4. **合并内容**:保留原技能的核心结构,将新内容融入适当位置
|
|
125
|
-
5. **覆盖写入**:使用 \`
|
|
127
|
+
5. **覆盖写入**:使用 \`agent_file\`(action: write)覆盖写入 SKILL.md(路径规则同上,必须使用绝对路径)
|
|
126
128
|
6. **告知用户**:告知更新内容,建议重新加载
|
|
127
129
|
|
|
128
130
|
## 更新注意事项
|