ai-helper-agent 1.11.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/agent.js CHANGED
@@ -399,6 +399,128 @@ if (command === 'start') {
399
399
  console.log(`[Agent] 工作目录: ${cfg.workdir}`);
400
400
  restartServer();
401
401
 
402
+ // ==================== _restart-helper(内部命令,两阶段重启包装器) ====================
403
+ } else if (command === '_restart-helper') {
404
+ // 由 /api/agent/restart 和 /api/agent/update 调用,detached 运行
405
+ // 职责:等待老进程退出 + 端口释放后,再以 --background 模式启动新进程
406
+ // 解决直接 spawn 新进程时 isRunning 检查劝退、端口 EADDRINUSE 的时序问题
407
+ const helperConfig = applyCliArgs({ port: 18910, host: '127.0.0.1' }, process.argv.slice(3));
408
+ const oldPidIdx = process.argv.indexOf('--old-pid');
409
+ const oldPid = oldPidIdx > -1 ? parseInt(process.argv[oldPidIdx + 1], 10) : 0;
410
+ const scriptIdx = process.argv.indexOf('--script');
411
+ const agentScript = scriptIdx > -1 ? process.argv[scriptIdx + 1] : process.argv[1];
412
+
413
+ const port = helperConfig.port;
414
+ const host = helperConfig.host;
415
+ const workdir = helperConfig.workdir || process.cwd();
416
+
417
+ const log = (msg) => console.log(`[Agent Restart Helper] ${msg}`);
418
+ log(`等待老进程 ${oldPid} 退出...`);
419
+
420
+ // 1. 等待老进程退出(轮询 PID 探活,process.kill(pid,0) 不发信号只检测存在性)
421
+ const waitForOldExit = (pid, timeoutMs = 15000) => new Promise((resolve) => {
422
+ if (!pid) return resolve(true);
423
+ const start = Date.now();
424
+ const timer = setInterval(() => {
425
+ try {
426
+ process.kill(pid, 0);
427
+ } catch {
428
+ clearInterval(timer);
429
+ resolve(true);
430
+ return;
431
+ }
432
+ if (Date.now() - start > timeoutMs) {
433
+ clearInterval(timer);
434
+ log('等待老进程退出超时,尝试强制终止...');
435
+ try { process.kill(pid, 'SIGKILL'); } catch {}
436
+ resolve(false);
437
+ }
438
+ }, 200);
439
+ });
440
+
441
+ // 2. 等待端口释放(fetch /api/status 失败即视为释放)
442
+ const waitForPortRelease = async (host, port, timeoutMs = 10000) => {
443
+ const start = Date.now();
444
+ while (Date.now() - start < timeoutMs) {
445
+ try {
446
+ const resp = await fetch(`http://${host}:${port}/api/status`);
447
+ if (resp.ok) {
448
+ await new Promise(r => setTimeout(r, 300));
449
+ continue;
450
+ }
451
+ } catch {
452
+ return true;
453
+ }
454
+ }
455
+ return false;
456
+ };
457
+
458
+ (async () => {
459
+ await waitForOldExit(oldPid);
460
+ await waitForPortRelease(host, port);
461
+ // 额外等待,确保 socket 完全回收
462
+ await new Promise(r => setTimeout(r, 500));
463
+
464
+ log('老进程已退出,端口已释放,启动新进程...');
465
+
466
+ // 清理旧 PID 文件,避免新进程 isRunning 检查误判
467
+ removePidFile();
468
+ ensureAgentDir();
469
+
470
+ // 直接以前台模式启动服务进程(不经过 --background 中间层)
471
+ // 原因:--background 会多 spawn 一层中间进程,中间进程退出后其 PID 会被
472
+ // 本 helper 覆盖写入 PID 文件,导致 stop/restart 读到已死的中间进程 PID。
473
+ // 前台模式下 child.pid 就是服务进程 PID,与服务进程自身写入的 PID 一致。
474
+ // 前台 start 不会 isRunning 检查,但本 helper 已确保老进程退出 + 端口释放。
475
+ const child = spawn(process.execPath, [
476
+ ...process.execArgv,
477
+ agentScript,
478
+ 'start',
479
+ '--port', String(port),
480
+ '--host', host,
481
+ '--workdir', workdir
482
+ ], {
483
+ detached: true,
484
+ stdio: 'ignore',
485
+ cwd: process.cwd(),
486
+ env: { ...process.env },
487
+ windowsHide: true
488
+ });
489
+
490
+ let spawnFailed = false;
491
+ child.on('error', (err) => {
492
+ spawnFailed = true;
493
+ log(`启动失败: ${err.message}`);
494
+ process.exit(1);
495
+ });
496
+
497
+ await new Promise(resolve => setTimeout(resolve, 1000));
498
+
499
+ // 健康检查:确认服务进程已成功监听端口,避免写入已崩溃进程的 PID
500
+ // (若端口意外未释放导致 EADDRINUSE,服务进程会崩溃,此时不应写错误 PID)
501
+ let healthy = false;
502
+ if (!spawnFailed && child.pid) {
503
+ try {
504
+ const resp = await fetch(`http://${host}:${port}/api/status`);
505
+ healthy = resp.ok;
506
+ } catch {}
507
+ }
508
+
509
+ if (healthy && child.pid) {
510
+ try {
511
+ writeFileSync(PID_FILE, String(child.pid));
512
+ } catch (err) {
513
+ log(`PID 文件写入失败: ${err.message}`);
514
+ }
515
+ child.unref();
516
+ log(`新进程已启动 (PID: ${child.pid})`);
517
+ } else if (!spawnFailed && child.pid) {
518
+ log(`警告:新进程健康检查失败,未更新 PID 文件(可能端口仍被占用或进程已崩溃)`);
519
+ }
520
+
521
+ process.exit(spawnFailed ? 1 : 0);
522
+ })();
523
+
402
524
  // ==================== status ====================
403
525
  } else if (command === 'status') {
404
526
  const config = readAgentConfig();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-helper-agent",
3
- "version": "1.11.0",
3
+ "version": "1.12.0",
4
4
  "description": "AI Helper 代理服务 - 为 Chrome 扩展提供本地文件读写和系统命令执行能力",
5
5
  "type": "module",
6
6
  "main": "./src/server.js",
@@ -23,6 +23,7 @@
23
23
  "@modelcontextprotocol/sdk": "^1.29.0",
24
24
  "archiver": "^8.0.0",
25
25
  "ws": "^8.18.0",
26
+ "xlsx": "^0.18.5",
26
27
  "zod": "^4.4.3"
27
28
  },
