ai-helper-agent 1.10.0 → 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.10.0",
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,19 +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';
4
+ import { readFileSync, createWriteStream, statSync, existsSync } from 'fs';
5
+ import { readFile, writeFile, readdir, stat, unlink, rmdir, chmod, mkdir, rename } from 'fs/promises';
6
6
  import { join, dirname, basename } from 'path';
7
7
  import { fileURLToPath } from 'url';
8
+ import { ZipArchive } from 'archiver';
8
9
  import { homedir, tmpdir } from 'os';
9
- import { spawn, execSync } from 'child_process';
10
+ import { spawn } from 'child_process';
10
11
  import { randomBytes } from 'crypto';
11
12
  import os from 'os';
12
13
  import { loadConfig } from './config.js';
13
- import { verifyToken, getCurrentPairCode, startPairCodeRotation, stopPairCodeRotation, handlePairRequest } from './auth.js';
14
+ import { verifyToken, startPairCodeRotation, stopPairCodeRotation, handlePairRequest } from './auth.js';
14
15
  import { checkPath, checkCommand } from './security.js';
15
16
  import { executeCommand, executeCommandSync, addWsClient, disconnectWsClient, killProcess, getRunningProcesses } from './executor.js';
16
- 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';
17
18
  import { initSearchTools, getSearchToolsAvailable, searchFiles, searchContent } from './search.js';
