ai-helper-agent 1.13.0 → 1.14.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-helper-agent",
3
- "version": "1.13.0",
3
+ "version": "1.14.1",
4
4
  "description": "AI Helper 代理服务 - 为 Chrome 扩展提供本地文件读写和系统命令执行能力",
5
5
  "type": "module",
6
6
  "main": "./src/server.js",
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', (code) => {
266
+ proc.on('close', () => {
259
267
  clearTimeout(timeout);
260
268
  if (timedOut) return;
261
- if (code !== 0 && code !== 1) { resolve([]); return; } // 1 = no matches
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
@@ -6,14 +6,33 @@ import { loadConfig, MEMORY_DIR } from './config.js';
6
6
 
7
7
  const isWin32 = platform() === 'win32';
8
8
 
9
- function normalizePathFormat(pathStr) {
9
+ /**
10
+ * 路径格式规整(跨平台)
11
+ * @param {string} pathStr - 待规整的路径
12
+ * @param {string} [platformOverride] - 平台覆盖('win32' | 'darwin' | 'linux'),用于单元测试 mock;
13
+ * 不传则使用当前运行平台 os.platform()
14
+ * @returns {string} 规整后的路径
15
+ */
16
+ function normalizePathFormat(pathStr, platformOverride) {
10
17
  if (!pathStr || typeof pathStr !== 'string') return pathStr;
11
- // 防御性修复:把异常的 "/C:/" 形式(带前导斜杠的盘符)规整为 "C:/"
12
- // 仅匹配 /<字母>:/ ,不会误伤 /home/ 等普通路径段
18
+ const isWin = platformOverride !== undefined ? platformOverride === 'win32' : isWin32;
19
+ // 1. 把异常的 "/C:/" 形式(带前导斜杠的盘符)规整为 "C:/"
20
+ // 仅匹配 /<字母>:/ ,不会误伤 /home/ 等普通路径段
13
21
  pathStr = pathStr.replace(/\/([a-zA-Z]):(\/|$)/g, '$1:$2');
14
- // 统一为正斜杠(path.resolve/normalize 在所有平台都能正确处理正斜杠并归一化到平台分隔符)
15
- // 不再做盘符相关的激进转换,避免把 /home/user 误转为 home:\user
16
- return pathStr.replace(/\\/g, '/');
22
+ // 2. MSYS/Git Bash 风格路径:/d/Users/... → D:/Users/...
23
+ // 前导 / + 单字母盘符 + / ,无冒号,是 MSYS 特有的挂载点表示法
24
+ if (isWin) {
25
+ pathStr = pathStr.replace(/^\/([a-zA-Z])\//, '$1:/');
26
+ }
27
+ // 3. 统一为正斜杠(path.resolve/normalize 在所有平台都能正确处理正斜杠并归一化到平台分隔符)
28
+ pathStr = pathStr.replace(/\\/g, '/');
29
+ // 4. Windows 盘符统一为大写:d:/ → D:/
30
+ // path.resolve 保留盘符原始大小写,而白名单前缀比较是大小写敏感的字符串比较,
31
+ // 小写盘符路径与大写盘符白名单前缀比较会失败(d:\Users\.startsWith(D:\Users\) → false)
32
+ if (isWin) {
33
+ pathStr = pathStr.replace(/^([a-zA-Z]):/, m => m.toUpperCase());
34
+ }
35
+ return pathStr;
17
36
  }
18
37
 
19
38
  // ==================== 硬阻止目录(任何情况下都不可访问) ====================
@@ -33,17 +52,22 @@ const HARD_BLOCKED_PATHS = [
33
52
 
34
53
  /**
35
54
  * 检查路径是否命中核心敏感文件
55
+ * 注意:Windows 文件系统不区分大小写,必须做大小写不敏感比较,
56
+ * 否则 .AI-HELPER-AGENT/config.JSON 等大小写变换可绕过硬阻止
36
57
  */
37
- function isHardBlocked(normalizedPath) {
58
+ function isHardBlocked(normalizedPath, platformOverride) {
59
+ const isWin = platformOverride !== undefined ? platformOverride === 'win32' : isWin32;
60
+ const cmpPath = isWin ? normalizedPath.toLowerCase() : normalizedPath;
38
61
  for (const blocked of HARD_BLOCKED_PATHS) {
62
+ const cmpBlocked = isWin ? blocked.toLowerCase() : blocked;
39
63
  if (blocked.endsWith(sep)) {
40
64
  // 目录:检查是否在该目录下
41
- if (normalizedPath.startsWith(blocked) || normalizedPath === blocked.slice(0, -1)) {
65
+ if (cmpPath.startsWith(cmpBlocked) || cmpPath === cmpBlocked.slice(0, -1)) {
42
66
  return true;
43
67
  }
44
68
  } else {
45
69
  // 文件:精确匹配
46
- if (normalizedPath === blocked) {
70
+ if (cmpPath === cmpBlocked) {
47
71
  return true;
48
72
  }
49
73
  }
@@ -303,4 +327,4 @@ function checkCommand(command, force = false) {
303
327
  return { safe: true, level: 'allow' };
304
328
  }
305
329
 
306
- export { checkPath, checkCommand };
330
+ export { checkPath, checkCommand, normalizePathFormat, isHardBlocked };
package/src/server.js CHANGED
@@ -12,7 +12,7 @@ import { randomBytes } from 'crypto';
12
12
  import os from 'os';
13
13
  import { loadConfig, saveConfig } from './config.js';
14
14
  import { verifyToken, startPairCodeRotation, stopPairCodeRotation, handlePairRequest } from './auth.js';
15
- import { checkPath, checkCommand } from './security.js';
15
+ import { checkPath, checkCommand, normalizePathFormat } from './security.js';
16
16
  import { moveToTrash, restoreFromTrash, listTrash, startPeriodicCleanup, stopPeriodicCleanup } from './trash.js';
17
17
  import { executeCommand, executeCommandSync, addWsClient, disconnectWsClient, killProcess, getRunningProcesses } from './executor.js';
18
18
  import { setConsoleOutput, logAuth, logFs, logExec, logSecurity, logSystem, logError, queryLogs, getLogDates } from './logger.js';
@@ -801,6 +801,9 @@ export function startServer() {
801
801
  } else if (resolvedWorkdir.startsWith('~/')) {
802
802
  resolvedWorkdir = join(homedir(), resolvedWorkdir.slice(2));
803
803
  }
804
+ // 规整路径格式(MSYS /d/Users/... → D:/Users/...、盘符大写统一),
805
+ // 必须在 isAbsolute/resolve 之前,否则 Windows 上 /d/Users 会被当作 Unix 绝对路径错误解析
806
+ resolvedWorkdir = normalizePathFormat(resolvedWorkdir);
804
807
 
805
808
  // 必须是绝对路径(跨平台:path.isAbsolute 覆盖 Unix /、Windows 盘符、UNC 路径)
806
809
  if (!isAbsolute(resolvedWorkdir)) {
@@ -844,6 +847,59 @@ export function startServer() {
844
847
  }
845
848
  }
846
849
 
850
+ // === 移除允许访问的目录(需认证,不可移除当前工作目录) ===
851
+ if (pathname === '/api/config/allowed-paths/remove' && req.method === 'POST') {
852
+ const targetPath = body.path;
853
+ if (!targetPath || typeof targetPath !== 'string') {
854
+ return jsonResponse(res, 400, { success: false, error: '缺少 path 参数' });
855
+ }
856
+
857
+ let resolvedPath = targetPath.trim();
858
+ if (resolvedPath === '~') {
859
+ resolvedPath = homedir();
860
+ } else if (resolvedPath.startsWith('~/')) {
861
+ resolvedPath = join(homedir(), resolvedPath.slice(2));
862
+ }
863
+ // 规整 MSYS 路径格式,与 workdir 切换端点保持一致
864
+ resolvedPath = normalizePathFormat(resolvedPath);
865
+ if (!isAbsolute(resolvedPath)) {
866
+ return jsonResponse(res, 400, { success: false, error: 'path 必须是绝对路径' });
867
+ }
868
+ resolvedPath = resolve(resolvedPath);
869
+
870
+ try {
871
+ const freshConfig = loadConfig();
872
+ const currentWorkdir = resolve(freshConfig.workdir || '');
873
+ // 禁止移除当前工作目录
874
+ if (resolvedPath === currentWorkdir) {
875
+ return jsonResponse(res, 400, { success: false, error: '不能移除当前工作目录,请先切换到其它目录' });
876
+ }
877
+
878
+ const allowedPaths = Array.isArray(freshConfig.allowedPaths) ? freshConfig.allowedPaths : [];
879
+ // 跨平台路径比较:统一分隔符 + 大小写不敏感
880
+ const norm = s => s.replace(/\\/g, '/').toLowerCase();
881
+ const filtered = allowedPaths.filter(p => norm(p) !== norm(resolvedPath));
882
+
883
+ // 没有变化说明该路径不在列表中
884
+ if (filtered.length === allowedPaths.length) {
885
+ return jsonResponse(res, 200, { success: true, allowedPaths: filtered, message: '该目录不在允许列表中' });
886
+ }
887
+
888
+ const updatedConfig = { ...freshConfig, allowedPaths: filtered };
889
+ await saveConfig(updatedConfig);
890
+
891
+ logSystem('allowed_path_removed', { path: resolvedPath, extId });
892
+ return jsonResponse(res, 200, {
893
+ success: true,
894
+ allowedPaths: updatedConfig.allowedPaths,
895
+ message: '已从允许列表中移除'
896
+ });
897
+ } catch (err) {
898
+ logError('config', 'allowed_path_remove_error', { path: resolvedPath, error: err.message });
899
+ return jsonResponse(res, 500, { success: false, error: `移除允许目录失败: ${err.message}` });
900
+ }
901
+ }
902
+
847
903
  // === 文件操作 ===
848
904
 
849
905
  // 搜索文件(按文件名模式)
@@ -877,6 +933,7 @@ export function startServer() {
877
933
  body.pattern,
878
934
  body.filePattern || null,
879
935
  body.caseSensitive || false,
936
+ body.recursive !== false,
880
937
  maxResults,
881
938
  body.contextLines !== undefined ? body.contextLines : 2
882
939
  );
@@ -1333,6 +1390,10 @@ export function startServer() {
1333
1390
  if (newName.includes('/') || newName.includes('\\')) {
1334
1391
  return jsonResponse(res, 400, { success: false, error: '新名称不能包含路径分隔符' });
1335
1392
  }
1393
+ // Windows 文件名非法字符(仅 Windows 拦截;Unix 允许这些字符)
1394
+ if (os.platform() === 'win32' && /[:*?"<>|]/.test(newName)) {
1395
+ return jsonResponse(res, 400, { success: false, error: '新名称包含 Windows 非法字符 (: * ? " < > |)' });
1396
+ }
1336
1397
  const check = await checkPath(oldPath);
1337
1398
  if (!check.allowed) {
1338
1399
  logSecurity('fs_rename_blocked', { path: oldPath, reason: check.reason });
@@ -1348,6 +1409,12 @@ export function startServer() {
1348
1409
  logSecurity('fs_rename_blocked', { path: newFullPath, reason: newCheck.reason });
1349
1410
  return jsonResponse(res, 403, { success: false, error: `目标路径不允许: ${newCheck.reason}` });
1350
1411
  }
1412
+ // 防止覆盖:目标已存在时统一返回 409。
1413
+ // macOS/Linux 的 fs.rename 会静默覆盖目标文件(仅 Windows 报 EEXIST),
1414
+ // 主动检查避免跨平台数据丢失风险
1415
+ if (await exists(newFullPath)) {
1416
+ return jsonResponse(res, 409, { success: false, error: '目标名称已存在' });
1417
+ }
1351
1418
  try {
1352
1419
  await rename(check.resolved, newFullPath);
1353
1420
  logFs('rename', { from: check.resolved, to: newFullPath });
@@ -1391,6 +1458,10 @@ export function startServer() {
1391
1458
  logSecurity('fs_move_blocked', { path: destPath, reason: destCheck2.reason });
1392
1459
  return jsonResponse(res, 403, { success: false, error: `移动目标路径不允许: ${destCheck2.reason}` });
1393
1460
  }
1461
+ // 防止覆盖:目标已存在时统一返回 409(与 rename 端点一致,避免 macOS/Linux 静默覆盖)
1462
+ if (await exists(destPath)) {
1463
+ return jsonResponse(res, 409, { success: false, error: '目标位置已存在同名文件/目录' });
1464
+ }
1394
1465
  try {
1395
1466
  await rename(srcCheck.resolved, destPath);
1396
1467
  logFs('move', { from: srcCheck.resolved, to: destPath });
@@ -133,6 +133,7 @@ async function executeToolCall(toolName, args) {
133
133
  args.pattern,
134
134
  args.filePattern || null,
135
135
  args.caseSensitive || false,
136
+ args.recursive !== false,
136
137
  Math.min(args.maxResults || 100, 5000),
137
138
  args.contextLines || 2
138
139
  );