ai-helper-agent 1.9.4 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-helper-agent",
3
- "version": "1.9.4",
3
+ "version": "1.10.0",
4
4
  "description": "AI Helper 代理服务 - 为 Chrome 扩展提供本地文件读写和系统命令执行能力",
5
5
  "type": "module",
6
6
  "main": "./src/server.js",
package/src/executor.js CHANGED
@@ -51,9 +51,14 @@ function getShellForExec() {
51
51
  return { shell: '/bin/bash', args: ['-c'] };
52
52
  }
53
53
 
54
- // 运行中的进程映射:execId → { process, wsClients: Set, timeoutId, forceKillId }
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 }
59
+ const completedProcesses = new Map();
60
+ const COMPLETED_CACHE_TTL = 30000; // 30秒后清理
61
+
57
62
  // 安全的子进程环境变量白名单(不泄露宿主敏感信息)
58
63
  const SAFE_ENV_KEYS = [
59
64
  'PATH', 'HOME', 'USER', 'SHELL', 'TERM', 'LANG', 'LC_ALL', 'LC_CTYPE',
@@ -127,7 +132,8 @@ function executeCommand(command, cwd, wsClient, onComplete, collectOutput = fals
127
132
  let finished = false; // 防止 close/error 重复处理
128
133
 
129
134
  // 存储到 entry 中供 clearTimers 读取
130
- const entry = { process: proc, wsClients };
135
+ // stdoutBuf/stderrBuf 始终收集,用于回放给延迟连接的 WS 客户端
136
+ const entry = { process: proc, wsClients, stdoutBuf: '', stderrBuf: '' };
131
137
  runningProcesses.set(execId, entry);
132
138
 
133
139
  // 超时控制
@@ -160,6 +166,7 @@ function executeCommand(command, cwd, wsClient, onComplete, collectOutput = fals
160
166
  proc.stdout.on('data', (chunk) => {
161
167
  const str = chunk.toString();
162
168
  if (collectOutput) stdoutCollected += str;
169
+ entry.stdoutBuf += str;
163
170
  broadcast({ type: 'stdout', data: str, execId });
164
171
  });
165
172
 
@@ -167,6 +174,7 @@ function executeCommand(command, cwd, wsClient, onComplete, collectOutput = fals
167
174
  proc.stderr.on('data', (chunk) => {
168
175
  const str = chunk.toString();
169
176
  if (collectOutput) stderrCollected += str;
177
+ entry.stderrBuf += str;
170
178
  broadcast({ type: 'stderr', data: str, execId });
171
179
  });
172
180
 
@@ -175,12 +183,24 @@ function executeCommand(command, cwd, wsClient, onComplete, collectOutput = fals
175
183
  if (finished) return;
176
184
  finished = true;
177
185
  clearTimers();
178
- broadcast({ type: 'exit', exitCode: typeof exitCode === 'number' ? exitCode : -1, execId, killed });
186
+ const finalExitCode = typeof exitCode === 'number' ? exitCode : -1;
187
+ broadcast({ type: 'exit', exitCode: finalExitCode, execId, killed });
188
+ // 移入已完成缓存,解决竞态条件:延迟连接的 WS 客户端可回放输出
189
+ const cleanupTimeout = setTimeout(() => {
190
+ completedProcesses.delete(execId);
191
+ }, COMPLETED_CACHE_TTL);
192
+ completedProcesses.set(execId, {
193
+ stdoutBuf: entry.stdoutBuf,
194
+ stderrBuf: entry.stderrBuf,
195
+ exitCode: finalExitCode,
196
+ killed,
197
+ cleanupTimeout
198
+ });
179
199
  runningProcesses.delete(execId);
180
200
  if (onComplete) {
181
201
  onComplete({
182
202
  execId,
183
- exitCode: typeof exitCode === 'number' ? exitCode : -1,
203
+ exitCode: finalExitCode,
184
204
  killed,
185
205
  stdout: collectOutput ? stdoutCollected : undefined,
186
206
  stderr: collectOutput ? stderrCollected : undefined
@@ -194,6 +214,18 @@ function executeCommand(command, cwd, wsClient, onComplete, collectOutput = fals
194
214
  finished = true;
195
215
  clearTimers();
196
216
  broadcast({ type: 'error', error: err.message, execId });
217
+ // 移入已完成缓存
218
+ const cleanupTimeout = setTimeout(() => {
219
+ completedProcesses.delete(execId);
220
+ }, COMPLETED_CACHE_TTL);
221
+ completedProcesses.set(execId, {
222
+ stdoutBuf: entry.stdoutBuf,
223
+ stderrBuf: entry.stderrBuf,
224
+ exitCode: -1,
225
+ killed: false,
226
+ error: err.message,
227
+ cleanupTimeout
228
+ });
197
229
  runningProcesses.delete(execId);
198
230
  if (onComplete) {
199
231
  onComplete({
@@ -241,6 +273,7 @@ function executeCommandSync(command, cwd) {
241
273
 
242
274
  /**
243
275
  * 添加 WebSocket 客户端到已有进程的监听中
276
+ * 如果进程已结束,从 completedProcesses 缓存回放全部输出
244
277
  */
245
278
  function addWsClient(execId, wsClient) {
246
279
  const entry = runningProcesses.get(execId);
@@ -248,6 +281,23 @@ function addWsClient(execId, wsClient) {
248
281
  entry.wsClients.add(wsClient);
249
282
  return true;
250
283
  }
284
+ // 进程已结束,检查缓存并回放全部输出
285
+ const completed = completedProcesses.get(execId);
286
+ if (completed) {
287
+ if (completed.stdoutBuf) {
288
+ wsClient.send(JSON.stringify({ type: 'stdout', data: completed.stdoutBuf, execId }));
289
+ }
290
+ if (completed.stderrBuf) {
291
+ wsClient.send(JSON.stringify({ type: 'stderr', data: completed.stderrBuf, execId }));
292
+ }
293
+ if (completed.error) {
294
+ wsClient.send(JSON.stringify({ type: 'error', error: completed.error, execId }));
295
+ }
296
+ wsClient.send(JSON.stringify({ type: 'exit', exitCode: completed.exitCode, execId, killed: completed.killed }));
297
+ clearTimeout(completed.cleanupTimeout);
298
+ completedProcesses.delete(execId);
299
+ return true;
300
+ }
251
301
  return false;
252
302
  }
253
303
 
package/src/server.js CHANGED
@@ -3,10 +3,11 @@ import http from 'http';
3
3
  import { WebSocketServer } from 'ws';
4
4
  import { readFileSync } from 'fs';
5
5
  import { readFile, writeFile, readdir, stat, unlink, rmdir, chmod, mkdir } from 'fs/promises';
6
- import { join, dirname } from 'path';
6
+ import { join, dirname, basename } from 'path';
7
7
  import { fileURLToPath } from 'url';
8
- import { homedir } from 'os';
9
- import { spawn } from 'child_process';
8
+ import { homedir, tmpdir } from 'os';
9
+ import { spawn, execSync } from 'child_process';
10
+ import { randomBytes } from 'crypto';
10
11
  import os from 'os';
11
12
  import { loadConfig } from './config.js';
12
13
  import { verifyToken, getCurrentPairCode, startPairCodeRotation, stopPairCodeRotation, handlePairRequest } from './auth.js';
@@ -107,6 +108,35 @@ async function exists(path) {
107
108
  catch { return false; }
108
109
  }
109
110
 
111
+ /**
112
+ * 根据文件扩展名返回 MIME 类型
113
+ */
114
+ function getMimeType(filePath) {
115
+ const ext = (filePath.split('.').pop() || '').toLowerCase();
116
+ const mimeMap = {
117
+ txt: 'text/plain', md: 'text/markdown', json: 'application/json',
118
+ js: 'application/javascript', mjs: 'application/javascript', ts: 'application/typescript',
119
+ jsx: 'text/jsx', tsx: 'text/tsx',
120
+ html: 'text/html', htm: 'text/html', css: 'text/css', scss: 'text/x-scss', less: 'text/x-less',
121
+ xml: 'application/xml', yaml: 'text/yaml', yml: 'text/yaml',
122
+ py: 'text/x-python', java: 'text/x-java', c: 'text/x-c', cpp: 'text/x-c++', h: 'text/x-c',
123
+ go: 'text/x-go', rs: 'text/x-rust', rb: 'text/x-ruby', php: 'text/x-php',
124
+ sql: 'text/x-sql', sh: 'text/x-sh', bash: 'text/x-sh', zsh: 'text/x-sh',
125
+ cfg: 'text/plain', ini: 'text/plain', toml: 'text/plain', conf: 'text/plain',
126
+ log: 'text/plain', csv: 'text/csv', tsv: 'text/tab-separated-values',
127
+ env: 'text/plain', vue: 'text/x-vue', svelte: 'text/x-svelte', astro: 'text/x-astro', rtf: 'application/rtf',
128
+ svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
129
+ gif: 'image/gif', webp: 'image/webp', bmp: 'image/bmp', ico: 'image/x-icon',
130
+ pdf: 'application/pdf',
131
+ doc: 'application/msword', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
132
+ xls: 'application/vnd.ms-excel', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
133
+ ppt: 'application/vnd.ms-powerpoint', pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
134
+ zip: 'application/zip', gz: 'application/gzip', tar: 'application/x-tar',
135
+ mp3: 'audio/mpeg', wav: 'audio/wav', mp4: 'video/mp4', webm: 'video/webm',
136
+ };
137
+ return mimeMap[ext] || 'application/octet-stream';
138
+ }
139
+
110
140
  const PLATFORM_INFO = {
111
141
  platform: os.platform(),
112
142
  platformName: (() => {
@@ -539,6 +569,129 @@ export function startServer() {
539
569
  return jsonResponse(res, 200, { success: true, path: check.resolved });
540
570
  }
541
571
 
572
+ // 下载文件/目录(返回 base64 内容,目录自动打包为 zip)
573
+ if (pathname === '/api/fs/download') {
574
+ const targetPath = body.path;
575
+ if (!targetPath || typeof targetPath !== 'string') {
576
+ return jsonResponse(res, 400, { success: false, error: '缺少 path 参数' });
577
+ }
578
+ const check = await checkPath(targetPath);
579
+ if (!check.allowed) {
580
+ logSecurity('fs_download_blocked', { path: targetPath, reason: check.reason });
581
+ return jsonResponse(res, 403, { success: false, error: check.reason });
582
+ }
583
+ if (!await exists(check.resolved)) {
584
+ return jsonResponse(res, 404, { success: false, error: '文件/目录不存在' });
585
+ }
586
+
587
+ try {
588
+ const fstat = await stat(check.resolved);
589
+ if (fstat.isDirectory()) {
590
+ // 目录:打包为 zip
591
+ const tmpFile = join(tmpdir(), `ws-dl-${randomBytes(6).toString('hex')}.zip`);
592
+ try {
593
+ const dirName = basename(check.resolved);
594
+ const parentDir = dirname(check.resolved);
595
+ execSync(`cd "${parentDir}" && zip -rq "${tmpFile}" "${dirName}"`, {
596
+ timeout: 120000,
597
+ maxBuffer: 10 * 1024 * 1024,
598
+ stdio: ['ignore', 'pipe', 'pipe']
599
+ });
600
+ const zipBuffer = await readFile(tmpFile);
601
+ logFs('download', { path: check.resolved, type: 'directory', zipSize: zipBuffer.length });
602
+ return jsonResponse(res, 200, {
603
+ success: true,
604
+ type: 'directory',
605
+ name: `${dirName}.zip`,
606
+ size: zipBuffer.length,
607
+ content: zipBuffer.toString('base64'),
608
+ mimeType: 'application/zip'
609
+ });
610
+ } finally {
611
+ try { await unlink(tmpFile); } catch {}
612
+ }
613
+ } else {
614
+ // 单个文件
615
+ if (fstat.size > maxSize) {
616
+ return jsonResponse(res, 400, { success: false, error: `文件过大 (${fstat.size} > ${maxSize})` });
617
+ }
618
+ const buf = await readFile(check.resolved);
619
+ const mimeType = getMimeType(check.resolved);
620
+ logFs('download', { path: check.resolved, type: 'file', size: buf.length });
621
+ return jsonResponse(res, 200, {
622
+ success: true,
623
+ type: 'file',
624
+ name: basename(check.resolved),
625
+ size: buf.length,
626
+ content: buf.toString('base64'),
627
+ mimeType
628
+ });
629
+ }
630
+ } catch (err) {
631
+ logError('fs', 'download_error', { path: targetPath, error: err.message });
632
+ return jsonResponse(res, 500, { success: false, error: `下载失败: ${err.message}` });
633
+ }
634
+ }
635
+
636
+ // 批量下载多个文件/目录(在后端打包为 zip)
637
+ if (pathname === '/api/fs/download-multi') {
638
+ const paths = body.paths;
639
+ if (!Array.isArray(paths) || paths.length === 0) {
640
+ return jsonResponse(res, 400, { success: false, error: '缺少 paths 参数或为空数组' });
641
+ }
642
+
643
+ const resolvedPaths = [];
644
+ for (const p of paths) {
645
+ const check = await checkPath(p);
646
+ if (!check.allowed) {
647
+ logSecurity('fs_download_blocked', { path: p, reason: check.reason });
648
+ return jsonResponse(res, 403, { success: false, error: `路径 "${p}" 不允许访问` });
649
+ }
650
+ if (!await exists(check.resolved)) {
651
+ return jsonResponse(res, 404, { success: false, error: `路径 "${p}" 不存在` });
652
+ }
653
+ resolvedPaths.push(check.resolved);
654
+ }
655
+
656
+ try {
657
+ const tmpFile = join(tmpdir(), `ws-dl-${randomBytes(6).toString('hex')}.zip`);
658
+ try {
659
+ const zipArgs = resolvedPaths.map(p => `"${basename(p)}"`).join(' ');
660
+ const commonParent = resolvedPaths.reduce((a, b) => {
661
+ const partsA = a.split('/').filter(Boolean);
662
+ const partsB = b.split('/').filter(Boolean);
663
+ const common = [];
664
+ for (let i = 0; i < Math.min(partsA.length, partsB.length); i++) {
665
+ if (partsA[i] === partsB[i]) common.push(partsA[i]);
666
+ else break;
667
+ }
668
+ return '/' + common.join('/');
669
+ });
670
+
671
+ execSync(`cd "${commonParent}" && zip -rq "${tmpFile}" ${zipArgs}`, {
672
+ timeout: 120000,
673
+ maxBuffer: 10 * 1024 * 1024,
674
+ stdio: ['ignore', 'pipe', 'pipe']
675
+ });
676
+ const zipBuffer = await readFile(tmpFile);
677
+ logFs('download_multi', { paths: resolvedPaths, zipSize: zipBuffer.length });
678
+ return jsonResponse(res, 200, {
679
+ success: true,
680
+ type: 'archive',
681
+ name: `workspace_${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.zip`,
682
+ size: zipBuffer.length,
683
+ content: zipBuffer.toString('base64'),
684
+ mimeType: 'application/zip'
685
+ });
686
+ } finally {
687
+ try { await unlink(tmpFile); } catch {}
688
+ }
689
+ } catch (err) {
690
+ logError('fs', 'download_multi_error', { paths, error: err.message });
691
+ return jsonResponse(res, 500, { success: false, error: `打包失败: ${err.message}` });
692
+ }
693
+ }
694
+
542
695
  // 在本地浏览器中打开文件
543
696
  if (pathname === '/api/browser/open') {
544
697
  const targetPath = body.path;