ai-helper-agent 1.9.5 → 1.11.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.5",
3
+ "version": "1.11.0",
4
4
  "description": "AI Helper 代理服务 - 为 Chrome 扩展提供本地文件读写和系统命令执行能力",
5
5
  "type": "module",
6
6
  "main": "./src/server.js",
@@ -21,6 +21,7 @@
21
21
  },
22
22
  "dependencies": {
23
23
  "@modelcontextprotocol/sdk": "^1.29.0",
24
+ "archiver": "^8.0.0",
24
25
  "ws": "^8.18.0",
25
26
  "zod": "^4.4.3"
26
27
  },
package/src/auth.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import crypto from 'crypto';
3
3
  import { loadConfig, loadPairings, savePairing } from './config.js';
4
4
 
5
- // 当前有效的配对码(30秒刷新一次)
5
+ // 当前有效的配对码(定时刷新 + 一次性使用)
6
6
  let currentPairCode = null;
7
7
  let pairCodeTimer = null;
8
8
 
@@ -12,11 +12,26 @@ let lastAuthTime = 0;
12
12
  // 判定"活跃"的时间窗口:此时间内有过认证则认为仍有扩展连接
13
13
  const ACTIVE_WINDOW_MS = 5 * 60 * 1000; // 5 分钟
14
14
 
15
+ // 配对码字符表:排除易混淆字符(0/O、1/I/l);6 位字母数字(≈21亿种,抗暴破)
16
+ const PAIR_CODE_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
17
+ const PAIR_CODE_LEN = 6;
18
+
19
+ // 配对失败锁定:连续失败 PAIR_FAIL_LIMIT 次后锁定 PAIR_LOCK_DURATION_MS 毫秒
20
+ let pairFailCount = 0;
21
+ let pairLockUntil = 0;
22
+ const PAIR_FAIL_LIMIT = 5;
23
+ const PAIR_LOCK_DURATION_MS = 60 * 1000; // 60 秒
24
+
15
25
  /**
16
- * 生成随机配对码(4位数字,使用密码学安全的随机数)
26
+ * 生成随机配对码(6位字母数字,使用密码学安全的随机数,排除易混淆字符)
17
27
  */
18
28
  function generatePairCode() {
19
- return String(crypto.randomInt(1000, 10000));
29
+ const bytes = crypto.randomBytes(PAIR_CODE_LEN);
30
+ let code = '';
31
+ for (let i = 0; i < PAIR_CODE_LEN; i++) {
32
+ code += PAIR_CODE_CHARS[bytes[i] % PAIR_CODE_CHARS.length];
33
+ }
34
+ return code;
20
35
  }
21
36
 
22
37
  /**
@@ -39,21 +54,15 @@ function startPairCodeRotation() {
39
54
 
40
55
  // 有配对且最近有活跃连接,无需刷新配对码
41
56
  if (hasPairings && isRecentlyActive) {
42
- console.log(`[Agent] 已有 ${Object.keys(pairings).length} 个配对扩展,最近 ${Math.round((Date.now() - lastAuthTime) / 1000)}s 前有认证活动,跳过配对码刷新`);
43
57
  currentPairCode = generatePairCode();
44
- console.log(`[Agent] 当前配对码(用于新增配对): ${currentPairCode}`);
58
+ console.log(`\n[Agent] 配对码: ${currentPairCode}\n`);
45
59
  return;
46
60
  }
47
61
 
48
- // 有配对但无活跃连接,给出提示
49
- if (hasPairings && !isRecentlyActive) {
50
- console.log(`[Agent] 存在配对记录但无活跃连接(超过 ${ACTIVE_WINDOW_MS / 60000} 分钟),恢复配对码刷新`);
51
- }
52
-
53
62
  const ttl = (config.pairCodeTTL || 30) * 1000;
54
63
 
55
64
  currentPairCode = generatePairCode();
56
- console.log(`[Agent] 当前配对码: ${currentPairCode}`);
65
+ console.log(`\n[Agent] 配对码: ${currentPairCode}\n`);
57
66
 
58
67
  if (pairCodeTimer) clearInterval(pairCodeTimer);
59
68
  pairCodeTimer = setInterval(() => {
@@ -89,17 +98,38 @@ function verifyToken(token) {
89
98
 
90
99
  /**
91
100
  * 处理配对请求(异步,支持写入锁)
101
+ * 安全策略:
102
+ * - 失败锁定:连续失败 PAIR_FAIL_LIMIT 次后临时锁定,并立即轮换配对码使已观测码失效
103
+ * - 一次性使用:配对成功后立即轮换配对码,防止配对码被重复利用
92
104
  */
