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 +2 -1
- package/src/auth.js +45 -12
- package/src/mcp/client.js +0 -8
- package/src/mcp/registry.js +13 -9
- package/src/search.js +21 -9
- package/src/server.js +324 -86
- package/src/skill/loader.js +1 -9
- package/src/skill/markdown-loader.js +164 -49
- package/src/skill/registry.js +3 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-helper-agent",
|
|
3
|
-
"version": "1.
|
|
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
|
-
//
|
|
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
|
-
* 生成随机配对码(
|
|
26
|
+
* 生成随机配对码(6位字母数字,使用密码学安全的随机数,排除易混淆字符)
|
|
17
27
|
*/
|
|
18
28
|
function generatePairCode() {
|
|
19
|
-
|
|
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(
|
|
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(
|
|
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() {
|
package/src/mcp/registry.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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,
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =>
|
|
728
|
+
const zipArgs = resolvedPaths.map(p => basename(p));
|
|
660
729
|
const commonParent = resolvedPaths.reduce((a, b) => {
|
|
661
|
-
const
|
|
662
|
-
const
|
|
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
|
-
|
|
738
|
+
const result = sep + common.join(sep);
|
|
739
|
+
// Windows 盘符处理:C:/Users -> 而不是 /C:/Users
|
|
740
|
+
return result;
|
|
669
741
|
});
|
|
670
742
|
|
|
671
|
-
|
|
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,
|
|
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
|
-
//
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
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
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
}
|
|
1439
|
+
try {
|
|
1440
|
+
await initializeSkillRegistry();
|
|
1441
|
+
} catch (err) {
|
|
1442
|
+
console.error('[Agent] Skill 注册表初始化失败:', err.message);
|
|
1443
|
+
}
|
|
1444
|
+
})();
|
|
1207
1445
|
});
|
|
1208
1446
|
|
|
1209
1447
|
// 优雅关闭(异步 + 防并发 + 超时兜底)
|
package/src/skill/loader.js
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
502
|
+
if (res.statusCode !== 200) {
|
|
503
|
+
reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
404
506
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
}
|
package/src/skill/registry.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|