28
29
  "engines": {
package/src/executor.js CHANGED
@@ -54,8 +54,25 @@ function getShellForExec() {
54
54
  // 运行中的进程映射:execId → { process, wsClients: Set, timeoutId, forceKillId, stdoutBuf, stderrBuf }
55
55
  const runningProcesses = new Map();
56
56
 
57
- // 已完成进程缓存(解决竞态条件:进程完成太快,WebSocket 客户端来不及连接)
58
- // execId → { stdoutBuf, stderrBuf, exitCode, killed, cleanupTimeout }
57
+ // 输出缓冲区上限(5MB),防止长时间命令内存无限增长
58
+ const MAX_OUTPUT_BUFFER = 5 * 1024 * 1024;
59
+
60
+ /**
61
+ * 安全追加字符串到缓冲区,超过上限时截断并标记
62
+ * @param {string} buf - 当前缓冲区
63
+ * @param {string} chunk - 要追加的字符串
64
+ * @param {object} truncState - 截断状态对象 { truncated: boolean }
65
+ * @returns {string} 更新后的缓冲区
66
+ */
67
+ function appendWithLimit(buf, chunk, truncState) {
68
+ if (truncState.truncated) return buf;
69
+ if (buf.length + chunk.length > MAX_OUTPUT_BUFFER) {
70
+ truncState.truncated = true;
71
+ const remaining = MAX_OUTPUT_BUFFER - buf.length;
72
+ return buf + (remaining > 0 ? chunk.slice(0, remaining) : '');
73
+ }
74
+ return buf + chunk;
75
+ }
59
76
  const completedProcesses = new Map();
60
77
  const COMPLETED_CACHE_TTL = 30000; // 30秒后清理
61
78
 
@@ -120,6 +137,7 @@ function executeCommand(command, cwd, wsClient, onComplete, collectOutput = fals
120
137
  cwd: workdir,
121
138
  env: buildSafeEnv(),
122
139
  stdio: ['ignore', 'pipe', 'pipe'],
140
+ detached: true, // 独立进程组,防止 kill 父进程后子进程成为孤儿
123
141
  windowsHide: true
124
142
  });
125
143
 
@@ -133,7 +151,8 @@ function executeCommand(command, cwd, wsClient, onComplete, collectOutput = fals
133
151
 
134
152
  // 存储到 entry 中供 clearTimers 读取
135
153
  // stdoutBuf/stderrBuf 始终收集,用于回放给延迟连接的 WS 客户端
136
- const entry = { process: proc, wsClients, stdoutBuf: '', stderrBuf: '' };
154
+ // command/startTime 用于状态详情面板展示运行中进程
155
+ const entry = { process: proc, wsClients, stdoutBuf: '', stderrBuf: '', truncStdout: false, truncStderr: false, command, startTime: Date.now() };
137
156
  runningProcesses.set(execId, entry);
138
157
 
139
158
  // 超时控制
@@ -166,7 +185,12 @@ function executeCommand(command, cwd, wsClient, onComplete, collectOutput = fals
166
185
  proc.stdout.on('data', (chunk) => {
167
186
  const str = chunk.toString();
168
187
  if (collectOutput) stdoutCollected += str;
169
- entry.stdoutBuf += str;
188
+ const truncState = { truncated: entry.truncStdout };
189
+ entry.stdoutBuf = appendWithLimit(entry.stdoutBuf, str, truncState);
190
+ entry.truncStdout = truncState.truncated;
191
+ if (truncState.truncated) {
192
+ broadcast({ type: 'stdout_truncated', message: 'stdout 输出超过 5MB 上限,已截断', execId });
193
+ }
170
194
  broadcast({ type: 'stdout', data: str, execId });
171
195
  });
172
196
 
@@ -174,7 +198,12 @@ function executeCommand(command, cwd, wsClient, onComplete, collectOutput = fals
174
198
  proc.stderr.on('data', (chunk) => {
175
199
  const str = chunk.toString();
176
200
  if (collectOutput) stderrCollected += str;
177
- entry.stderrBuf += str;
201
+ const truncState = { truncated: entry.truncStderr };
202
+ entry.stderrBuf = appendWithLimit(entry.stderrBuf, str, truncState);
203
+ entry.truncStderr = truncState.truncated;
204
+ if (truncState.truncated) {
205
+ broadcast({ type: 'stderr_truncated', message: 'stderr 输出超过 5MB 上限,已截断', execId });
206
+ }
178
207
  broadcast({ type: 'stderr', data: str, execId });
179
208
  });
180
209
 
@@ -326,9 +355,9 @@ function disconnectWsClient(execId, wsClient) {
326
355
  }
327
356
 
328
357
  /**
329
- * 停止正在运行的命令(跨平台兼容)
330
- * - macOS/Linux: SIGTERM(优雅终止)→ 5s → SIGKILL(强制杀)
331
- * - Windows: taskkill /PID(优雅终止)→ 5s → taskkill /F /PID(强制杀)
358
+ * 停止正在运行的命令(跨平台兼容,递归终止全部子进程)
359
+ * - macOS/Linux: SIGTERM to process group(-pid)→ 5s → SIGKILL to process group
360
+ * - Windows: taskkill /T /PID(递归终止进程树)→ 5s → taskkill /F /T /PID
332
361
  */
333
362
  function killProcess(execId) {
334
363
  const entry = runningProcesses.get(execId);
@@ -340,18 +369,20 @@ function killProcess(execId) {
340
369
  const pid = entry.process.pid;
341
370
 
342
371
  if (isWin) {
343
- // Windows: taskkill 支持优雅终止(/PID 发送 WM_CLOSE)和强制终止(/F)
344
- exec(`taskkill /PID ${pid}`, (err) => {
372
+ // Windows: /T 递归终止整个进程树
373
+ exec(`taskkill /T /PID ${pid}`, (err) => {
345
374
  if (err) {
346
- // 尝试强制杀
347
- exec(`taskkill /F /PID ${pid}`, () => {});
375
+ exec(`taskkill /F /T /PID ${pid}`, () => {});
348
376
  }
349
377
  });
350
378
  } else {
351
- // macOS/Linux: SIGTERM 优雅终止
379
+ // Unix: 负 PID 表示杀整个进程组(detached + 未调用 setsid 时有效)
352
380
  try {
353
- entry.process.kill('SIGTERM');
354
- } catch {}
381
+ process.kill(-pid, 'SIGTERM');
382
+ } catch {
383
+ // 回退到单进程
384
+ try { entry.process.kill('SIGTERM'); } catch {}
385
+ }
355
386
  }
356
387
 
357
388
  // 清理旧的定时器
@@ -363,14 +394,15 @@ function killProcess(execId) {
363
394
  clearTimeout(entry.forceKillId);
364
395
  }
365
396
 
366
- // 5秒后强制杀(绑定到 entry 对象防止闭包引用旧变量)
397
+ // 5秒后强制杀整个进程组
367
398
  entry.forceKillId = setTimeout(() => {
368
399
  try {
369
400
  if (entry.process.exitCode === null) {
370
401
  if (isWin) {
371
- exec(`taskkill /F /PID ${pid}`, () => {});
402
+ exec(`taskkill /F /T /PID ${pid}`, () => {});
372
403
  } else {
373
- entry.process.kill('SIGKILL');
404
+ try { process.kill(-pid, 'SIGKILL'); }
405
+ catch { try { entry.process.kill('SIGKILL'); } catch {} }
374
406
  }
375
407
  }
376
408
  } catch {}
@@ -382,11 +414,19 @@ function killProcess(execId) {
382
414
 
383
415
  /**
384
416
  * 获取运行中的进程列表
417
+ * 返回每个进程的 execId、pid、command、startTime、duration(已运行毫秒数)
385
418
  */
386
419
  function getRunningProcesses() {
420
+ const now = Date.now();
387
421
  const list = [];
388
- for (const [execId] of runningProcesses) {
389
- list.push({ execId });
422
+ for (const [execId, entry] of runningProcesses) {
423
+ list.push({
424
+ execId,
425
+ pid: entry.process && entry.process.pid ? entry.process.pid : null,
426
+ command: entry.command || '',
427
+ startTime: entry.startTime || null,
428
+ duration: entry.startTime ? now - entry.startTime : 0
429
+ });
390
430
  }
391
431
  return list;
392
432
  }
package/src/mcp/client.js CHANGED
@@ -99,7 +99,6 @@ export class McpClient {
99
99
  this.connected = true;
100
100
  return { success: true, toolCount: this.tools.length };
101
101
  } catch (err) {
102
- console.error(`[MCP:${this.serverId}] 连接失败:`, err.message);
103
102
  this.disconnect();
104
103
  return { success: false, error: err.message };
105
104
  }
@@ -32,10 +32,8 @@ export async function initializeMcpRegistry() {
32
32
 
33
33
  if (config.servers.length > 0) {
34
34
  if (failed > 0) {
35
- console.log(`[MCP] ${connected}/${config.servers.length} 已连接`);
36
- for (const fs of failedServers) {
37
- console.error(`[MCP] ${fs.id} 连接失败: ${fs.error}`);
38
- }
35
+ const failedList = failedServers.map(fs => `${fs.id}(${fs.error})`).join(', ');
36
+ console.warn(`[MCP] ${connected}/${config.servers.length} 已连接,${failed} 个失败: ${failedList}`);
39
37
  } else if (connected > 0) {
40
38
  console.log(`[MCP] ${connected} 个 Server 已连接`);
41
39
  }
package/src/search.js CHANGED
@@ -112,11 +112,12 @@ function searchWithFd(rootPath, filePattern, recursive, maxResults) {
112
112
  const output = Buffer.concat(stdout).toString('utf-8').trim();
113
113
  const lines = output ? output.split('\n').filter(Boolean) : [];
114
114
  const results = lines.map(p => {
115
+ const absPath = join(rootPath, p);
115
116
  try {
116
- const s = statSync(p);
117
- return { path: p, name: basename(p), type: s.isDirectory() ? 'directory' : 'file', size: s.size, mtime: s.mtimeMs };
117
+ const s = statSync(absPath);
118
+ return { path: absPath, name: basename(p), type: s.isDirectory() ? 'directory' : 'file', size: s.size, mtime: s.mtimeMs };
118
119
  } catch {
119
- return { path: p, name: basename(p), type: 'file', size: 0, mtime: 0 };
120
+ return { path: absPath, name: basename(p), type: 'file', size: 0, mtime: 0 };
120
121
  }
121
122
  });
122
123
  resolve(results);
package/src/security.js CHANGED
@@ -28,6 +28,9 @@ const HARD_BLOCKED_PATHS = [
28
28
  join(AGENT_DIR, 'pairings.json'), // 配对记录
29
29
  join(AGENT_DIR, 'mcp_servers.json'),// MCP 服务器配置
30
30
  join(AGENT_DIR, 'logs') + sep, // 日志目录
31
+ join(AGENT_DIR, '.trash') + sep, // 回收站目录(通过 /api/trash 端点管理)
32
+ join(AGENT_DIR, 'builtin_skills_state.json'), // 内置技能状态
33
+ join(AGENT_DIR, 'disabled_skills.json'), // 停用技能状态
31
34
  ];
32
35
 
33
36
  /**
package/src/server.js CHANGED
@@ -1,9 +1,9 @@
1
1
  // agent/src/server.js - HTTP Router + WebSocket 服务器
2
2
  import http from 'http';
3
3
  import { WebSocketServer } from 'ws';
4
- import { readFileSync, createWriteStream, statSync, existsSync } from 'fs';
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 } from 'path';
6
+ import { join, dirname, basename, resolve } from 'path';
7
7
  import { fileURLToPath } from 'url';
8
8
  import { ZipArchive } from 'archiver';
9
9
  import { homedir, tmpdir } from 'os';
@@ -13,8 +13,9 @@ import os from 'os';
13
13
  import { loadConfig } from './config.js';
14
14
  import { verifyToken, startPairCodeRotation, stopPairCodeRotation, handlePairRequest } from './auth.js';
15
15
  import { checkPath, checkCommand } from './security.js';
16
+ import { moveToTrash, restoreFromTrash, listTrash, startPeriodicCleanup, stopPeriodicCleanup } from './trash.js';
16
17
  import { executeCommand, executeCommandSync, addWsClient, disconnectWsClient, killProcess, getRunningProcesses } from './executor.js';
17
- import { setConsoleOutput, logAuth, logFs, logExec, logSecurity, logSystem, logError } from './logger.js';
18
+ import { setConsoleOutput, logAuth, logFs, logExec, logSecurity, logSystem, logError, queryLogs, getLogDates } from './logger.js';
18
19
  import { initSearchTools, getSearchToolsAvailable, searchFiles, searchContent } from './search.js';
19
20
  import {
20
21
  initializeMcpRegistry,
@@ -43,9 +44,11 @@ import {
43
44
  getSkillsDir
44
45
  } from './skill/registry.js';
45
46
  import { saveMarkdownSkill, importMarkdownSkillFromZip, importMarkdownSkillFromUrl } from './skill/loader.js';
47
+ import XLSX from 'xlsx';
46
48
 
47
49
  const __dirname = dirname(fileURLToPath(import.meta.url));
48
50
  const AGENT_VERSION = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8')).version;
51
+ const AGENT_START_TIME = Date.now();
49
52
 
50
53
  /**
51
54
  * 跨平台 ZIP 打包(使用 archiver,替换 spawn zip)
@@ -88,10 +91,11 @@ async function createZipBuffer(sourceNames, cwd, outputPath) {
88
91
  });
89
92
  }
90
93
 
91
- const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10MB
94
+ const MAX_BODY_SIZE = 200 * 1024 * 1024; // 200MB(JSON body 解析上限,旧 base64 上传受此限制)
92
95
  const MAX_SEARCH_RESULTS = 5000; // 单次搜索最大结果数
93
96
  const PID_FILE = join(homedir(), '.ai-helper-agent', 'agent.pid');
94
- const DEFAULT_MAX_SIZE = 50 * 1024 * 1024; // 文件默认大小限制
97
+ const DEFAULT_MAX_SIZE = 50 * 1024 * 1024; // 文件默认大小限制(read/list 等读取操作)
98
+ const MAX_UPLOAD_SIZE = 200 * 1024 * 1024; // 流式上传最大 200MB(与 MAX_BODY_SIZE 一致,流式无 base64 膨胀,实际可传 200MB 原文件)
95
99
 
96
100
  function detectShell() {
97
101
  const platform = os.platform();
@@ -356,6 +360,10 @@ export function startServer() {
356
360
 
357
361
  // 防止 shutdown 并发执行
358
362
  let shuttingDown = false;
363
+ // restart/update/shutdown 防重入 + 频率限制
364
+ let agentOperationInProgress = false;
365
+ let lastOperationTime = 0;
366
+ const OPERATION_COOLDOWN_MS = 10000; // 10s 冷却,防 DoS
359
367
 
360
368
  // ==================== HTTP Server ====================
361
369
  const server = http.createServer((req, res) => {
@@ -431,24 +439,178 @@ export function startServer() {
431
439
  return jsonResponse(res, 403, { success: false, error: '认证 token 无效' });
432
440
  }
433
441
 
434
- // Agent 关闭(需认证 + 仅限本地来源,防止远程无认证关闭 Agent 造成 DoS)
442
+ // Agent 关闭(需认证)
435
443
  if (req.method === 'POST' && pathname === '/api/shutdown') {
436
- const remoteAddr = req.socket.remoteAddress || '';
437
- const isLocal = remoteAddr === '127.0.0.1' || remoteAddr === '::1' || remoteAddr === '::ffff:127.0.0.1';
438
- if (!isLocal) {
439
- logSecurity('shutdown_remote_blocked', { remoteAddr, extId });
440
- return jsonResponse(res, 403, { success: false, error: '关闭 Agent 仅允许本地请求' });
444
+ if (agentOperationInProgress) {
445
+ return jsonResponse(res, 409, { success: false, error: '已有操作正在进行中,请稍后再试' });
441
446
  }
447
+ if (Date.now() - lastOperationTime < OPERATION_COOLDOWN_MS) {
448
+ const wait = Math.ceil((OPERATION_COOLDOWN_MS - (Date.now() - lastOperationTime)) / 1000);
449
+ return jsonResponse(res, 429, { success: false, error: `操作过于频繁,请 ${wait} 秒后再试` });
450
+ }
451
+ agentOperationInProgress = true;
452
+ lastOperationTime = Date.now();
442
453
  logSystem('shutdown', { reason: 'api_request', extId });
443
454
  jsonResponse(res, 200, { success: true, message: 'Agent 正在关闭...' });
444
455
  shutdown();
445
456
  return;
446
457
  }
447
458
 
459
+ // 重启 Agent(需认证)
460
+ if (req.method === 'POST' && pathname === '/api/agent/restart') {
461
+ if (agentOperationInProgress) {
462
+ return jsonResponse(res, 409, { success: false, error: '已有操作正在进行中,请稍后再试' });
463
+ }
464
+ if (Date.now() - lastOperationTime < OPERATION_COOLDOWN_MS) {
465
+ const wait = Math.ceil((OPERATION_COOLDOWN_MS - (Date.now() - lastOperationTime)) / 1000);
466
+ return jsonResponse(res, 429, { success: false, error: `操作过于频繁,请 ${wait} 秒后再试` });
467
+ }
468
+ agentOperationInProgress = true;
469
+ lastOperationTime = Date.now();
470
+ logSystem('restart', { reason: 'api_request', extId });
471
+ jsonResponse(res, 200, { success: true, message: 'Agent 正在重启...' });
472
+
473
+ // 转成绝对路径,避免相对路径在 detached 子进程中失效
474
+ const agentScript = resolve(process.argv[1]);
475
+ const restartPort = config.port || 18910;
476
+ const restartHost = config.host || '127.0.0.1';
477
+ const restartWorkdir = resolve(config.workdir || process.cwd());
478
+ const currentPid = process.pid;
479
+
480
+ // spawn 两阶段重启包装器(detached,独立于老进程存活)
481
+ // 包装器会先等老进程退出 + 端口释放,再启动新进程
482
+ const child = spawn(process.execPath, [
483
+ ...process.execArgv,
484
+ agentScript,
485
+ '_restart-helper',
486
+ '--old-pid', String(currentPid),
487
+ '--port', String(restartPort),
488
+ '--host', restartHost,
489
+ '--workdir', restartWorkdir,
490
+ '--script', agentScript
491
+ ], {
492
+ detached: true,
493
+ stdio: 'ignore',
494
+ cwd: process.cwd(),
495
+ env: { ...process.env },
496
+ windowsHide: true
497
+ });
498
+
499
+ child.on('error', (err) => {
500
+ logError('restart_spawn_failed', err.message);
501
+ });
502
+ child.unref();
503
+
504
+ // 延迟后关闭当前进程,让 _restart-helper 接管
505
+ setTimeout(() => shutdown(), 500);
506
+ return;
507
+ }
508
+
509
+ // 更新并重启 Agent(需认证)
510
+ if (req.method === 'POST' && pathname === '/api/agent/update') {
511
+ if (agentOperationInProgress) {
512
+ return jsonResponse(res, 409, { success: false, error: '已有操作正在进行中,请稍后再试' });
513
+ }
514
+ if (Date.now() - lastOperationTime < OPERATION_COOLDOWN_MS) {
515
+ const wait = Math.ceil((OPERATION_COOLDOWN_MS - (Date.now() - lastOperationTime)) / 1000);
516
+ return jsonResponse(res, 429, { success: false, error: `操作过于频繁,请 ${wait} 秒后再试` });
517
+ }
518
+ agentOperationInProgress = true;
519
+ lastOperationTime = Date.now();
520
+ logSystem('update', { reason: 'api_request', extId });
521
+
522
+ const agentScript = resolve(process.argv[1]);
523
+ const restartPort = config.port || 18910;
524
+ const restartHost = config.host || '127.0.0.1';
525
+ const restartWorkdir = resolve(config.workdir || process.cwd());
526
+ const currentPid = process.pid;
527
+
528
+ // 同步执行 npm 全局安装,完成后才返回结果(前端能收到真实成功/失败)
529
+ (async () => {
530
+ let npmSuccess = false;
531
+ let npmError = '';
532
+ const npmOutput = [];
533
+
534
+ try {
535
+ // npm install -g ai-helper-agent@latest
536
+ // 不指定 --registry,继承用户环境配置(公司内网/外网自适应)
537
+ const result = await new Promise((resolvePromise) => {
538
+ const npm = spawn('npm', ['install', '-g', 'ai-helper-agent@latest', '--no-audit', '--no-fund'], {
539
+ shell: true,
540
+ env: { ...process.env }
541
+ });
542
+ npm.stdout.on('data', d => npmOutput.push(d.toString().trim()));
543
+ npm.stderr.on('data', d => npmOutput.push(d.toString().trim()));
544
+ npm.on('error', (err) => resolvePromise({ success: false, error: err.message }));
545
+
546
+ // 超时保护(90s),防止 npm 卡住导致前端长等待
547
+ const timer = setTimeout(() => {
548
+ try { npm.kill('SIGTERM'); } catch {}
549
+ resolvePromise({ success: false, error: 'npm install 超时(90s)' });
550
+ }, 90000);
551
+ timer.unref();
552
+
553
+ npm.on('close', (code) => {
554
+ clearTimeout(timer);
555
+ resolvePromise({ success: code === 0, code });
556
+ });
557
+ });
558
+ npmSuccess = result.success;
559
+ if (!npmSuccess) {
560
+ npmError = result.error || `npm install 退出码: ${result.code}`;
561
+ }
562
+ } catch (err) {
563
+ npmError = err.message;
564
+ }
565
+
566
+ if (!npmSuccess) {
567
+ // 更新失败:不重启,保持老进程运行,提示用户手动执行
568
+ agentOperationInProgress = false; // 重置防重入标志,允许后续重试
569
+ logError('update_npm_failed', npmError + ' | output: ' + npmOutput.join('\n').slice(-500));
570
+ jsonResponse(res, 200, {
571
+ success: false,
572
+ error: '更新失败,请手动执行:npm install -g ai-helper-agent@latest'
573
+ });
574
+ return;
575
+ }
576
+
577
+ // 更新成功:返回响应并触发两阶段重启
578
+ logSystem('update_success', { output: npmOutput.join('\n').slice(-500) });
579
+ jsonResponse(res, 200, { success: true, message: '更新成功,Agent 正在重启...' });
580
+
581
+ // spawn 两阶段重启包装器
582
+ const child = spawn(process.execPath, [
583
+ ...process.execArgv,
584
+ agentScript,
585
+ '_restart-helper',
586
+ '--old-pid', String(currentPid),
587
+ '--port', String(restartPort),
588
+ '--host', restartHost,
589
+ '--workdir', restartWorkdir,
590
+ '--script', agentScript
591
+ ], {
592
+ detached: true,
593
+ stdio: 'ignore',
594
+ cwd: process.cwd(),
595
+ env: { ...process.env },
596
+ windowsHide: true
597
+ });
598
+
599
+ child.on('error', (err) => {
600
+ logError('update_restart_spawn_failed', err.message);
601
+ });
602
+ child.unref();
603
+
604
+ setTimeout(() => shutdown(), 500);
605
+ })();
606
+ return;
607
+ }
608
+
448
609
  const maxSize = config.fileMaxSize || DEFAULT_MAX_SIZE;
449
610
 
450
611
  // 认证后的状态信息(不再下发 pairCode:配对码仅用于一次性配对,避免已配对端横向获取)
451
612
  if (req.method === 'GET' && pathname === '/api/status/detail') {
613
+ const mem = process.memoryUsage();
452
614
  return jsonResponse(res, 200, {
453
615
  success: true,
454
616
  version: AGENT_VERSION,
@@ -456,8 +618,17 @@ export function startServer() {
456
618
  workdir: config.workdir,
457
619
  allowedPaths: config.allowedPaths,
458
620
  commandTimeout: config.commandTimeout,
459
- fileMaxSize: config.fileMaxSize,
621
+ fileMaxSize: config.fileMaxSize, // 读取大小限制(/api/fs/read、/api/fs/download)
622
+ uploadMaxSize: MAX_UPLOAD_SIZE, // 上传大小限制(/api/fs/upload-stream,流式不占内存可更大)
460
623
  runningProcesses: getRunningProcesses(),
624
+ resourceUsage: {
625
+ memoryUsedMB: Math.round(mem.heapUsed / 1024 / 1024 * 100) / 100,
626
+ memoryTotalMB: Math.round(mem.heapTotal / 1024 / 1024 * 100) / 100,
627
+ memoryRssMB: Math.round(mem.rss / 1024 / 1024 * 100) / 100,
628
+ uptimeSeconds: Math.round((Date.now() - AGENT_START_TIME) / 1000),
629
+ cpuUser: process.cpuUsage().user,
630
+ cpuSystem: process.cpuUsage().system
631
+ },
461
632
  ...PLATFORM_INFO,
462
633
  searchTools: getSearchToolsAvailable()
463
634
  });
@@ -468,8 +639,8 @@ export function startServer() {
468
639
  try {
469
640
  const { fileBuffer, fileName, mimeType } = await parseMultipartBody(req);
470
641
 
471
- // 安全检查:过滤文件名中的危险字符
472
- const safeName = fileName.replace(/[/\\:*?"<>|]/g, '_');
642
+ // 安全检查:过滤危险字符 + basename 防路径穿越(.. 等)
643
+ const safeName = basename(fileName.replace(/[/\\:*?"<>|]/g, '_')) || 'unnamed_file';
473
644
  const destPath = join(config.workdir, safeName);
474
645
 
475
646
  // 去重:如果文件已存在,添加后缀
@@ -504,6 +675,78 @@ export function startServer() {
504
675
  }
505
676
  }
506
677
 
678
+ // === 流式文件上传(raw binary body,目标路径通过 query 传递,避免 base64 膨胀) ===
679
+ // 必须在通用 JSON body 解析之前拦截,因为 body 是二进制而非 JSON
680
+ if (req.method === 'POST' && pathname === '/api/fs/upload-stream') {
681
+ const targetPath = url.searchParams.get('path');
682
+ if (!targetPath || typeof targetPath !== 'string') {
683
+ return jsonResponse(res, 400, { success: false, error: '缺少 path 参数' });
684
+ }
685
+ const check = await checkPath(targetPath);
686
+ if (!check.allowed) {
687
+ logSecurity('fs_upload_stream_blocked', { path: targetPath, reason: check.reason });
688
+ return jsonResponse(res, 403, { success: false, error: check.reason });
689
+ }
690
+
691
+ const maxSize = MAX_UPLOAD_SIZE;
692
+ const declaredSize = parseInt(req.headers['content-length'] || '0', 10);
693
+ if (declaredSize > maxSize) {
694
+ return jsonResponse(res, 400, { success: false, error: `文件过大 (${declaredSize} > ${maxSize})` });
695
+ }
696
+
697
+ await mkdir(dirname(check.resolved), { recursive: true });
698
+
699
+ try {
700
+ const written = await new Promise((resolve, reject) => {
701
+ const ws = createWriteStream(check.resolved);
702
+ let totalWritten = 0;
703
+ let aborted = false;
704
+
705
+ // 计数 + 超限保护(防止 Content-Length 造假或缺失)
706
+ const onReqData = (chunk) => {
707
+ totalWritten += chunk.length;
708
+ if (totalWritten > maxSize) {
709
+ aborted = true;
710
+ try { ws.destroy(); } catch {}
711
+ try { req.destroy(); } catch {}
712
+ reject(new Error(`文件过大 (已接收 ${totalWritten} > ${maxSize})`));
713
+ }
714
+ };
715
+ req.on('data', onReqData);
716
+ req.on('error', (err) => {
717
+ if (aborted) return;
718
+ aborted = true;
719
+ try { ws.destroy(); } catch {}
720
+ reject(err);
721
+ });
722
+ ws.on('error', (err) => {
723
+ if (aborted) return;
724
+ aborted = true;
725
+ reject(err);
726
+ });
727
+ ws.on('finish', () => {
728
+ if (aborted) return;
729
+ resolve(totalWritten);
730
+ });
731
+
732
+ req.pipe(ws);
733
+ });
734
+
735
+ // 脚本文件剥离执行权限
736
+ const SCRIPT_EXT_RE = /\.(sh|bash|zsh|py|js|mjs|rb|pl|php|lua)$/i;
737
+ if (SCRIPT_EXT_RE.test(check.resolved)) {
738
+ try { await chmod(check.resolved, 0o644); } catch {}
739
+ }
740
+
741
+ logFs('upload_stream', { path: check.resolved, size: written });
742
+ return jsonResponse(res, 200, { success: true, size: written, path: check.resolved });
743
+ } catch (err) {
744
+ logError('fs', 'upload_stream_error', { path: check.resolved, error: err.message });
745
+ try { await unlink(check.resolved); } catch {} // 清理半成品文件
746
+ return jsonResponse(res, 400, { success: false, error: `上传失败: ${err.message}` });
747
+ }
748
+ }
749
+
507
750
  if (['POST', 'PUT', 'DELETE'].includes(req.method)) {
508
751
  let body;
509
752
  try { body = await parseBody(req); }
@@ -581,23 +824,32 @@ export function startServer() {
581
824
  return jsonResponse(res, 403, { success: false, error: check.reason });
582
825
  }
583
826
  const rawContent = 'content' in body ? String(body.content) : '';
584
- const buf = Buffer.from(rawContent, 'utf-8');
827
+ const encoding = body.encoding === 'base64' ? 'base64' : 'utf-8';
828
+ // base64 编码:先解码为二进制 Buffer 再写入,避免把 base64 字符串当文本写入导致文件损坏
829
+ const buf = encoding === 'base64'
830
+ ? Buffer.from(rawContent, 'base64')
831
+ : Buffer.from(rawContent, 'utf-8');
585
832
  if (buf.length > maxSize) return jsonResponse(res, 400, { success: false, error: `内容过大 (${buf.length} > ${maxSize})` });
586
833
  // 确保父目录存在
587
834
  await mkdir(dirname(check.resolved), { recursive: true });
588
- await writeFile(check.resolved, rawContent, 'utf-8');
835
+ if (encoding === 'base64') {
836
+ // 二进制内容直接写入 Buffer
837
+ await writeFile(check.resolved, buf);
838
+ } else {
839
+ await writeFile(check.resolved, rawContent, 'utf-8');
840
+ }
589
841
 
590
842
  // 如果写入的是脚本文件,剥离执行权限防止直接运行
591
843
  const SCRIPT_EXT_RE = /\.(sh|bash|zsh|py|js|mjs|rb|pl|php|lua)$/i;
592
844
  const isScriptExt = SCRIPT_EXT_RE.test(check.resolved);
593
- const hasShebang = rawContent.startsWith('#!');
845
+ const hasShebang = encoding === 'utf-8' && rawContent.startsWith('#!');
594
846
  if (isScriptExt || hasShebang) {
595
847
  try {
596
848
  await chmod(check.resolved, 0o644);
597
849
  } catch {}
598
850
  }
599
851
 
600
- logFs('write', { path: check.resolved, size: buf.length });
852
+ logFs('write', { path: check.resolved, size: buf.length, encoding });
601
853
  return jsonResponse(res, 200, { success: true, size: buf.length, path: check.resolved });
602
854
  }
603
855
 
@@ -624,7 +876,39 @@ export function startServer() {
624
876
  return jsonResponse(res, 200, { success: true, entries, path: check.resolved });
625
877
  }
626
878
 
627
- // 删除文件/目录
879
+ // 获取文件详细信息(权限、创建/访问/修改时间、MIME 类型等)
880
+ if (pathname === '/api/fs/stat') {
881
+ const check = await checkPath(body.path);
882
+ if (!check.allowed) {
883
+ logSecurity('fs_stat_blocked', { path: body.path, reason: check.reason });
884
+ return jsonResponse(res, 403, { success: false, error: check.reason });
885
+ }
886
+ if (!await exists(check.resolved)) {
887
+ return jsonResponse(res, 404, { success: false, error: '文件/目录不存在' });
888
+ }
889
+ const s = await stat(check.resolved);
890
+ const info = {
891
+ name: basename(check.resolved),
892
+ path: check.resolved,
893
+ type: s.isDirectory() ? 'directory' : 'file',
894
+ size: s.size,
895
+ mtime: s.mtimeMs,
896
+ ctime: s.birthtimeMs || s.ctimeMs,
897
+ atime: s.atimeMs,
898
+ mode: s.mode,
899
+ isDirectory: s.isDirectory(),
900
+ isFile: s.isFile(),
901
+ isSymbolicLink: s.isSymbolicLink()
902
+ };
903
+ // Windows 没有 uid/gid
904
+ if (s.uid !== undefined) info.uid = s.uid;
905
+ if (s.gid !== undefined) info.gid = s.gid;
906
+ if (info.isFile) info.mimeType = getMimeType(check.resolved);
907
+ logFs('stat', { path: check.resolved, type: info.type, size: info.size });
908
+ return jsonResponse(res, 200, { success: true, info });
909
+ }
910
+
911
+ // 删除文件/目录(移至回收站,7天后自动清理)
628
912
  if (pathname === '/api/fs/delete') {
629
913
  const check = await checkPath(body.path);
630
914
  if (!check.allowed) {
@@ -634,13 +918,13 @@ export function startServer() {
634
918
  if (!await exists(check.resolved)) return jsonResponse(res, 404, { success: false, error: '文件/目录不存在' });
635
919
  const fstat2 = await stat(check.resolved);
636
920
  const isDir = fstat2.isDirectory();
637
- if (isDir) {
638
- await rmdir(check.resolved, { recursive: true });
639
- } else {
640
- await unlink(check.resolved);
921
+ const trashResult = await moveToTrash(check.resolved);
922
+ if (!trashResult.success) {
923
+ logError('fs', 'trash_error', { path: check.resolved, error: trashResult.error });
924
+ return jsonResponse(res, 500, { success: false, error: trashResult.error });
641
925
  }
642
- logFs('delete', { path: check.resolved, type: isDir ? 'directory' : 'file', size: fstat2.size });
643
- return jsonResponse(res, 200, { success: true, path: check.resolved });
926
+ logFs('delete', { path: check.resolved, type: isDir ? 'directory' : 'file', size: fstat2.size, trashId: trashResult.trashId });
927
+ return jsonResponse(res, 200, { success: true, path: check.resolved, trashId: trashResult.trashId, message: '已移至回收站(7天后自动清理)' });
644
928
  }
645
929
 
646
930
  // 下载文件/目录(返回 base64 内容,目录自动打包为 zip)
@@ -765,15 +1049,39 @@ export function startServer() {
765
1049
  const targetPath = body.path;
766
1050
  const paths = body.paths;
767
1051
 
768
- // 辅助:设置下载响应头并返回二进制
1052
+ // 辅助:设置下载响应头并分块流式返回二进制
1053
+ // 分块写入避免一次性 res.end(buffer) 导致客户端进度条瞬间跳到 100%
769
1054
  const sendBinary = (buffer, filename, mimeType) => {
770
1055
  const encodedName = encodeURIComponent(filename);
771
1056
  res.writeHead(200, {
772
1057
  'Content-Type': mimeType || 'application/octet-stream',
773
1058
  'Content-Disposition': `attachment; filename="${encodedName}"; filename*=UTF-8''${encodedName}`,
774
- 'Content-Length': buffer.length
1059
+ 'Content-Length': buffer.length,
1060
+ 'Cache-Control': 'no-cache'
775
1061
  });
776
- res.end(buffer);
1062
+
1063
+ // 分块写入:每块 64KB,写完一块让出事件循环,让 TCP 有机会发送数据
1064
+ // 客户端 reader.read() 会持续拿到 64KB 左右的 chunk,进度条平滑更新
1065
+ const CHUNK_SIZE = 64 * 1024;
1066
+ let offset = 0;
1067
+ const writeNext = () => {
1068
+ if (res.writableEnded) return;
1069
+ if (offset >= buffer.length) {
1070
+ res.end();
1071
+ return;
1072
+ }
1073
+ const end = Math.min(offset + CHUNK_SIZE, buffer.length);
1074
+ const chunk = buffer.subarray(offset, end);
1075
+ offset = end;
1076
+ if (res.write(chunk)) {
1077
+ // 缓冲区未满,用 setImmediate 让出事件循环,让 TCP 有机会发送
1078
+ setImmediate(writeNext);
1079
+ } else {
1080
+ // 缓冲区满了,等待 drain 事件再继续
1081
+ res.once('drain', writeNext);
1082
+ }
1083
+ };
1084
+ writeNext();
777
1085
  };
778
1086
 
779
1087
  try {
@@ -837,11 +1145,23 @@ export function startServer() {
837
1145
  try { await unlink(tmpFile); } catch {}
838
1146
  }
839
1147
  } else {
840
- // 单文件直接返回(不受 maxSize 限制,流式传输)
841
- const buf = await readFile(check.resolved);
1148
+ // 单文件:用 createReadStream 流式传输,避免 readFile 把整个文件读入内存导致 OOM
842
1149
  const mimeType = getMimeType(check.resolved);
843
- logFs('download_stream', { path: check.resolved, type: 'file', size: buf.length });
844
- return sendBinary(buf, basename(check.resolved), mimeType);
1150
+ const encodedName = encodeURIComponent(basename(check.resolved));
1151
+ res.writeHead(200, {
1152
+ 'Content-Type': mimeType || 'application/octet-stream',
1153
+ 'Content-Disposition': `attachment; filename="${encodedName}"; filename*=UTF-8''${encodedName}`,
1154
+ 'Content-Length': fstat.size,
1155
+ 'Cache-Control': 'no-cache'
1156
+ });
1157
+ const rs = createReadStream(check.resolved);
1158
+ rs.on('error', (err) => {
1159
+ logError('fs', 'download_stream_read_error', { path: check.resolved, error: err.message });
1160
+ try { res.destroy(); } catch {}
1161
+ });
1162
+ logFs('download_stream', { path: check.resolved, type: 'file', size: fstat.size });
1163
+ rs.pipe(res);
1164
+ return;
845
1165
  }
846
1166
  } else {
847
1167
  return jsonResponse(res, 400, { success: false, error: '缺少 path 或 paths 参数' });
@@ -852,6 +1172,63 @@ export function startServer() {
852
1172
  }
853
1173
  }
854
1174
 
1175
+ // XLSX 预览(服务端解析前 500 行,返回 JSON,避免前端引入 SheetJS)
1176
+ if (pathname === '/api/fs/preview-xlsx') {
1177
+ const filePath = body.path;
1178
+ if (!filePath || typeof filePath !== 'string') {
1179
+ return jsonResponse(res, 400, { success: false, error: '缺少 path 参数' });
1180
+ }
1181
+ const check = await checkPath(filePath);
1182
+ if (!check.allowed) {
1183
+ logSecurity('fs_preview_xlsx_blocked', { path: filePath, reason: check.reason });
1184
+ return jsonResponse(res, 403, { success: false, error: check.reason });
1185
+ }
1186
+ try {
1187
+ const buffer = await readFile(check.resolved);
1188
+ const workbook = XLSX.read(buffer, { type: 'buffer', sheetRows: 2001 });
1189
+ const sheets = workbook.SheetNames.map((name, idx) => {
1190
+ const sheet = workbook.Sheets[name];
1191
+ const range = XLSX.utils.decode_range(sheet['!ref'] || 'A1');
1192
+ const maxRow = Math.min(range.e.r, 2000);
1193
+ const rows = [];
1194
+ for (let r = range.s.r; r <= maxRow && r <= range.e.r; r++) {
1195
+ const row = [];
1196
+ for (let c = range.s.c; c <= range.e.c; c++) {
1197
+ const addr = XLSX.utils.encode_cell({ r, c });
1198
+ const cell = sheet[addr];
1199
+ row.push(cell ? String(cell.w ?? cell.v ?? '') : '');
1200
+ }
1201
+ rows.push(row);
1202
+ }
1203
+
1204
+ // 裁剪:去掉尾部全空行和尾部全空列(避免仅设置了边框的空单元格撑爆 DOM)
1205
+ let lastDataRow = -1;
1206
+ let lastDataCol = -1;
1207
+ for (let ri = 0; ri < rows.length; ri++) {
1208
+ for (let ci = 0; ci < rows[ri].length; ci++) {
1209
+ if (rows[ri][ci] !== '') {
1210
+ lastDataRow = Math.max(lastDataRow, ri);
1211
+ lastDataCol = Math.max(lastDataCol, ci);
1212
+ }
1213
+ }
1214
+ }
1215
+
1216
+ const trimmedRows = lastDataRow >= 0
1217
+ ? rows.slice(0, lastDataRow + 1).map(r => r.slice(0, lastDataCol + 1))
1218
+ : [];
1219
+ const colCount = lastDataCol >= 0 ? lastDataCol + 1 : 0;
1220
+ const totalRows = range.e.r - range.s.r + 1;
1221
+
1222
+ return { name, index: idx, colCount, totalRows, rows: trimmedRows };
1223
+ });
1224
+ logFs('preview_xlsx', { path: check.resolved, sheets: sheets.length });
1225
+ return jsonResponse(res, 200, { success: true, data: { sheets } });
1226
+ } catch (err) {
1227
+ logError('fs', 'preview_xlsx_error', { path: filePath, error: err.message });
1228
+ return jsonResponse(res, 500, { success: false, error: `解析失败: ${err.message}` });
1229
+ }
1230
+ }
1231
+
855
1232
  // 创建目录
856
1233
  if (pathname === '/api/fs/mkdir') {
857
1234
  const dirPath = body.path;
@@ -1075,6 +1452,40 @@ export function startServer() {
1075
1452
 
1076
1453
  // === Skill 操作(需要 body) ===
1077
1454
 
1455
+ // 回收站恢复
1456
+ if (pathname === '/api/trash/restore') {
1457
+ if (!body.trashId) return jsonResponse(res, 400, { success: false, error: '缺少 trashId 参数' });
1458
+ const result = await restoreFromTrash(body.trashId);
1459
+ if (result.success) {
1460
+ logFs('trash_restore', { trashId: body.trashId, restoredPath: result.restoredPath });
1461
+ } else {
1462
+ logSecurity('trash_restore_failed', { trashId: body.trashId, reason: result.error });
1463
+ }
1464
+ return jsonResponse(res, result.success ? 200 : 400, result);
1465
+ }
1466
+
1467
+ // 审计日志查询
1468
+ if (pathname === '/api/logs/query') {
1469
+ const limit = Math.min(body.limit || 200, 500); // 单次最多 500 条
1470
+ const cat = body.category || null;
1471
+ // 默认只查询最近 7 天的日志
1472
+ const today = new Date();
1473
+ const dates = [];
1474
+ for (let i = 0; i < 7; i++) {
1475
+ const d = new Date(today);
1476
+ d.setDate(d.getDate() - i);
1477
+ dates.push(d.toISOString().slice(0, 10));
1478
+ }
1479
+ let allEntries = [];
1480
+ for (const date of dates) {
1481
+ const result = queryLogs({ date, category: cat, limit: limit - allEntries.length, offset: 0 });
1482
+ allEntries = allEntries.concat(result.entries);
1483
+ if (allEntries.length >= limit) break;
1484
+ }
1485
+ allEntries = allEntries.slice(0, limit);
1486
+ return jsonResponse(res, 200, { success: true, entries: allEntries, total: allEntries.length, dates });
1487
+ }
1488
+
1078
1489
  // 执行 Skill
1079
1490
  if (pathname === '/api/skill/run') {
1080
1491
  if (!body.name) return jsonResponse(res, 400, { success: false, error: '缺少 name 参数' });
@@ -1205,6 +1616,21 @@ export function startServer() {
1205
1616
  if (!isHttp && !body.command) {
1206
1617
  return jsonResponse(res, 400, { success: false, error: 'stdio 传输需要提供 command' });
1207
1618
  }
1619
+ // stdio 传输:对 command + args 做安全校验,防止 MCP 成为命令管控后门
1620
+ if (!isHttp) {
1621
+ const fullCmd = body.args?.length > 0
1622
+ ? `${body.command} ${body.args.join(' ')}`
1623
+ : body.command;
1624
+ const cmdCheck = checkCommand(fullCmd, false);
1625
+ if (cmdCheck.level === 'deny') {
1626
+ logSecurity('mcp_stdio_denied', { command: fullCmd, reason: cmdCheck.reason });
1627
+ return jsonResponse(res, 403, { success: false, error: `MCP 命令被拦截: ${cmdCheck.reason}` });
1628
+ }
1629
+ if (cmdCheck.level === 'confirm') {
1630
+ logSecurity('mcp_stdio_confirm_required', { command: fullCmd, reason: cmdCheck.reason });
1631
+ return jsonResponse(res, 200, { success: true, level: 'confirm', reason: cmdCheck.reason, message: `MCP 命令需要确认: ${cmdCheck.reason}` });
1632
+ }
1633
+ }
1208
1634
  const result = await addMcpServer(body);
1209
1635
  logSystem('mcp_server_add', { serverId: body.id });
1210
1636
  return jsonResponse(res, result.success ? 200 : 400, result);
@@ -1339,6 +1765,12 @@ export function startServer() {
1339
1765
 
1340
1766
  // ========== MCP 管理接口(仅 GET 路由) ==========
1341
1767
 
1768
+ // 回收站列表
1769
+ if (req.method === 'GET' && pathname === '/api/trash/list') {
1770
+ const result = await listTrash();
1771
+ return jsonResponse(res, 200, result);
1772
+ }
1773
+
1342
1774
  // MCP Servers 列表
1343
1775
  if (req.method === 'GET' && pathname === '/api/mcp/servers') {
1344
1776
  return jsonResponse(res, 200, getMcpServersStatus());
@@ -1428,6 +1860,9 @@ export function startServer() {
1428
1860
  console.log(`[Agent] WebSocket 服务已启动: ws://${host}:${port}`);
1429
1861
  startPairCodeRotation();
1430
1862
 
1863
+ // 启动回收站定期清理(每6小时)+ 启动时清理一次过期文件
1864
+ startPeriodicCleanup();
1865
+
1431
1866
  // 后台异步初始化 MCP 和 Skill,不阻塞主流程
1432
1867
  (async () => {
1433
1868
  try {
@@ -1458,6 +1893,7 @@ export function startServer() {
1458
1893
  forceExitTimer.unref();
1459
1894
 
1460
1895
  stopPairCodeRotation();
1896
+ stopPeriodicCleanup();
1461
1897
 
1462
1898
  // 终止所有运行中的进程
1463
1899
  for (const entry of getRunningProcesses()) {
package/src/trash.js ADDED
@@ -0,0 +1,211 @@
1
+ // agent/src/trash.js - 文件回收站模块
2
+ // 删除文件时先移动到回收站,7天后自动清理
3
+ import { mkdir, rename, readFile, writeFile, readdir, stat, unlink, rmdir } from 'fs/promises';
4
+ import { join, basename } from 'path';
5
+ import { homedir } from 'os';
6
+ import { existsSync } from 'fs';
7
+
8
+ const AGENT_DIR = join(homedir(), '.ai-helper-agent');
9
+ const TRASH_DIR = join(AGENT_DIR, '.trash');
10
+ const TRASH_META_FILE = join(TRASH_DIR, 'metadata.json');
11
+ const TRASH_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 天
12
+ const PERIODIC_CLEAN_INTERVAL_MS = 6 * 60 * 60 * 1000; // 每 6 小时执行一次定期清理
13
+ let periodicCleanTimer = null;
14
+
15
+ /**
16
+ * 确保回收站目录存在
17
+ */
18
+ async function ensureTrashDir() {
19
+ try {
20
+ if (!existsSync(TRASH_DIR)) {
21
+ await mkdir(TRASH_DIR, { recursive: true });
22
+ }
23
+ } catch (err) {
24
+ console.error('[Trash] 无法创建回收站目录:', err.message);
25
+ throw err;
26
+ }
27
+ }
28
+
29
+ /**
30
+ * 读取回收站元数据
31
+ * @returns {Promise<Array>} [{ id, originalPath, name, size, deletedAt }]
32
+ */
33
+ async function loadMetadata() {
34
+ try {
35
+ if (existsSync(TRASH_META_FILE)) {
36
+ const raw = await readFile(TRASH_META_FILE, 'utf-8');
37
+ return JSON.parse(raw).entries || [];
38
+ }
39
+ } catch {}
40
+ return [];
41
+ }
42
+
43
+ /**
44
+ * 保存回收站元数据
45
+ */
46
+ async function saveMetadata(entries) {
47
+ await writeFile(TRASH_META_FILE, JSON.stringify({ entries }, null, 2), 'utf-8');
48
+ }
49
+
50
+ /**
51
+ * 清理过期回收站条目(超过7天)
52
+ * 同时在文件系统上删除对应文件/目录
53
+ */
54
+ async function cleanExpiredTrash() {
55
+ await ensureTrashDir();
56
+ const entries = await loadMetadata();
57
+ const now = Date.now();
58
+ const expired = entries.filter(e => (now - e.deletedAt) > TRASH_TTL_MS);
59
+ const kept = entries.filter(e => (now - e.deletedAt) <= TRASH_TTL_MS);
60
+
61
+ for (const entry of expired) {
62
+ const trashPath = join(TRASH_DIR, entry.id);
63
+ try {
64
+ const s = await stat(trashPath);
65
+ if (s.isDirectory()) {
66
+ await rmdir(trashPath, { recursive: true });
67
+ } else {
68
+ await unlink(trashPath);
69
+ }
70
+ } catch { /* 文件可能已被手动清理 */ }
71
+ }
72
+
73
+ if (expired.length > 0) {
74
+ await saveMetadata(kept);
75
+ console.log(`[Trash] 清理了 ${expired.length} 个过期回收站条目`);
76
+ }
77
+ }
78
+
79
+ /**
80
+ * 将文件/目录移动到回收站
81
+ * @param {string} sourcePath - 源文件/目录的绝对路径
82
+ * @returns {Promise<{success: boolean, trashId?: string, error?: string}>}
83
+ */
84
+ export async function moveToTrash(sourcePath) {
85
+ await ensureTrashDir();
86
+
87
+ // 每次删除前先清理过期条目(投机式清理,无需定时器)
88
+ await cleanExpiredTrash();
89
+
90
+ try {
91
+ const ts = Date.now();
92
+ const randomSuffix = Math.random().toString(36).slice(2, 8);
93
+ const id = `${ts}_${randomSuffix}`;
94
+ const name = basename(sourcePath);
95
+ const trashPath = join(TRASH_DIR, id);
96
+
97
+ // 获取文件信息
98
+ let size = 0;
99
+ try {
100
+ const s = await stat(sourcePath);
101
+ size = s.size;
102
+ } catch {}
103
+
104
+ // 移动到回收站
105
+ await rename(sourcePath, trashPath);
106
+
107
+ // 记录元数据
108
+ const entries = await loadMetadata();
109
+ entries.push({
110
+ id,
111
+ originalPath: sourcePath,
112
+ name,
113
+ size,
114
+ deletedAt: ts
115
+ });
116
+ await saveMetadata(entries);
117
+
118
+ console.log(`[Trash] 已移至回收站: ${sourcePath} -> ${trashPath}`);
119
+ return { success: true, trashId: id };
120
+ } catch (err) {
121
+ return { success: false, error: `移至回收站失败: ${err.message}` };
122
+ }
123
+ }
124
+
125
+ /**
126
+ * 从回收站恢复文件
127
+ * @param {string} trashId - 回收站条目 ID
128
+ * @returns {Promise<{success: boolean, error?: string}>}
129
+ */
130
+ export async function restoreFromTrash(trashId) {
131
+ const entries = await loadMetadata();
132
+ const idx = entries.findIndex(e => e.id === trashId);
133
+ if (idx === -1) {
134
+ return { success: false, error: '回收站条目不存在或已过期' };
135
+ }
136
+
137
+ const entry = entries[idx];
138
+ const trashPath = join(TRASH_DIR, entry.id);
139
+
140
+ if (!existsSync(trashPath)) {
141
+ // 文件已不在回收站,清理元数据
142
+ entries.splice(idx, 1);
143
+ await saveMetadata(entries);
144
+ return { success: false, error: '回收站文件已不存在' };
145
+ }
146
+
147
+ try {
148
+ // 检查原路径是否已被占用
149
+ if (existsSync(entry.originalPath)) {
150
+ return { success: false, error: `原位置已有文件: ${entry.originalPath}` };
151
+ }
152
+
153
+ await rename(trashPath, entry.originalPath);
154
+ entries.splice(idx, 1);
155
+ await saveMetadata(entries);
156
+
157
+ console.log(`[Trash] 已恢复: ${entry.originalPath}`);
158
+ return { success: true, restoredPath: entry.originalPath };
159
+ } catch (err) {
160
+ return { success: false, error: `恢复失败: ${err.message}` };
161
+ }
162
+ }
163
+
164
+ /**
165
+ * 获取回收站条目列表(不包含清理逻辑,用于前端展示)
166
+ * @returns {Promise<{success: boolean, entries: Array}>}
167
+ */
168
+ export async function listTrash() {
169
+ await ensureTrashDir();
170
+ // 前先清理一次过期条目,保持视图清爽
171
+ await cleanExpiredTrash();
172
+ const entries = await loadMetadata();
173
+ return { success: true, entries };
174
+ }
175
+
176
+ /**
177
+ * 获取回收站目录路径(用于配置 etc)
178
+ */
179
+ export function getTrashDir() {
180
+ return TRASH_DIR;
181
+ }
182
+
183
+ /**
184
+ * 启动定期清理定时器(每6小时执行一次过期清理)
185
+ * 配合 moveToTrash/listTrash 中的投机式清理,确保即使长期不操作也能清理过期文件
186
+ */
187
+ export function startPeriodicCleanup() {
188
+ if (periodicCleanTimer) return; // 防止重复启动
189
+ periodicCleanTimer = setInterval(async () => {
190
+ try {
191
+ await cleanExpiredTrash();
192
+ } catch (err) {
193
+ console.error('[Trash] 定期清理出错:', err.message);
194
+ }
195
+ }, PERIODIC_CLEAN_INTERVAL_MS);
196
+ // 允许进程退出(不阻止事件循环),Node 会在退出时自动清理
197
+ if (periodicCleanTimer.unref) periodicCleanTimer.unref();
198
+ console.log('[Trash] 定期清理已启动(间隔6小时)');
199
+ }
200
+
201
+ /**
202
+ * 停止定期清理定时器
203
+ */
204
+ export function stopPeriodicCleanup() {
205
+ if (periodicCleanTimer) {
206
+ clearInterval(periodicCleanTimer);
207
+ periodicCleanTimer = null;
208
+ }
209
+ }
210
+
211
+ export { TRASH_DIR, TRASH_TTL_MS };