18
19
  import {
19
20
  initializeMcpRegistry,
@@ -41,15 +42,54 @@ import {
41
42
  getAgentSkillPrompt,
42
43
  getSkillsDir
43
44
  } from './skill/registry.js';
44
- import { getSkillExecutionStatus } from './skill/executor.js';
45
45
  import { saveMarkdownSkill, importMarkdownSkillFromZip, importMarkdownSkillFromUrl } from './skill/loader.js';
46
46
 
47
47
  const __dirname = dirname(fileURLToPath(import.meta.url));
48
48
  const AGENT_VERSION = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8')).version;
49
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
+
50
91
  const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10MB
51
92
  const MAX_SEARCH_RESULTS = 5000; // 单次搜索最大结果数
52
- const MAX_LOG_QUERY_LIMIT = 1000; // 日志查询最大条数
53
93
  const PID_FILE = join(homedir(), '.ai-helper-agent', 'agent.pid');
54
94
  const DEFAULT_MAX_SIZE = 50 * 1024 * 1024; // 文件默认大小限制
55
95
 
@@ -72,7 +112,7 @@ function detectShell() {
72
112
  ];
73
113
  for (const path of gitBashPaths) {
74
114
  try {
75
- if (require('fs').existsSync(path)) {
115
+ if (existsSync(path)) {
76
116
  return path;
77
117
  }
78
118
  } catch {}
@@ -150,16 +190,36 @@ const PLATFORM_INFO = {
150
190
  nodeVersion: process.version
151
191
  };
152
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
+
153
208
  /**
154
209
  * JSON 响应辅助
155
210
  */
156
211
  function jsonResponse(res, status, data) {
157
- res.writeHead(status, {
212
+ const headers = {
158
213
  'Content-Type': 'application/json; charset=utf-8',
159
- 'Access-Control-Allow-Origin': '*',
160
214
  'Access-Control-Allow-Headers': 'Content-Type, Authorization',
161
215
  'Access-Control-Allow-Methods': 'GET, POST, OPTIONS'
162
- });
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);
163
223
  res.end(JSON.stringify(data));
164
224
  }
165
225
 
@@ -189,8 +249,10 @@ function parseBody(req) {
189
249
 
190
250
  req.on('end', () => {
191
251
  const body = Buffer.concat(chunks).toString();
252
+ // 空 body 视为 {}(兼容无 body 的 POST 请求,如 /api/skill/reload)
253
+ if (body.trim() === '') { resolve({}); return; }
192
254
  try { resolve(JSON.parse(body)); }
193
- catch { resolve({}); }
255
+ catch { reject(new Error('请求体不是有效的 JSON')); }
194
256
  });
195
257
 
196
258
  req.on('error', () => reject(new Error('读取请求失败')));
@@ -304,13 +366,18 @@ export function startServer() {
304
366
  });
305
367
 
306
368
  async function handleRequest(req, res) {
307
- // CORS 预检
369
+ // CORS 预检(仅放行 Chrome 扩展来源)
308
370
  if (req.method === 'OPTIONS') {
309
- res.writeHead(204, {
310
- 'Access-Control-Allow-Origin': '*',
371
+ const optHeaders = {
311
372
  'Access-Control-Allow-Headers': 'Content-Type, Authorization',
312
373
  'Access-Control-Allow-Methods': 'GET, POST, OPTIONS'
313
- });
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);
314
381
  return res.end();
315
382
  }
316
383
 
@@ -338,25 +405,19 @@ export function startServer() {
338
405
  return jsonResponse(res, result.success ? 200 : 400, result);
339
406
  }
340
407
 
341
- // 健康检查 + 平台信息
408
+ // 健康检查 + 平台信息(无认证:仅返回必要的平台标识,不泄露 hostname/homeDir/shell 等宿主敏感信息)
342
409
  if (req.method === 'GET' && pathname === '/api/status') {
343
410
  return jsonResponse(res, 200, {
344
411
  success: true,
345
412
  version: AGENT_VERSION,
346
413
  running: true,
347
- ...PLATFORM_INFO,
414
+ platform: PLATFORM_INFO.platform,
415
+ arch: PLATFORM_INFO.arch,
416
+ nodeVersion: PLATFORM_INFO.nodeVersion,
348
417
  searchTools: getSearchToolsAvailable()
349
418
  });
350
419
  }
351
420
 
352
- // Agent 关闭(无需认证,仅限本地访问)
353
- if (req.method === 'POST' && pathname === '/api/shutdown') {
354
- logSystem('shutdown', { reason: 'api_request' });
355
- jsonResponse(res, 200, { success: true, message: 'Agent 正在关闭...' });
356
- shutdown();
357
- return;
358
- }
359
-
360
421
  // ---------- 需要认证的接口 ----------
361
422
  const authHeader = req.headers.authorization;
362
423
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
@@ -370,14 +431,27 @@ export function startServer() {
370
431
  return jsonResponse(res, 403, { success: false, error: '认证 token 无效' });
371
432
  }
372
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
+
373
448
  const maxSize = config.fileMaxSize || DEFAULT_MAX_SIZE;
374
449
 
375
- // 认证后的状态信息
450
+ // 认证后的状态信息(不再下发 pairCode:配对码仅用于一次性配对,避免已配对端横向获取)
376
451
  if (req.method === 'GET' && pathname === '/api/status/detail') {
377
452
  return jsonResponse(res, 200, {
378
453
  success: true,
379
454
  version: AGENT_VERSION,
380
- pairCode: getCurrentPairCode(),
381
455
  pairCodeTTL: config.pairCodeTTL,
382
456
  workdir: config.workdir,
383
457
  allowedPaths: config.allowedPaths,
@@ -592,12 +666,7 @@ export function startServer() {
592
666
  try {
593
667
  const dirName = basename(check.resolved);
594
668
  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);
669
+ const zipBuffer = await createZipBuffer([dirName], parentDir, tmpFile);
601
670
  logFs('download', { path: check.resolved, type: 'directory', zipSize: zipBuffer.length });
602
671
  return jsonResponse(res, 200, {
603
672
  success: true,
@@ -656,24 +725,22 @@ export function startServer() {
656
725
  try {
657
726
  const tmpFile = join(tmpdir(), `ws-dl-${randomBytes(6).toString('hex')}.zip`);
658
727
  try {
659
- const zipArgs = resolvedPaths.map(p => `"${basename(p)}"`).join(' ');
728
+ const zipArgs = resolvedPaths.map(p => basename(p));
660
729
  const commonParent = resolvedPaths.reduce((a, b) => {
661
- const partsA = a.split('/').filter(Boolean);
662
- const partsB = b.split('/').filter(Boolean);
730
+ const sep = '/';
731
+ const partsA = a.replace(/\\/g, '/').split('/').filter(Boolean);
732
+ const partsB = b.replace(/\\/g, '/').split('/').filter(Boolean);
663
733
  const common = [];
664
734
  for (let i = 0; i < Math.min(partsA.length, partsB.length); i++) {
665
735
  if (partsA[i] === partsB[i]) common.push(partsA[i]);
666
736
  else break;
667
737
  }
668
- return '/' + common.join('/');
738
+ const result = sep + common.join(sep);
739
+ // Windows 盘符处理:C:/Users -> 而不是 /C:/Users
740
+ return result;
669
741
  });
670
742
 
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);
743
+ const zipBuffer = await createZipBuffer(zipArgs, commonParent, tmpFile);
677
744
  logFs('download_multi', { paths: resolvedPaths, zipSize: zipBuffer.length });
678
745
  return jsonResponse(res, 200, {
679
746
  success: true,
@@ -692,6 +759,202 @@ export function startServer() {
692
759
  }
693
760
  }
694
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
+
695
958
  // 在本地浏览器中打开文件
696
959
  if (pathname === '/api/browser/open') {
697
960
  const targetPath = body.path;
@@ -996,27 +1259,6 @@ export function startServer() {
996
1259
  }
997
1260
  }
998
1261
 
999
- // 运行中进程列表(需认证)
1000
- if (req.method === 'GET' && pathname === '/api/exec/running') {
1001
- return jsonResponse(res, 200, { success: true, processes: getRunningProcesses() });
1002
- }
1003
-
1004
- // 日志查询(需认证)
1005
- if (req.method === 'GET' && pathname === '/api/logs') {
1006
- const date = url.searchParams.get('date') || undefined;
1007
- const category = url.searchParams.get('category') || undefined;
1008
- const rawLimit = parseInt(url.searchParams.get('limit') || '200', 10);
1009
- const limit = Math.min(isNaN(rawLimit) ? 200 : rawLimit, MAX_LOG_QUERY_LIMIT);
1010
- const offset = parseInt(url.searchParams.get('offset') || '0', 10);
1011
- const result = queryLogs({ date, category, limit, offset });
1012
- return jsonResponse(res, 200, { success: true, ...result });
1013
- }
1014
-
1015
- // 日志日期列表(需认证)
1016
- if (req.method === 'GET' && pathname === '/api/logs/dates') {
1017
- return jsonResponse(res, 200, { success: true, dates: getLogDates() });
1018
- }
1019
-
1020
1262
  // ========== Skill 管理接口(仅 GET 路由) ==========
1021
1263
 
1022
1264
  // Skill 列表
@@ -1031,13 +1273,6 @@ export function startServer() {
1031
1273
  return jsonResponse(res, 200, getSkill(name));
1032
1274
  }
1033
1275
 
1034
- // Skill 执行状态查询
1035
- if (req.method === 'GET' && pathname === '/api/skill/status') {
1036
- const execId = url.searchParams.get('execId');
1037
- if (!execId) return jsonResponse(res, 400, { success: false, error: '缺少 execId 参数' });
1038
- return jsonResponse(res, 200, getSkillExecutionStatus(execId));
1039
- }
1040
-
1041
1276
  // Agent Skill Prompts(用于 AI System Prompt 注入)
1042
1277
  if (req.method === 'GET' && pathname === '/api/skill/agent-prompts') {
1043
1278
  const prompts = getAgentSkillPrompts();
@@ -1143,6 +1378,9 @@ export function startServer() {
1143
1378
  if (pathParts[1] === 'ws' && pathParts[2] === 'exec') {
1144
1379
  const execId = pathParts[3];
1145
1380
 
1381
+ // WebSocket 认证:优先用 Authorization Header;回退到 ?token= query 是因为
1382
+ // 浏览器 WebSocket API 不支持自定义 Header(插件端 new WebSocket() 无法设置 Header)。
1383
+ // 安全注意:query token 可能被代理日志/Referer 记录,故此处绝不将 token 写入任何日志。
1146
1384
  const authHeader = request.headers.authorization;
1147
1385
  let token = null;
1148
1386
  if (authHeader && authHeader.startsWith('Bearer ')) {
@@ -1185,25 +1423,25 @@ export function startServer() {
1185
1423
  }
1186
1424
  });
1187
1425
 
1188
- server.listen(port, host, async () => {
1426
+ server.listen(port, host, () => {
1189
1427
  console.log(`[Agent] HTTP 服务已启动: http://${host}:${port}`);
1190
1428
  console.log(`[Agent] WebSocket 服务已启动: ws://${host}:${port}`);
1191
1429
  startPairCodeRotation();
1192
1430
 
1193
- // 初始化 MCP 注册表(异步,不阻塞请求)
1194
- try {
1195
- await initializeMcpRegistry();
1196
- } catch (err) {
1197
- console.error('[Agent] MCP 注册表初始化失败:', err.message);
1198
- }
1431
+ // 后台异步初始化 MCP 和 Skill,不阻塞主流程
1432
+ (async () => {
1433
+ try {
1434
+ await initializeMcpRegistry();
1435
+ } catch (err) {
1436
+ console.error('[Agent] MCP 注册表初始化失败:', err.message);
1437
+ }
1199
1438
 
1200
- // 初始化 Skill 注册表
1201
- try {
1202
- const skillCount = initializeSkillRegistry();
1203
- console.log(`[Agent] Skill 注册表已初始化: ${skillCount} 个 Skill`);
1204
- } catch (err) {
1205
- console.error('[Agent] Skill 注册表初始化失败:', err.message);
1206
- }
1439
+ try {
1440
+ await initializeSkillRegistry();
1441
+ } catch (err) {
1442
+ console.error('[Agent] Skill 注册表初始化失败:', err.message);
1443
+ }
1444
+ })();
1207
1445
  });
1208
1446
 
1209
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