93
105
  async function handlePairRequest(code, extensionId) {
106
+ // 锁定期内直接拒绝(不泄露是否处于锁定状态细节)
107
+ if (Date.now() < pairLockUntil) {
108
+ return { success: false, error: '配对尝试过于频繁,请稍后再试' };
109
+ }
94
110
  if (!code || !extensionId) {
95
111
  return { success: false, error: '缺少配对码或扩展ID' };
96
112
  }
97
- if (code !== currentPairCode) {
113
+ if (code.toUpperCase() !== currentPairCode) {
114
+ pairFailCount++;
115
+ if (pairFailCount >= PAIR_FAIL_LIMIT) {
116
+ pairLockUntil = Date.now() + PAIR_LOCK_DURATION_MS;
117
+ pairFailCount = 0;
118
+ // 锁定时立即轮换配对码,使攻击者已观测到的配对码失效
119
+ currentPairCode = generatePairCode();
120
+ console.log('[Agent] 配对失败次数过多,已临时锁定 60s 并轮换配对码');
121
+ }
98
122
  return { success: false, error: '配对码无效' };
99
123
  }
124
+ // 配对码正确:重置失败计数
125
+ pairFailCount = 0;
126
+
100
127
  // 检查是否已有配对
101
128
  const pairings = loadPairings();
102
129
  if (pairings[extensionId]) {
130
+ // 一次性使用:成功后立即轮换配对码
131
+ currentPairCode = generatePairCode();
132
+ console.log('[Agent] 配对码校验通过,配对码已轮换(一次性使用)');
103
133
  return { success: true, token: pairings[extensionId].token, message: '已配对,返回现有 token' };
104
134
  }
105
135
  // 生成新 token 并保存
@@ -109,6 +139,9 @@ async function handlePairRequest(code, extensionId) {
109
139
  } catch (err) {
110
140
  return { success: false, error: `配对保存失败: ${err.message}` };
111
141
  }
142
+ // 一次性使用:成功后立即轮换配对码
143
+ currentPairCode = generatePairCode();
144
+ console.log('[Agent] 配对成功,配对码已轮换(一次性使用)');
112
145
  return { success: true, token, message: '配对成功' };
113
146
  }
114
147
 
package/src/mcp/client.js CHANGED
@@ -33,7 +33,6 @@ export class McpClient {
33
33
  case TRANSPORT_SSE: {
34
34
  const url = this.serverConfig.url;
35
35
  if (!url) throw new Error('SSE 传输需要提供 url');
36
- console.log(`[MCP:${this.serverId}] 使用 SSE 传输,URL: ${url}`);
37
36
  const opts = {};
38
37
  if (Object.keys(headers).length > 0) {
39
38
  opts.requestInit = { headers };
@@ -44,7 +43,6 @@ export class McpClient {
44
43
  case TRANSPORT_STREAMABLE_HTTP: {
45
44
  const url = this.serverConfig.url;
46
45
  if (!url) throw new Error('StreamableHTTP 传输需要提供 url');
47
- console.log(`[MCP:${this.serverId}] 使用 StreamableHTTP 传输,URL: ${url}`);
48
46
  const opts = {};
49
47
  if (Object.keys(headers).length > 0) {
50
48
  opts.requestInit = { headers };
@@ -55,7 +53,6 @@ export class McpClient {
55
53
  case TRANSPORT_WEBSOCKET: {
56
54
  const url = this.serverConfig.url;
57
55
  if (!url) throw new Error('WebSocket 传输需要提供 url');
58
- console.log(`[MCP:${this.serverId}] 使用 WebSocket 传输,URL: ${url}`);
59
56
  return new WebSocketClientTransport(new URL(url));
60
57
  }
61
58
 
@@ -63,7 +60,6 @@ export class McpClient {
63
60
  default: {
64
61
  const command = this.serverConfig.command;
65
62
  if (!command) throw new Error('stdio 传输需要提供 command');
66
- console.log(`[MCP:${this.serverId}] 使用 stdio 传输,命令: ${command}`);
67
63
  return new StdioClientTransport({
68
64
  command: this.serverConfig.command,
69
65
  args: this.serverConfig.args || [],
@@ -92,12 +88,10 @@ export class McpClient {
92
88
  serverInfo: this.client.getServerVersion(),
93
89
  capabilities: this.client.getServerCapabilities()
94
90
  };
95
- console.log(`[MCP:${this.serverId}] Initialize 成功, server:`, this.serverInfo.serverInfo?.name);
96
91
 
97
92
  const toolsResult = await this.client.listTools({});
98
93
  if (toolsResult && toolsResult.tools) {
99
94
  this.tools = toolsResult.tools;
100
- console.log(`[MCP:${this.serverId}] 发现 ${this.tools.length} 个工具`);
101
95
  } else {
102
96
  this.tools = [];
103
97
  }
@@ -155,8 +149,6 @@ export class McpClient {
155
149
  try { await this.transport.close(); } catch {}
156
150
  this.transport = null;
157
151
  }
158
-
159
- console.log(`[MCP:${this.serverId}] 已断开连接`);
160
152
  }
161
153
 
162
154
  getStatus() {
@@ -14,14 +14,10 @@ export async function initializeMcpRegistry() {
14
14
  const config = loadMcpConfig();
15
15
  let connected = 0;
16
16
  let failed = 0;
17
-
18
- console.log(`[MCP Registry] 初始化,共配置 ${config.servers.length} 个 MCP Server`);
17
+ const failedServers = [];
19
18
 
20
19
  for (const server of config.servers) {
21
- if (!server.enabled) {
22
- console.log(`[MCP Registry] 跳过禁用的 Server: ${server.id}`);
23
- continue;
24
- }
20
+ if (!server.enabled) continue;
25
21
 
26
22
  const client = new McpClient(server);
27
23
  const result = await client.connect();
@@ -29,12 +25,21 @@ export async function initializeMcpRegistry() {
29
25
  clients.set(server.id, client);
30
26
  connected++;
31
27
  } else {
32
- console.error(`[MCP Registry] 连接失败 "${server.id}":`, result.error);
33
28
  failed++;
29
+ failedServers.push({ id: server.id, error: result.error });
34
30
  }
35
31
  }
36
32
 
37
- console.log(`[MCP Registry] 初始化完成: ${connected} 已连接, ${failed} 失败`);
33
+ if (config.servers.length > 0) {
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
+ }
39
+ } else if (connected > 0) {
40
+ console.log(`[MCP] ${connected} 个 Server 已连接`);
41
+ }
42
+ }
38
43
  return { connected, failed };
39
44
  }
40
45
 
@@ -154,7 +159,6 @@ export async function disconnectMcpServer(serverId) {
154
159
  * 关闭所有 MCP 连接(Agent 关闭时调用)
155
160
  */
156
161
  export async function shutdownMcpRegistry() {
157
- console.log('[MCP Registry] 关闭所有 MCP 连接...');
158
162
  for (const [id, client] of clients) {
159
163
  try { await client.disconnect(); } catch {}
160
164
  }
package/src/search.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // agent/src/search.js - 文件搜索(fd/rg 优先 + Node.js 回退)
2
2
  import { spawn } from 'child_process';
3
3
  import { readdir, stat, readFile } from 'fs/promises';
4
+ import { statSync } from 'fs';
4
5
  import { join, basename, extname, resolve } from 'path';
5
6
  import { checkPath } from './security.js';
6
7
 
@@ -43,8 +44,6 @@ export async function initSearchTools() {
43
44
  const [fd, rg] = await Promise.all([checkFdAvailable(), checkRgAvailable()]);
44
45
  fdAvailable = fd;
45
46
  rgAvailable = rg;
46
- console.log(`[Agent] fd (文件搜索): ${fd ? '✅ 可用' : '❌ 未安装,使用 Node.js 回退'}`);
47
- console.log(`[Agent] rg (内容搜索): ${rg ? '✅ 可用' : '❌ 未安装,使用 Node.js 回退'}`);
48
47
  return { fd: fdAvailable, rg: rgAvailable };
49
48
  })();
50
49
  return searchToolsInitPromise;
@@ -81,11 +80,15 @@ function matchGlob(filename, pattern) {
81
80
  }
82
81
 
83
82
  /**
84
- * 使用 fd 搜索文件(快速)
83
+ * 使用 fd 搜索文件和目录(快速)
85
84
  */
86
85
  function searchWithFd(rootPath, filePattern, recursive, maxResults) {
87
86
  return new Promise((resolve) => {
88
- const args = ['--glob', filePattern, '--type', 'f', '--max-results', String(maxResults)];
87
+ const args = ['--glob', filePattern, '--type', 'f', '--type', 'd', '--max-results', String(maxResults)];
88
+ // 排除默认忽略的目录
89
+ for (const dir of DEFAULT_IGNORE_DIRS) {
90
+ args.push('--exclude', dir);
91
+ }
89
92
  if (!recursive) args.push('--max-depth', '1');
90
93
  args.push(rootPath);
91
94
 
@@ -111,9 +114,9 @@ function searchWithFd(rootPath, filePattern, recursive, maxResults) {
111
114
  const results = lines.map(p => {
112
115
  try {
113
116
  const s = statSync(p);
114
- return { path: p, name: basename(p), size: s.size, mtime: s.mtimeMs };
117
+ return { path: p, name: basename(p), type: s.isDirectory() ? 'directory' : 'file', size: s.size, mtime: s.mtimeMs };
115
118
  } catch {
116
- return { path: p, name: basename(p), size: 0, mtime: 0 };
119
+ return { path: p, name: basename(p), type: 'file', size: 0, mtime: 0 };
117
120
  }
118
121
  });
119
122
  resolve(results);
@@ -131,7 +134,7 @@ function yieldEventLoop() {
131
134
  }
132
135
 
133
136
  /**
134
- * Node.js 原生递归搜索文件(回退方案,异步 + 分片防止阻塞事件循环)
137
+ * Node.js 原生递归搜索文件和目录(回退方案,异步 + 分片防止阻塞事件循环)
135
138
  */
136
139
  async function searchFilesNative(rootPath, filePattern, recursive, maxResults) {
137
140
  const results = [];
@@ -154,14 +157,23 @@ async function searchFilesNative(rootPath, filePattern, recursive, maxResults) {
154
157
  if (entry.isDirectory()) {
155
158
  if (DEFAULT_IGNORE_DIRS.has(entry.name)) continue;
156
159
  if (entry.name.startsWith('.')) continue;
160
+ // 目录也参与搜索匹配
161
+ if (matchGlob(entry.name, filePattern)) {
162
+ try {
163
+ const s = await stat(fullPath);
164
+ results.push({ path: fullPath, name: entry.name, type: 'directory', size: s.size, mtime: s.mtimeMs });
165
+ } catch {
166
+ results.push({ path: fullPath, name: entry.name, type: 'directory', size: 0, mtime: 0 });
167
+ }
168
+ }
157
169
  await walk(fullPath, depth + 1);
158
170
  } else if (entry.isFile()) {
159
171
  if (matchGlob(entry.name, filePattern)) {
160
172
  try {
161
173
  const s = await stat(fullPath);
162
- results.push({ path: fullPath, name: entry.name, size: s.size, mtime: s.mtimeMs });
174
+ results.push({ path: fullPath, name: entry.name, type: 'file', size: s.size, mtime: s.mtimeMs });
163
175
  } catch {
164
- results.push({ path: fullPath, name: entry.name, size: 0, mtime: 0 });
176
+ results.push({ path: fullPath, name: entry.name, type: 'file', size: 0, mtime: 0 });
165
177
  }
166
178
  }
167
179
  }
package/src/server.js CHANGED
@@ -1,18 +1,20 @@
1
1
  // agent/src/server.js - HTTP Router + WebSocket 服务器
2
2
  import http from 'http';
3
3
  import { WebSocketServer } from 'ws';
4
- import { readFileSync } from 'fs';
5
- import { readFile, writeFile, readdir, stat, unlink, rmdir, chmod, mkdir } from 'fs/promises';
6
- import { join, dirname } from 'path';
4
+ import { readFileSync, createWriteStream, statSync, existsSync } from 'fs';
5
+ import { readFile, writeFile, readdir, stat, unlink, rmdir, chmod, mkdir, rename } from 'fs/promises';
6
+ import { join, dirname, basename } from 'path';
7
7
  import { fileURLToPath } from 'url';
8
- import { homedir } from 'os';
8
+ import { ZipArchive } from 'archiver';
9
+ import { homedir, tmpdir } from 'os';
9
10
  import { spawn } from 'child_process';
11
+ import { randomBytes } from 'crypto';
10
12
  import os from 'os';
11
13
  import { loadConfig } from './config.js';
12
- import { verifyToken, getCurrentPairCode, startPairCodeRotation, stopPairCodeRotation, handlePairRequest } from './auth.js';
14
+ import { verifyToken, startPairCodeRotation, stopPairCodeRotation, handlePairRequest } from './auth.js';
13
15
  import { checkPath, checkCommand } from './security.js';
14
16
  import { executeCommand, executeCommandSync, addWsClient, disconnectWsClient, killProcess, getRunningProcesses } from './executor.js';
15
- import { setConsoleOutput, logAuth, logFs, logExec, logSecurity, logSystem, logError, queryLogs, getLogDates } from './logger.js';
17
+ import { setConsoleOutput, logAuth, logFs, logExec, logSecurity, logSystem, logError } from './logger.js';
16
18
  import { initSearchTools, getSearchToolsAvailable, searchFiles, searchContent } from './search.js';
17
19
  import {
18
20
  initializeMcpRegistry,
@@ -40,15 +42,54 @@ import {
40
42
  getAgentSkillPrompt,
41
43
  getSkillsDir
42
44
  } from './skill/registry.js';
43
- import { getSkillExecutionStatus } from './skill/executor.js';
44
45
  import { saveMarkdownSkill, importMarkdownSkillFromZip, importMarkdownSkillFromUrl } from './skill/loader.js';
45
46
 
46
47
  const __dirname = dirname(fileURLToPath(import.meta.url));
47
48
  const AGENT_VERSION = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8')).version;
48
49
 
50
+ /**
51
+ * 跨平台 ZIP 打包(使用 archiver,替换 spawn zip)
52
+ * @param {string[]} sourceNames - 源文件/目录名(basename)
53
+ * @param {string} cwd - 工作目录(压缩时以此为当前目录)
54
+ * @param {string} outputPath - 输出 ZIP 文件路径
55
+ * @returns {Promise<Buffer>} ZIP 文件 Buffer
56
+ */
57
+ async function createZipBuffer(sourceNames, cwd, outputPath) {
58
+ return new Promise((resolve, reject) => {
59
+ const output = createWriteStream(outputPath);
60
+ const archive = new ZipArchive({ zlib: { level: 9 } });
61
+ const chunks = [];
62
+
63
+ output.on('close', () => {
64
+ resolve(Buffer.concat(chunks));
65
+ });
66
+ output.on('error', reject);
67
+
68
+ archive.on('error', reject);
69
+ archive.on('data', (chunk) => chunks.push(chunk));
70
+
71
+ archive.pipe(output);
72
+
73
+ for (const name of sourceNames) {
74
+ const fullPath = join(cwd, name);
75
+ try {
76
+ const s = statSync(fullPath);
77
+ if (s.isDirectory()) {
78
+ archive.directory(fullPath, name);
79
+ } else {
80
+ archive.file(fullPath, { name });
81
+ }
82
+ } catch {
83
+ // 跳过不存在的文件
84
+ }
85
+ }
86
+
87
+ archive.finalize();
88
+ });
89
+ }
90
+
49
91
  const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10MB
50
92
  const MAX_SEARCH_RESULTS = 5000; // 单次搜索最大结果数
51
- const MAX_LOG_QUERY_LIMIT = 1000; // 日志查询最大条数
52
93
  const PID_FILE = join(homedir(), '.ai-helper-agent', 'agent.pid');
53
94
  const DEFAULT_MAX_SIZE = 50 * 1024 * 1024; // 文件默认大小限制
54
95
 
@@ -71,7 +112,7 @@ function detectShell() {
71
112
  ];
72
113
  for (const path of gitBashPaths) {
73
114
  try {
74
- if (require('fs').existsSync(path)) {
115
+ if (existsSync(path)) {
75
116
  return path;
76
117
  }
77
118
  } catch {}
@@ -107,6 +148,35 @@ async function exists(path) {
107
148
  catch { return false; }
108
149
  }
109
150
 
151
+ /**
152
+ * 根据文件扩展名返回 MIME 类型
153
+ */
154
+ function getMimeType(filePath) {
155
+ const ext = (filePath.split('.').pop() || '').toLowerCase();
156
+ const mimeMap = {
157
+ txt: 'text/plain', md: 'text/markdown', json: 'application/json',
158
+ js: 'application/javascript', mjs: 'application/javascript', ts: 'application/typescript',
159
+ jsx: 'text/jsx', tsx: 'text/tsx',
160
+ html: 'text/html', htm: 'text/html', css: 'text/css', scss: 'text/x-scss', less: 'text/x-less',
161
+ xml: 'application/xml', yaml: 'text/yaml', yml: 'text/yaml',
162
+ py: 'text/x-python', java: 'text/x-java', c: 'text/x-c', cpp: 'text/x-c++', h: 'text/x-c',
163
+ go: 'text/x-go', rs: 'text/x-rust', rb: 'text/x-ruby', php: 'text/x-php',
164
+ sql: 'text/x-sql', sh: 'text/x-sh', bash: 'text/x-sh', zsh: 'text/x-sh',
165
+ cfg: 'text/plain', ini: 'text/plain', toml: 'text/plain', conf: 'text/plain',
166
+ log: 'text/plain', csv: 'text/csv', tsv: 'text/tab-separated-values',
167
+ env: 'text/plain', vue: 'text/x-vue', svelte: 'text/x-svelte', astro: 'text/x-astro', rtf: 'application/rtf',
168
+ svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
169
+ gif: 'image/gif', webp: 'image/webp', bmp: 'image/bmp', ico: 'image/x-icon',
170
+ pdf: 'application/pdf',
171
+ doc: 'application/msword', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
172
+ xls: 'application/vnd.ms-excel', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
173
+ ppt: 'application/vnd.ms-powerpoint', pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
174
+ zip: 'application/zip', gz: 'application/gzip', tar: 'application/x-tar',
175
+ mp3: 'audio/mpeg', wav: 'audio/wav', mp4: 'video/mp4', webm: 'video/webm',
176
+ };
177
+ return mimeMap[ext] || 'application/octet-stream';
178
+ }
179
+
110
180
  const PLATFORM_INFO = {
111
181
  platform: os.platform(),
112
182
  platformName: (() => {
@@ -120,16 +190,36 @@ const PLATFORM_INFO = {
120
190
  nodeVersion: process.version
121
191
  };
122
192
 
193
+ /**
194
+ * 判断请求来源是否允许跨域读取响应
195
+ * 安全策略:仅放行 Chrome 扩展来源(chrome-extension://<id>)。
196
+ * - 恶意网页(http/https origin)不返回 ACAO,浏览器阻止其跨域读取(保护无认证端点 /api/status、/api/pair 等)
197
+ * - 无 Origin 的请求(curl / 本地脚本):不设 ACAO,非浏览器客户端不受 CORS 限制仍可访问
198
+ * - 扩展请求:返回其 Origin,扩展 fetch 可正常跨域读取
199
+ */
200
+ function getAllowedOrigin(req) {
201
+ const origin = req?.headers?.origin;
202
+ if (typeof origin === 'string' && origin.startsWith('chrome-extension://')) {
203
+ return origin;
204
+ }
205
+ return null;
206
+ }
207
+
123
208
  /**
124
209
  * JSON 响应辅助
125
210
  */
126
211
  function jsonResponse(res, status, data) {
127
- res.writeHead(status, {
212
+ const headers = {
128
213
  'Content-Type': 'application/json; charset=utf-8',
129
- 'Access-Control-Allow-Origin': '*',
130
214
  'Access-Control-Allow-Headers': 'Content-Type, Authorization',
131
215
  'Access-Control-Allow-Methods': 'GET, POST, OPTIONS'
132
- });
216
+ };
217
+ const allowedOrigin = getAllowedOrigin(res.req);
218
+ if (allowedOrigin) {
219
+ headers['Access-Control-Allow-Origin'] = allowedOrigin;
220
+ headers['Vary'] = 'Origin';
221
+ }
222
+ res.writeHead(status, headers);
133
223
  res.end(JSON.stringify(data));
134
224
  }
135
225
 
@@ -159,8 +249,10 @@ function parseBody(req) {
159
249
 
160
250
  req.on('end', () => {
161
251
  const body = Buffer.concat(chunks).toString();
252
+ // 空 body 视为 {}(兼容无 body 的 POST 请求,如 /api/skill/reload)
253
+ if (body.trim() === '') { resolve({}); return; }
162
254
  try { resolve(JSON.parse(body)); }
163
- catch { resolve({}); }
255
+ catch { reject(new Error('请求体不是有效的 JSON')); }
164
256
  });
165
257
 
166
258
  req.on('error', () => reject(new Error('读取请求失败')));
@@ -274,13 +366,18 @@ export function startServer() {
274
366
  });
275
367
 
276
368
  async function handleRequest(req, res) {
277
- // CORS 预检
369
+ // CORS 预检(仅放行 Chrome 扩展来源)
278
370
  if (req.method === 'OPTIONS') {
279
- res.writeHead(204, {
280
- 'Access-Control-Allow-Origin': '*',
371
+ const optHeaders = {
281
372
  'Access-Control-Allow-Headers': 'Content-Type, Authorization',
282
373
  'Access-Control-Allow-Methods': 'GET, POST, OPTIONS'
283
- });
374
+ };
375
+ const allowedOrigin = getAllowedOrigin(req);
376
+ if (allowedOrigin) {
377
+ optHeaders['Access-Control-Allow-Origin'] = allowedOrigin;
378
+ optHeaders['Vary'] = 'Origin';
379
+ }
380
+ res.writeHead(204, optHeaders);
284
381
  return res.end();
285
382
  }
286
383
 
@@ -308,25 +405,19 @@ export function startServer() {
308
405
  return jsonResponse(res, result.success ? 200 : 400, result);
309
406
  }
310
407
 
311
- // 健康检查 + 平台信息
408
+ // 健康检查 + 平台信息(无认证:仅返回必要的平台标识,不泄露 hostname/homeDir/shell 等宿主敏感信息)
312
409
  if (req.method === 'GET' && pathname === '/api/status') {
313
410
  return jsonResponse(res, 200, {
314
411
  success: true,
315
412
  version: AGENT_VERSION,
316
413
  running: true,
317
- ...PLATFORM_INFO,
414
+ platform: PLATFORM_INFO.platform,
415
+ arch: PLATFORM_INFO.arch,
416
+ nodeVersion: PLATFORM_INFO.nodeVersion,
318
417
  searchTools: getSearchToolsAvailable()
319
418
  });
320
419
  }
321
420
 
322
- // Agent 关闭(无需认证,仅限本地访问)
323
- if (req.method === 'POST' && pathname === '/api/shutdown') {
324
- logSystem('shutdown', { reason: 'api_request' });
325
- jsonResponse(res, 200, { success: true, message: 'Agent 正在关闭...' });
326
- shutdown();
327
- return;
328
- }
329
-
330
421
  // ---------- 需要认证的接口 ----------
331
422
  const authHeader = req.headers.authorization;
332
423
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
@@ -340,14 +431,27 @@ export function startServer() {
340
431
  return jsonResponse(res, 403, { success: false, error: '认证 token 无效' });
341
432
  }
342
433
 
434
+ // Agent 关闭(需认证 + 仅限本地来源,防止远程无认证关闭 Agent 造成 DoS)
435
+ 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 仅允许本地请求' });
441
+ }
442
+ logSystem('shutdown', { reason: 'api_request', extId });
443
+ jsonResponse(res, 200, { success: true, message: 'Agent 正在关闭...' });
444
+ shutdown();
445
+ return;
446
+ }
447
+
343
448
  const maxSize = config.fileMaxSize || DEFAULT_MAX_SIZE;
344
449
 
345
- // 认证后的状态信息
450
+ // 认证后的状态信息(不再下发 pairCode:配对码仅用于一次性配对,避免已配对端横向获取)
346
451
  if (req.method === 'GET' && pathname === '/api/status/detail') {
347
452
  return jsonResponse(res, 200, {
348
453
  success: true,
349
454
  version: AGENT_VERSION,
350
- pairCode: getCurrentPairCode(),
351
455
  pairCodeTTL: config.pairCodeTTL,
352
456
  workdir: config.workdir,
353
457
  allowedPaths: config.allowedPaths,
@@ -539,6 +643,318 @@ export function startServer() {
539
643
  return jsonResponse(res, 200, { success: true, path: check.resolved });
540
644
  }
541
645
 
646
+ // 下载文件/目录(返回 base64 内容,目录自动打包为 zip)
647
+ if (pathname === '/api/fs/download') {
648
+ const targetPath = body.path;
649
+ if (!targetPath || typeof targetPath !== 'string') {
650
+ return jsonResponse(res, 400, { success: false, error: '缺少 path 参数' });
651
+ }
652
+ const check = await checkPath(targetPath);
653
+ if (!check.allowed) {
654
+ logSecurity('fs_download_blocked', { path: targetPath, reason: check.reason });
655
+ return jsonResponse(res, 403, { success: false, error: check.reason });
656
+ }
657
+ if (!await exists(check.resolved)) {
658
+ return jsonResponse(res, 404, { success: false, error: '文件/目录不存在' });
659
+ }
660
+
661
+ try {
662
+ const fstat = await stat(check.resolved);
663
+ if (fstat.isDirectory()) {
664
+ // 目录:打包为 zip
665
+ const tmpFile = join(tmpdir(), `ws-dl-${randomBytes(6).toString('hex')}.zip`);
666
+ try {
667
+ const dirName = basename(check.resolved);
668
+ const parentDir = dirname(check.resolved);
669
+ const zipBuffer = await createZipBuffer([dirName], parentDir, tmpFile);
670
+ logFs('download', { path: check.resolved, type: 'directory', zipSize: zipBuffer.length });
671
+ return jsonResponse(res, 200, {
672
+ success: true,
673
+ type: 'directory',
674
+ name: `${dirName}.zip`,
675
+ size: zipBuffer.length,
676
+ content: zipBuffer.toString('base64'),
677
+ mimeType: 'application/zip'
678
+ });
679
+ } finally {
680
+ try { await unlink(tmpFile); } catch {}
681
+ }
682
+ } else {
683
+ // 单个文件
684
+ if (fstat.size > maxSize) {
685
+ return jsonResponse(res, 400, { success: false, error: `文件过大 (${fstat.size} > ${maxSize})` });
686
+ }
687
+ const buf = await readFile(check.resolved);
688
+ const mimeType = getMimeType(check.resolved);
689
+ logFs('download', { path: check.resolved, type: 'file', size: buf.length });
690
+ return jsonResponse(res, 200, {
691
+ success: true,
692
+ type: 'file',
693
+ name: basename(check.resolved),
694
+ size: buf.length,
695
+ content: buf.toString('base64'),
696
+ mimeType
697
+ });
698
+ }
699
+ } catch (err) {
700
+ logError('fs', 'download_error', { path: targetPath, error: err.message });
701
+ return jsonResponse(res, 500, { success: false, error: `下载失败: ${err.message}` });
702
+ }
703
+ }
704
+
705
+ // 批量下载多个文件/目录(在后端打包为 zip)
706
+ if (pathname === '/api/fs/download-multi') {
707
+ const paths = body.paths;
708
+ if (!Array.isArray(paths) || paths.length === 0) {
709
+ return jsonResponse(res, 400, { success: false, error: '缺少 paths 参数或为空数组' });
710
+ }
711
+
712
+ const resolvedPaths = [];
713
+ for (const p of paths) {
714
+ const check = await checkPath(p);
715
+ if (!check.allowed) {
716
+ logSecurity('fs_download_blocked', { path: p, reason: check.reason });
717
+ return jsonResponse(res, 403, { success: false, error: `路径 "${p}" 不允许访问` });
718
+ }
719
+ if (!await exists(check.resolved)) {
720
+ return jsonResponse(res, 404, { success: false, error: `路径 "${p}" 不存在` });
721
+ }
722
+ resolvedPaths.push(check.resolved);
723
+ }
724
+
725
+ try {
726
+ const tmpFile = join(tmpdir(), `ws-dl-${randomBytes(6).toString('hex')}.zip`);
727
+ try {
728
+ const zipArgs = resolvedPaths.map(p => basename(p));
729
+ const commonParent = resolvedPaths.reduce((a, b) => {
730
+ const sep = '/';
731
+ const partsA = a.replace(/\\/g, '/').split('/').filter(Boolean);
732
+ const partsB = b.replace(/\\/g, '/').split('/').filter(Boolean);
733
+ const common = [];
734
+ for (let i = 0; i < Math.min(partsA.length, partsB.length); i++) {
735
+ if (partsA[i] === partsB[i]) common.push(partsA[i]);
736
+ else break;
737
+ }
738
+ const result = sep + common.join(sep);
739
+ // Windows 盘符处理:C:/Users -> 而不是 /C:/Users
740
+ return result;
741
+ });
742
+
743
+ const zipBuffer = await createZipBuffer(zipArgs, commonParent, tmpFile);
744
+ logFs('download_multi', { paths: resolvedPaths, zipSize: zipBuffer.length });
745
+ return jsonResponse(res, 200, {
746
+ success: true,
747
+ type: 'archive',
748
+ name: `workspace_${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.zip`,
749
+ size: zipBuffer.length,
750
+ content: zipBuffer.toString('base64'),
751
+ mimeType: 'application/zip'
752
+ });
753
+ } finally {
754
+ try { await unlink(tmpFile); } catch {}
755
+ }
756
+ } catch (err) {
757
+ logError('fs', 'download_multi_error', { paths, error: err.message });
758
+ return jsonResponse(res, 500, { success: false, error: `打包失败: ${err.message}` });
759
+ }
760
+ }
761
+
762
+ // 流式下载(直接返回二进制流,避免 base64 膨胀和前端同步解码)
763
+ // 支持单文件/目录(path)或多文件打包(paths)
764
+ if (pathname === '/api/fs/download-stream') {
765
+ const targetPath = body.path;
766
+ const paths = body.paths;
767
+
768
+ // 辅助:设置下载响应头并返回二进制
769
+ const sendBinary = (buffer, filename, mimeType) => {
770
+ const encodedName = encodeURIComponent(filename);
771
+ res.writeHead(200, {
772
+ 'Content-Type': mimeType || 'application/octet-stream',
773
+ 'Content-Disposition': `attachment; filename="${encodedName}"; filename*=UTF-8''${encodedName}`,
774
+ 'Content-Length': buffer.length
775
+ });
776
+ res.end(buffer);
777
+ };
778
+
779
+ try {
780
+ if (paths && Array.isArray(paths) && paths.length > 0) {
781
+ // 多文件打包流式下载
782
+ const resolvedPaths = [];
783
+ for (const p of paths) {
784
+ const check = await checkPath(p);
785
+ if (!check.allowed) {
786
+ logSecurity('fs_download_blocked', { path: p, reason: check.reason });
787
+ return jsonResponse(res, 403, { success: false, error: `路径 "${p}" 不允许访问` });
788
+ }
789
+ if (!await exists(check.resolved)) {
790
+ return jsonResponse(res, 404, { success: false, error: `路径 "${p}" 不存在` });
791
+ }
792
+ resolvedPaths.push(check.resolved);
793
+ }
794
+
795
+ const tmpFile = join(tmpdir(), `ws-dl-${randomBytes(6).toString('hex')}.zip`);
796
+ try {
797
+ const zipArgs = resolvedPaths.map(p => basename(p));
798
+ const commonParent = resolvedPaths.reduce((a, b) => {
799
+ const partsA = a.split('/').filter(Boolean);
800
+ const partsB = b.split('/').filter(Boolean);
801
+ const common = [];
802
+ for (let i = 0; i < Math.min(partsA.length, partsB.length); i++) {
803
+ if (partsA[i] === partsB[i]) common.push(partsA[i]);
804
+ else break;
805
+ }
806
+ return '/' + common.join('/');
807
+ });
808
+ const zipBuffer = await createZipBuffer(zipArgs, commonParent, tmpFile);
809
+ logFs('download_multi_stream', { paths: resolvedPaths, zipSize: zipBuffer.length });
810
+ const zipName = `workspace_${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.zip`;
811
+ return sendBinary(zipBuffer, zipName, 'application/zip');
812
+ } finally {
813
+ try { await unlink(tmpFile); } catch {}
814
+ }
815
+ } else if (targetPath && typeof targetPath === 'string') {
816
+ // 单文件/目录流式下载
817
+ const check = await checkPath(targetPath);
818
+ if (!check.allowed) {
819
+ logSecurity('fs_download_blocked', { path: targetPath, reason: check.reason });
820
+ return jsonResponse(res, 403, { success: false, error: check.reason });
821
+ }
822
+ if (!await exists(check.resolved)) {
823
+ return jsonResponse(res, 404, { success: false, error: '文件/目录不存在' });
824
+ }
825
+
826
+ const fstat = await stat(check.resolved);
827
+ if (fstat.isDirectory()) {
828
+ // 目录打包
829
+ const tmpFile = join(tmpdir(), `ws-dl-${randomBytes(6).toString('hex')}.zip`);
830
+ try {
831
+ const dirName = basename(check.resolved);
832
+ const parentDir = dirname(check.resolved);
833
+ const zipBuffer = await createZipBuffer([dirName], parentDir, tmpFile);
834
+ logFs('download_stream', { path: check.resolved, type: 'directory', zipSize: zipBuffer.length });
835
+ return sendBinary(zipBuffer, `${dirName}.zip`, 'application/zip');
836
+ } finally {
837
+ try { await unlink(tmpFile); } catch {}
838
+ }
839
+ } else {
840
+ // 单文件直接返回(不受 maxSize 限制,流式传输)
841
+ const buf = await readFile(check.resolved);
842
+ 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);
845
+ }
846
+ } else {
847
+ return jsonResponse(res, 400, { success: false, error: '缺少 path 或 paths 参数' });
848
+ }
849
+ } catch (err) {
850
+ logError('fs', 'download_stream_error', { path: targetPath, paths, error: err.message });
851
+ return jsonResponse(res, 500, { success: false, error: `下载失败: ${err.message}` });
852
+ }
853
+ }
854
+
855
+ // 创建目录
856
+ if (pathname === '/api/fs/mkdir') {
857
+ const dirPath = body.path;
858
+ if (!dirPath || typeof dirPath !== 'string') {
859
+ return jsonResponse(res, 400, { success: false, error: '缺少 path 参数' });
860
+ }
861
+ const check = await checkPath(dirPath);
862
+ if (!check.allowed) {
863
+ logSecurity('fs_mkdir_blocked', { path: dirPath, reason: check.reason });
864
+ return jsonResponse(res, 403, { success: false, error: check.reason });
865
+ }
866
+ try {
867
+ await mkdir(check.resolved, { recursive: false });
868
+ logFs('mkdir', { path: check.resolved });
869
+ return jsonResponse(res, 200, { success: true, path: check.resolved });
870
+ } catch (err) {
871
+ if (err.code === 'EEXIST') {
872
+ return jsonResponse(res, 409, { success: false, error: '目录已存在' });
873
+ }
874
+ throw err;
875
+ }
876
+ }
877
+
878
+ // 重命名文件/目录(仅修改文件名,不跨目录)
879
+ if (pathname === '/api/fs/rename') {
880
+ const oldPath = body.path;
881
+ const newName = body.newName;
882
+ if (!oldPath || typeof oldPath !== 'string' || !newName || typeof newName !== 'string') {
883
+ return jsonResponse(res, 400, { success: false, error: '缺少 path 或 newName 参数' });
884
+ }
885
+ if (newName.includes('/') || newName.includes('\\')) {
886
+ return jsonResponse(res, 400, { success: false, error: '新名称不能包含路径分隔符' });
887
+ }
888
+ const check = await checkPath(oldPath);
889
+ if (!check.allowed) {
890
+ logSecurity('fs_rename_blocked', { path: oldPath, reason: check.reason });
891
+ return jsonResponse(res, 403, { success: false, error: check.reason });
892
+ }
893
+ if (!await exists(check.resolved)) {
894
+ return jsonResponse(res, 404, { success: false, error: '文件/目录不存在' });
895
+ }
896
+ const newFullPath = join(dirname(check.resolved), newName);
897
+ // 确保目标路径也在允许范围内
898
+ const newCheck = await checkPath(newFullPath);
899
+ if (!newCheck.allowed) {
900
+ logSecurity('fs_rename_blocked', { path: newFullPath, reason: newCheck.reason });
901
+ return jsonResponse(res, 403, { success: false, error: `目标路径不允许: ${newCheck.reason}` });
902
+ }
903
+ try {
904
+ await rename(check.resolved, newFullPath);
905
+ logFs('rename', { from: check.resolved, to: newFullPath });
906
+ return jsonResponse(res, 200, { success: true, newPath: newFullPath, newName });
907
+ } catch (err) {
908
+ if (err.code === 'ENOTEMPTY' || err.code === 'EEXIST') {
909
+ return jsonResponse(res, 409, { success: false, error: '目标名称已存在' });
910
+ }
911
+ throw err;
912
+ }
913
+ }
914
+
915
+ // 移动文件/目录
916
+ if (pathname === '/api/fs/move') {
917
+ const srcPath = body.path;
918
+ const destDir = body.destDir;
919
+ if (!srcPath || typeof srcPath !== 'string' || !destDir || typeof destDir !== 'string') {
920
+ return jsonResponse(res, 400, { success: false, error: '缺少 path 或 destDir 参数' });
921
+ }
922
+ const srcCheck = await checkPath(srcPath);
923
+ if (!srcCheck.allowed) {
924
+ logSecurity('fs_move_blocked', { path: srcPath, reason: srcCheck.reason });
925
+ return jsonResponse(res, 403, { success: false, error: srcCheck.reason });
926
+ }
927
+ const destCheck = await checkPath(destDir);
928
+ if (!destCheck.allowed) {
929
+ logSecurity('fs_move_blocked', { path: destDir, reason: destCheck.reason });
930
+ return jsonResponse(res, 403, { success: false, error: destCheck.reason });
931
+ }
932
+ if (!await exists(srcCheck.resolved)) {
933
+ return jsonResponse(res, 404, { success: false, error: '源文件/目录不存在' });
934
+ }
935
+ const destStat = await stat(destCheck.resolved).catch(() => null);
936
+ if (!destStat || !destStat.isDirectory()) {
937
+ return jsonResponse(res, 400, { success: false, error: '目标不是有效目录' });
938
+ }
939
+ const itemName = basename(srcCheck.resolved);
940
+ const destPath = join(destCheck.resolved, itemName);
941
+ const destCheck2 = await checkPath(destPath);
942
+ if (!destCheck2.allowed) {
943
+ logSecurity('fs_move_blocked', { path: destPath, reason: destCheck2.reason });
944
+ return jsonResponse(res, 403, { success: false, error: `移动目标路径不允许: ${destCheck2.reason}` });
945
+ }
946
+ try {
947
+ await rename(srcCheck.resolved, destPath);
948
+ logFs('move', { from: srcCheck.resolved, to: destPath });
949
+ return jsonResponse(res, 200, { success: true, newPath: destPath });
950
+ } catch (err) {
951
+ if (err.code === 'ENOTEMPTY' || err.code === 'EEXIST') {
952
+ return jsonResponse(res, 409, { success: false, error: '目标位置已存在同名文件/目录' });
953
+ }
954
+ throw err;
955
+ }
956
+ }
957
+
542
958
  // 在本地浏览器中打开文件
543
959
  if (pathname === '/api/browser/open') {
544
960
  const targetPath = body.path;
@@ -843,27 +1259,6 @@ export function startServer() {
843
1259
  }
844
1260
  }
845
1261
 
846
- // 运行中进程列表(需认证)
847
- if (req.method === 'GET' && pathname === '/api/exec/running') {
848
- return jsonResponse(res, 200, { success: true, processes: getRunningProcesses() });
849
- }
850
-
851
- // 日志查询(需认证)
852
- if (req.method === 'GET' && pathname === '/api/logs') {
853
- const date = url.searchParams.get('date') || undefined;
854
- const category = url.searchParams.get('category') || undefined;
855
- const rawLimit = parseInt(url.searchParams.get('limit') || '200', 10);
856
- const limit = Math.min(isNaN(rawLimit) ? 200 : rawLimit, MAX_LOG_QUERY_LIMIT);
857
- const offset = parseInt(url.searchParams.get('offset') || '0', 10);
858
- const result = queryLogs({ date, category, limit, offset });
859
- return jsonResponse(res, 200, { success: true, ...result });
860
- }
861
-
862
- // 日志日期列表(需认证)
863
- if (req.method === 'GET' && pathname === '/api/logs/dates') {
864
- return jsonResponse(res, 200, { success: true, dates: getLogDates() });
865
- }
866
-
867
1262
  // ========== Skill 管理接口(仅 GET 路由) ==========
868
1263
 
869
1264
  // Skill 列表
@@ -878,13 +1273,6 @@ export function startServer() {
878
1273
  return jsonResponse(res, 200, getSkill(name));
879
1274
  }
880
1275
 
881
- // Skill 执行状态查询
882
- if (req.method === 'GET' && pathname === '/api/skill/status') {
883
- const execId = url.searchParams.get('execId');
884
- if (!execId) return jsonResponse(res, 400, { success: false, error: '缺少 execId 参数' });
885
- return jsonResponse(res, 200, getSkillExecutionStatus(execId));
886
- }
887
-
888
1276
  // Agent Skill Prompts(用于 AI System Prompt 注入)
889
1277
  if (req.method === 'GET' && pathname === '/api/skill/agent-prompts') {
890
1278
  const prompts = getAgentSkillPrompts();
@@ -990,6 +1378,9 @@ export function startServer() {
990
1378
  if (pathParts[1] === 'ws' && pathParts[2] === 'exec') {
991
1379
  const execId = pathParts[3];
992
1380
 
1381
+ // WebSocket 认证:优先用 Authorization Header;回退到 ?token= query 是因为
1382
+ // 浏览器 WebSocket API 不支持自定义 Header(插件端 new WebSocket() 无法设置 Header)。
1383
+ // 安全注意:query token 可能被代理日志/Referer 记录,故此处绝不将 token 写入任何日志。
993
1384
  const authHeader = request.headers.authorization;
994
1385
  let token = null;
995
1386
  if (authHeader && authHeader.startsWith('Bearer ')) {
@@ -1032,25 +1423,25 @@ export function startServer() {
1032
1423
  }
1033
1424
  });
1034
1425
 
1035
- server.listen(port, host, async () => {
1426
+ server.listen(port, host, () => {
1036
1427
  console.log(`[Agent] HTTP 服务已启动: http://${host}:${port}`);
1037
1428
  console.log(`[Agent] WebSocket 服务已启动: ws://${host}:${port}`);
1038
1429
  startPairCodeRotation();
1039
1430
 
1040
- // 初始化 MCP 注册表(异步,不阻塞请求)
1041
- try {
1042
- await initializeMcpRegistry();
1043
- } catch (err) {
1044
- console.error('[Agent] MCP 注册表初始化失败:', err.message);
1045
- }
1431
+ // 后台异步初始化 MCP 和 Skill,不阻塞主流程
1432
+ (async () => {
1433
+ try {
1434
+ await initializeMcpRegistry();
1435
+ } catch (err) {
1436
+ console.error('[Agent] MCP 注册表初始化失败:', err.message);
1437
+ }
1046
1438
 
1047
- // 初始化 Skill 注册表
1048
- try {
1049
- const skillCount = initializeSkillRegistry();
1050
- console.log(`[Agent] Skill 注册表已初始化: ${skillCount} 个 Skill`);
1051
- } catch (err) {
1052
- console.error('[Agent] Skill 注册表初始化失败:', err.message);
1053
- }
1439
+ try {
1440
+ await initializeSkillRegistry();
1441
+ } catch (err) {
1442
+ console.error('[Agent] Skill 注册表初始化失败:', err.message);
1443
+ }
1444
+ })();
1054
1445
  });
1055
1446
 
1056
1447
  // 优雅关闭(异步 + 防并发 + 超时兜底)
@@ -182,7 +182,6 @@ export function loadAllSkills() {
182
182
  const agentSkills = loadAllMarkdownSkills(SKILLS_DIR);
183
183
  allSkills = allSkills.concat(agentSkills);
184
184
 
185
- console.log(`[Skill Loader] 共加载 ${allSkills.length} 个 Skill (${allSkills.filter(s => s.type === 'workflow').length} Workflow + ${allSkills.filter(s => s.type === 'agent').length} Agent)`);
186
185
  return allSkills;
187
186
  }
188
187
 
@@ -264,18 +263,12 @@ export function seedSkillCreator() {
264
263
  const skillDir = join(SKILLS_DIR, SKILL_CREATOR_DIR);
265
264
  const skillMdPath = join(skillDir, 'SKILL.md');
266
265
 
267
- console.log(`[Skill Loader] seedSkillCreator: 检查路径 ${skillMdPath}`);
268
- console.log(`[Skill Loader] seedSkillCreator: 文件存在? ${existsSync(skillMdPath)}`);
269
-
270
266
  if (existsSync(skillMdPath)) {
271
- // 已存在,检查是否需要升级(版本号不同则覆盖)
272
267
  try {
273
268
  const content = readFileSync(skillMdPath, 'utf-8');
274
269
  const versionMatch = content.match(/^version:\s*([\d.]+)/m);
275
270
  const currentVersion = versionMatch ? versionMatch[1] : '0.0.0';
276
- console.log(`[Skill Loader] seedSkillCreator: 已有文件版本=${currentVersion}, 目标版本=1.1.0`);
277
271
  if (currentVersion === '1.1.0') {
278
- console.log('[Skill Loader] seedSkillCreator: 版本匹配,跳过写入');
279
272
  return false;
280
273
  }
281
274
  } catch { /* 读取失败则覆盖 */ }
@@ -286,10 +279,9 @@ export function seedSkillCreator() {
286
279
  mkdirSync(skillDir, { recursive: true });
287
280
  }
288
281
  writeFileSync(skillMdPath, SKILL_CREATOR_SEED_MD, 'utf-8');
289
- console.log('[Skill Loader] 已种子写入 skill-creator SKILL.md (v1.1.0)');
290
282
  return true;
291
283
  } catch (err) {
292
- console.warn('[Skill Loader] 种子写入 skill-creator 失败:', err.message);
284
+ console.warn('[Skill] 种子写入 skill-creator 失败:', err.message);
293
285
  return false;
294
286
  }
295
287
  }
@@ -1,12 +1,17 @@
1
1
  // skill/markdown-loader.js - Markdown Skill 加载器
2
2
  // 扫描子目录中的 SKILL.md,解析 YAML frontmatter 和正文内容
3
3
  import { readFileSync, readdirSync, existsSync, statSync, writeFileSync, mkdirSync, rmSync } from 'fs';
4
- import { join, basename, extname } from 'path';
5
- import { execSync } from 'child_process';
4
+ import { join, basename, extname, normalize, sep } from 'path';
5
+ import { execFile } from 'child_process';
6
+ import { promisify } from 'util';
7
+ import { randomBytes } from 'crypto';
6
8
  import { tmpdir } from 'os';
9
+ import { lookup as dnsLookup } from 'dns/promises';
7
10
  import https from 'https';
8
11
  import http from 'http';
9
12
 
13
+ const execFileAsync = promisify(execFile);
14
+
10
15
  /**
11
16
  * 解析 YAML frontmatter(--- 包裹的元数据)
12
17
  * 支持基本类型:string, number, boolean, array, object
@@ -201,7 +206,6 @@ export function loadAllMarkdownSkills(skillsDir) {
201
206
  } catch { /* skip */ }
202
207
  }
203
208
 
204
- console.log(`[Markdown Loader] 共加载 ${skills.length} 个 Agent Skill`);
205
209
  return skills;
206
210
  }
207
211
 
@@ -267,6 +271,78 @@ export function deleteMarkdownSkillDir(skillsDir, name) {
267
271
  }
268
272
  }
269
273
 
274
+ // Skill 下载大小上限(20MB)
275
+ const MAX_SKILL_DOWNLOAD_SIZE = 20 * 1024 * 1024;
276
+
277
+ /**
278
+ * 判断 IP 字符串是否为私有/环回/链路本地等内网地址
279
+ */
280
+ function isPrivateIp(ip) {
281
+ // IPv4
282
+ const v4 = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
283
+ if (v4) {
284
+ const a = parseInt(v4[1], 10);
285
+ const b = parseInt(v4[2], 10);
286
+ if (a === 10) return true; // 10.0.0.0/8
287
+ if (a === 127) return true; // 127.0.0.0/8 环回(全段)
288
+ if (a === 0) return true; // 0.0.0.0/8
289
+ if (a === 169 && b === 254) return true; // 169.254.0.0/16 链路本地(含云元数据)
290
+ if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
291
+ if (a === 192 && b === 168) return true; // 192.168.0.0/16
292
+ if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT
293
+ return false;
294
+ }
295
+ // IPv6(含 ::ffff:IPv4 映射)
296
+ const v6 = ip.toLowerCase().replace(/^\[|]$/g, '');
297
+ const mapped = v6.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
298
+ if (mapped) return isPrivateIp(mapped[1]);
299
+ if (v6 === '::1' || v6 === '::') return true; // 环回/未指定
300
+ if (v6.startsWith('fc') || v6.startsWith('fd')) return true; // fc00::/7 唯一本地
301
+ if (/^fe[89ab]/.test(v6)) return true; // fe80::/10 链路本地
302
+ return false;
303
+ }
304
+
305
+ /**
306
+ * 校验 URL 主机是否为公网地址(含 DNS 解析,防 DNS 重绑定与 IP 编码绕过)
307
+ * @returns {Promise<{ ok: boolean, error?: string }>}
308
+ */
309
+ async function assertPublicHost(hostname) {
310
+ if (!hostname) return { ok: false, error: '缺少 hostname' };
311
+ const blockedHostnames = ['localhost', 'ip6-localhost', 'ip6-loopback'];
312
+ if (blockedHostnames.includes(hostname.toLowerCase())) {
313
+ return { ok: false, error: '禁止访问本地地址' };
314
+ }
315
+ // 直接 IP 形式(含 IPv6):直接判断
316
+ const stripped = hostname.replace(/^\[|]$/g, '');
317
+ if (/^(\d{1,3}\.){3}\d{1,3}$/.test(stripped) || /^[0-9a-f:]+$/i.test(stripped)) {
318
+ if (isPrivateIp(stripped)) return { ok: false, error: '禁止访问内网地址' };
319
+ return { ok: true };
320
+ }
321
+ // 域名:DNS 解析后判断所有结果(防 DNS 重绑定:解析阶段即拒绝内网 IP)
322
+ try {
323
+ const results = await dnsLookup(hostname, { all: true });
324
+ if (results.length === 0) return { ok: false, error: `域名解析为空: ${hostname}` };
325
+ for (const r of results) {
326
+ if (isPrivateIp(r.address)) {
327
+ return { ok: false, error: `域名 ${hostname} 解析到内网地址 ${r.address}` };
328
+ }
329
+ }
330
+ return { ok: true };
331
+ } catch {
332
+ return { ok: false, error: `域名解析失败: ${hostname}` };
333
+ }
334
+ }
335
+
336
+ /**
337
+ * 校验 Skill 名称:仅允许字母、数字、下划线、短横线,防止命令注入与路径穿越
338
+ */
339
+ function sanitizeSkillName(name) {
340
+ if (typeof name !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(name)) {
341
+ return null;
342
+ }
343
+ return name;
344
+ }
345
+
270
346
  /**
271
347
  * 从 Zip 文件导入 Skill
272
348
  * 要求 zip 包内有一个顶层目录,目录中包含 SKILL.md
@@ -276,15 +352,15 @@ export function deleteMarkdownSkillDir(skillsDir, name) {
276
352
  * @returns {Promise<{ success: boolean, error?: string, skill?: Object }>}
277
353
  */
278
354
  export async function importMarkdownSkillFromZip(skillsDir, zipBuffer, skillName) {
279
- const tmpDir = join(tmpdir(), `ai-helper-skill-import-${Date.now()}`);
280
- const tmpZip = join(tmpdir(), `ai-helper-skill-${Date.now()}.zip`);
355
+ const tmpDir = join(tmpdir(), `ai-helper-skill-import-${Date.now()}-${randomBytes(4).toString('hex')}`);
356
+ const tmpZip = join(tmpdir(), `ai-helper-skill-${Date.now()}-${randomBytes(4).toString('hex')}.zip`);
281
357
 
282
358
  try {
283
359
  mkdirSync(tmpDir, { recursive: true });
284
360
  writeFileSync(tmpZip, zipBuffer);
285
361
 
286
- // 解压到临时目录
287
- execSync(`unzip -o "${tmpZip}" -d "${tmpDir}"`, { encoding: 'utf-8', timeout: 30000 });
362
+ // 解压到临时目录(execFile 数组参数,不经 shell,杜绝命令注入)
363
+ await execFileAsync('unzip', ['-o', tmpZip, '-d', tmpDir], { timeout: 30000 });
288
364
 
289
365
  // 查找 SKILL.md
290
366
  const entries = readdirSync(tmpDir);
@@ -316,15 +392,29 @@ export async function importMarkdownSkillFromZip(skillsDir, zipBuffer, skillName
316
392
 
317
393
  const content = readFileSync(foundSkillMd, 'utf-8');
318
394
  const { frontmatter } = parseFrontmatter(content);
319
- const name = skillName || frontmatter.name || basename(skillDir === tmpDir ? tmpDir : skillDir);
395
+ const rawName = skillName || frontmatter.name || basename(skillDir === tmpDir ? tmpDir : skillDir);
396
+
397
+ // 名称安全校验:防止命令注入与路径穿越(name 来源于 zip 内可控内容)
398
+ const safeName = sanitizeSkillName(rawName);
399
+ if (!safeName) {
400
+ return { success: false, error: `无效的 Skill 名称: "${rawName}"(仅允许字母、数字、下划线、短横线)` };
401
+ }
402
+
403
+ // 目标目录越界校验:确保 destDir 仍在 skillsDir 之下
404
+ const destDir = join(skillsDir, safeName);
405
+ const normalizedDest = normalize(destDir);
406
+ const normalizedSkills = normalize(skillsDir);
407
+ if (normalizedDest !== normalizedSkills &&
408
+ !(normalizedDest.startsWith(normalizedSkills + sep))) {
409
+ return { success: false, error: '目标路径越界,拒绝写入' };
410
+ }
320
411
 
321
- // 复制到 skills 目录
322
- const destDir = join(skillsDir, name);
323
412
  if (existsSync(destDir)) {
324
413
  rmSync(destDir, { recursive: true, force: true });
325
414
  }
326
415
 
327
- execSync(`cp -r "${skillDir}/" "${destDir}"`, { encoding: 'utf-8' });
416
+ // 复制(execFile 数组参数,不经 shell)
417
+ await execFileAsync('cp', ['-r', skillDir + '/.', destDir], { timeout: 30000 });
328
418
 
329
419
  // 重新加载
330
420
  const skill = loadMarkdownSkill(destDir);
@@ -347,31 +437,24 @@ export async function importMarkdownSkillFromZip(skillsDir, zipBuffer, skillName
347
437
  * @returns {Promise<{ success: boolean, error?: string, skill?: Object }>}
348
438
  */
349
439
  export async function importMarkdownSkillFromUrl(skillsDir, url) {
350
- // URL 安全检查
440
+ // URL 基础校验
441
+ let parsed;
351
442
  try {
352
- const parsed = new URL(url);
353
- if (!['http:', 'https:'].includes(parsed.protocol)) {
354
- return { success: false, error: '仅支持 http/https 协议的 URL' };
355
- }
356
- // 禁止访问内网地址
357
- if (parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1' || parsed.hostname === '[::1]') {
358
- return { success: false, error: '禁止访问本地地址' };
359
- }
360
- // 禁止访问私有网络
361
- const octets = parsed.hostname.split('.');
362
- if (octets.length === 4) {
363
- const first = parseInt(octets[0], 10);
364
- const second = parseInt(octets[1], 10);
365
- if (first === 10) return { success: false, error: '禁止访问内网地址' };
366
- if (first === 172 && second >= 16 && second <= 31) return { success: false, error: '禁止访问内网地址' };
367
- if (first === 192 && second === 168) return { success: false, error: '禁止访问内网地址' };
368
- }
443
+ parsed = new URL(url);
369
444
  } catch {
370
445
  return { success: false, error: '无效的 URL' };
371
446
  }
447
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
448
+ return { success: false, error: '仅支持 http/https 协议的 URL' };
449
+ }
450
+ // 主机安全校验(含 DNS 解析,防 DNS 重绑定与 IP 编码绕过)
451
+ const hostCheck = await assertPublicHost(parsed.hostname);
452
+ if (!hostCheck.ok) {
453
+ return { success: false, error: hostCheck.error };
454
+ }
372
455
 
373
456
  try {
374
- const buffer = await downloadFile(url);
457
+ const buffer = await downloadFile(url, 5);
375
458
  return await importMarkdownSkillFromZip(skillsDir, buffer);
376
459
  } catch (err) {
377
460
  return { success: false, error: `下载失败: ${err.message}` };
@@ -380,31 +463,63 @@ export async function importMarkdownSkillFromUrl(skillsDir, url) {
380
463
 
381
464
  /**
382
465
  * 下载文件到 Buffer(支持 http/https,最多重定向 5 次)
466
+ * 安全:每次重定向目标都重新校验主机(防 302 跳转到内网/云元数据端点);限制响应体大小
383
467
  */
384
468
  function downloadFile(url, maxRedirects = 5) {
385
469
  return new Promise((resolve, reject) => {
386
- const protocol = url.startsWith('https') ? https : http;
387
- const chunks = [];
388
-
389
- protocol.get(url, { timeout: 60000 }, (res) => {
390
- // 处理重定向(带次数限制)
391
- if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
392
- if (maxRedirects <= 0) {
393
- reject(new Error('重定向次数过多'));
394
- return;
395
- }
396
- downloadFile(res.headers.location, maxRedirects - 1).then(resolve).catch(reject);
470
+ let parsed;
471
+ try {
472
+ parsed = new URL(url);
473
+ } catch {
474
+ reject(new Error('无效的重定向 URL'));
475
+ return;
476
+ }
477
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
478
+ reject(new Error('仅支持 http/https 协议'));
479
+ return;
480
+ }
481
+ // 重定向目标必须重新校验主机(防止 302 跳转到内网/元数据端点)
482
+ assertPublicHost(parsed.hostname).then((hostCheck) => {
483
+ if (!hostCheck.ok) {
484
+ reject(new Error(`重定向被拦截: ${hostCheck.error}`));
397
485
  return;
398
486
  }
487
+ const protocol = parsed.protocol === 'https:' ? https : http;
488
+ const chunks = [];
489
+ let totalSize = 0;
490
+
491
+ const req = protocol.get(url, { timeout: 60000 }, (res) => {
492
+ // 处理重定向(带次数限制 + 主机重校验)
493
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
494
+ if (maxRedirects <= 0) {
495
+ reject(new Error('重定向次数过多'));
496
+ return;
497
+ }
498
+ downloadFile(res.headers.location, maxRedirects - 1).then(resolve).catch(reject);
499
+ return;
500
+ }
399
501
 
400
- if (res.statusCode !== 200) {
401
- reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
402
- return;
403
- }
502
+ if (res.statusCode !== 200) {
503
+ reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
504
+ return;
505
+ }
404
506
 
405
- res.on('data', (chunk) => chunks.push(chunk));
406
- res.on('end', () => resolve(Buffer.concat(chunks)));
407
- res.on('error', reject);
408
- }).on('error', reject);
507
+ res.on('data', (chunk) => {
508
+ totalSize += chunk.length;
509
+ if (totalSize > MAX_SKILL_DOWNLOAD_SIZE) {
510
+ reject(new Error(`下载内容超过上限 ${MAX_SKILL_DOWNLOAD_SIZE} 字节`));
511
+ req.destroy();
512
+ return;
513
+ }
514
+ chunks.push(chunk);
515
+ });
516
+ res.on('end', () => resolve(Buffer.concat(chunks)));
517
+ res.on('error', reject);
518
+ });
519
+ req.on('error', reject);
520
+ req.on('timeout', () => {
521
+ req.destroy(new Error('下载超时'));
522
+ });
523
+ }).catch(reject);
409
524
  });
410
525
  }
@@ -121,9 +121,10 @@ export function initializeSkillRegistry() {
121
121
  const workflowCount = [...skills.values()].filter(s => s.type === 'workflow').length;
122
122
  const agentCount = [...skills.values()].filter(s => s.type === 'agent').length;
123
123
  const enabledCount = [...skills.values()].filter(s => s.enabled !== false).length;
124
- const builtinCount = [...skills.values()].filter(s => s.builtin).length;
125
124
 
126
- console.log(`[Skill Registry] 初始化完成,共注册 ${skills.size} 个 Skill (${workflowCount} Workflow + ${agentCount} Agent),已启用 ${enabledCount},内置 ${builtinCount}`);
125
+ if (skills.size > 0) {
126
+ console.log(`[Skill] ${skills.size} 个已注册 (${workflowCount} Workflow + ${agentCount} Agent),已启用 ${enabledCount}`);
127
+ }
127
128
  return skills.size;
128
129
  }
129
130