ai-helper-agent 1.11.0 → 1.12.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/bin/agent.js +122 -0
- package/package.json +2 -1
- package/src/executor.js +60 -20
- package/src/mcp/client.js +0 -1
- package/src/mcp/registry.js +2 -4
- package/src/search.js +4 -3
- package/src/security.js +3 -0
- package/src/server.js +468 -32
- package/src/trash.js +227 -0
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.
|
|
3
|
+
"version": "1.12.1",
|
|
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
|
-
//
|
|
58
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
331
|
-
* - Windows: taskkill /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:
|
|
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
|
-
//
|
|
379
|
+
// Unix: 负 PID 表示杀整个进程组(detached + 未调用 setsid 时有效)
|
|
352
380
|
try {
|
|
353
|
-
|
|
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
|
|
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
|
-
|
|
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({
|
|
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
|
}
|
package/src/mcp/registry.js
CHANGED
|
@@ -32,10 +32,8 @@ export async function initializeMcpRegistry() {
|
|
|
32
32
|
|
|
33
33
|
if (config.servers.length > 0) {
|
|
34
34
|
if (failed > 0) {
|
|
35
|
-
|
|
36
|
-
|
|
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(
|
|
117
|
-
return { path:
|
|
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:
|
|
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 =
|
|
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
|
|
442
|
+
// Agent 关闭(需认证)
|
|
435
443
|
if (req.method === 'POST' && pathname === '/api/shutdown') {
|
|
436
|
-
|
|
437
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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, isDir: trashResult.isDir, 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
|
-
|
|
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
|
-
//
|
|
841
|
-
const buf = await readFile(check.resolved);
|
|
1148
|
+
// 单文件:用 createReadStream 流式传输,避免 readFile 把整个文件读入内存导致 OOM
|
|
842
1149
|
const mimeType = getMimeType(check.resolved);
|
|
843
|
-
|
|
844
|
-
|
|
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,227 @@
|
|
|
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, isDir }]
|
|
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
|
+
* 递归计算目录实际大小
|
|
52
|
+
* @param {string} dirPath - 目录路径
|
|
53
|
+
* @returns {Promise<number>} 总大小(字节)
|
|
54
|
+
*/
|
|
55
|
+
async function getDirSize(dirPath) {
|
|
56
|
+
let total = 0;
|
|
57
|
+
try {
|
|
58
|
+
const items = await readdir(dirPath, { withFileTypes: true });
|
|
59
|
+
for (const item of items) {
|
|
60
|
+
const fullPath = join(dirPath, item.name);
|
|
61
|
+
if (item.isDirectory()) {
|
|
62
|
+
total += await getDirSize(fullPath);
|
|
63
|
+
} else if (item.isFile() || item.isSymbolicLink()) {
|
|
64
|
+
try {
|
|
65
|
+
const s = await stat(fullPath);
|
|
66
|
+
total += s.size;
|
|
67
|
+
} catch {}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
} catch {}
|
|
71
|
+
return total;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 清理过期回收站条目(超过7天)
|
|
76
|
+
*/
|
|
77
|
+
async function cleanExpiredTrash() {
|
|
78
|
+
await ensureTrashDir();
|
|
79
|
+
const entries = await loadMetadata();
|
|
80
|
+
const now = Date.now();
|
|
81
|
+
const expired = entries.filter(e => (now - e.deletedAt) > TRASH_TTL_MS);
|
|
82
|
+
const kept = entries.filter(e => (now - e.deletedAt) <= TRASH_TTL_MS);
|
|
83
|
+
|
|
84
|
+
for (const entry of expired) {
|
|
85
|
+
const trashPath = join(TRASH_DIR, entry.id);
|
|
86
|
+
try {
|
|
87
|
+
// 目录用 rename 存入,需递归删除;文件用 unlink
|
|
88
|
+
const s = await stat(trashPath);
|
|
89
|
+
if (s.isDirectory()) {
|
|
90
|
+
await rmdir(trashPath, { recursive: true });
|
|
91
|
+
} else {
|
|
92
|
+
await unlink(trashPath);
|
|
93
|
+
}
|
|
94
|
+
} catch { /* 文件可能已被手动清理 */ }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (expired.length > 0) {
|
|
98
|
+
await saveMetadata(kept);
|
|
99
|
+
console.log(`[Trash] 清理了 ${expired.length} 个过期回收站条目`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 将文件/目录移动到回收站(使用 rename,同文件系统瞬时完成)
|
|
105
|
+
* @param {string} sourcePath - 源文件/目录的绝对路径
|
|
106
|
+
* @returns {Promise<{success: boolean, trashId?: string, isDir?: boolean, error?: string}>}
|
|
107
|
+
*/
|
|
108
|
+
export async function moveToTrash(sourcePath) {
|
|
109
|
+
await ensureTrashDir();
|
|
110
|
+
await cleanExpiredTrash();
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const s = await stat(sourcePath);
|
|
114
|
+
const isDir = s.isDirectory();
|
|
115
|
+
const ts = Date.now();
|
|
116
|
+
const randomSuffix = Math.random().toString(36).slice(2, 8);
|
|
117
|
+
const id = `${ts}_${randomSuffix}`;
|
|
118
|
+
const name = basename(sourcePath);
|
|
119
|
+
const trashPath = join(TRASH_DIR, id);
|
|
120
|
+
|
|
121
|
+
// 获取实际大小(目录需要递归计算)
|
|
122
|
+
const size = isDir ? await getDirSize(sourcePath) : s.size;
|
|
123
|
+
|
|
124
|
+
// 移动到回收站(rename 是文件系统元数据操作,瞬时完成)
|
|
125
|
+
await rename(sourcePath, trashPath);
|
|
126
|
+
|
|
127
|
+
// 记录元数据
|
|
128
|
+
const entries = await loadMetadata();
|
|
129
|
+
entries.push({
|
|
130
|
+
id,
|
|
131
|
+
originalPath: sourcePath,
|
|
132
|
+
name,
|
|
133
|
+
size,
|
|
134
|
+
deletedAt: ts,
|
|
135
|
+
isDir
|
|
136
|
+
});
|
|
137
|
+
await saveMetadata(entries);
|
|
138
|
+
|
|
139
|
+
console.log(`[Trash] 已移至回收站: ${sourcePath} -> ${trashPath} (${size} 字节, ${isDir ? '目录' : '文件'})`);
|
|
140
|
+
return { success: true, trashId: id, isDir };
|
|
141
|
+
} catch (err) {
|
|
142
|
+
return { success: false, error: `移至回收站失败: ${err.message}` };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* 从回收站恢复文件/目录
|
|
148
|
+
* @param {string} trashId - 回收站条目 ID
|
|
149
|
+
* @returns {Promise<{success: boolean, restoredPath?: string, error?: string}>}
|
|
150
|
+
*/
|
|
151
|
+
export async function restoreFromTrash(trashId) {
|
|
152
|
+
const entries = await loadMetadata();
|
|
153
|
+
const idx = entries.findIndex(e => e.id === trashId);
|
|
154
|
+
if (idx === -1) {
|
|
155
|
+
return { success: false, error: '回收站条目不存在或已过期' };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const entry = entries[idx];
|
|
159
|
+
const trashPath = join(TRASH_DIR, entry.id);
|
|
160
|
+
|
|
161
|
+
if (!existsSync(trashPath)) {
|
|
162
|
+
entries.splice(idx, 1);
|
|
163
|
+
await saveMetadata(entries);
|
|
164
|
+
return { success: false, error: '回收站文件已不存在' };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
if (existsSync(entry.originalPath)) {
|
|
169
|
+
return { success: false, error: `原位置已有文件: ${entry.originalPath}` };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
await rename(trashPath, entry.originalPath);
|
|
173
|
+
entries.splice(idx, 1);
|
|
174
|
+
await saveMetadata(entries);
|
|
175
|
+
|
|
176
|
+
console.log(`[Trash] 已恢复: ${entry.originalPath}`);
|
|
177
|
+
return { success: true, restoredPath: entry.originalPath };
|
|
178
|
+
} catch (err) {
|
|
179
|
+
return { success: false, error: `恢复失败: ${err.message}` };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* 获取回收站条目列表
|
|
185
|
+
* @returns {Promise<{success: boolean, entries: Array}>}
|
|
186
|
+
*/
|
|
187
|
+
export async function listTrash() {
|
|
188
|
+
await ensureTrashDir();
|
|
189
|
+
await cleanExpiredTrash();
|
|
190
|
+
const entries = await loadMetadata();
|
|
191
|
+
return { success: true, entries };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* 获取回收站目录路径
|
|
196
|
+
*/
|
|
197
|
+
export function getTrashDir() {
|
|
198
|
+
return TRASH_DIR;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* 启动定期清理定时器(每6小时执行一次过期清理)
|
|
203
|
+
*/
|
|
204
|
+
export function startPeriodicCleanup() {
|
|
205
|
+
if (periodicCleanTimer) return;
|
|
206
|
+
periodicCleanTimer = setInterval(async () => {
|
|
207
|
+
try {
|
|
208
|
+
await cleanExpiredTrash();
|
|
209
|
+
} catch (err) {
|
|
210
|
+
console.error('[Trash] 定期清理出错:', err.message);
|
|
211
|
+
}
|
|
212
|
+
}, PERIODIC_CLEAN_INTERVAL_MS);
|
|
213
|
+
if (periodicCleanTimer.unref) periodicCleanTimer.unref();
|
|
214
|
+
console.log('[Trash] 定期清理已启动(间隔6小时)');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* 停止定期清理定时器
|
|
219
|
+
*/
|
|
220
|
+
export function stopPeriodicCleanup() {
|
|
221
|
+
if (periodicCleanTimer) {
|
|
222
|
+
clearInterval(periodicCleanTimer);
|
|
223
|
+
periodicCleanTimer = null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export { TRASH_DIR, TRASH_TTL_MS };
|