ysagc-agent 0.1.0 → 0.3.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/README.md +22 -27
- package/package.json +2 -2
- package/src/config.js +6 -1
- package/src/http-fs.js +11 -4
- package/src/index.js +72 -8
- package/src/methods/browse.js +91 -0
- package/src/methods/dialog.js +84 -0
- package/src/methods/skill.js +126 -1
- package/src/origin.js +38 -0
- package/src/pairing.js +12 -1
package/README.md
CHANGED
|
@@ -6,39 +6,31 @@
|
|
|
6
6
|
- 通信:JSON-RPC 2.0 over WebSocket(控制通道)+ 二进制帧(终端通道)
|
|
7
7
|
- 路径沙箱:只能操作 allowlist(用户显式添加的项目根目录)内路径
|
|
8
8
|
- 终端:node-pty(Windows: PowerShell / macOS·Linux: 默认 shell),输入输出**不经过网站后端**
|
|
9
|
+
- **免配对码**(v0.2.0):浏览器打开网站「工作台」自动识别并连接本机代理,无需输入配对码、无需安装 exe
|
|
10
|
+
- **目录树浏览 + 技能链接安装**(v0.3.0):新增 `fs.browse`(网页内逐级选文件夹)与 `skill.installFromUrl`(从链接下载安装技能)
|
|
9
11
|
|
|
10
|
-
##
|
|
12
|
+
## 安装(npm 方式,全球通用)
|
|
11
13
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
到网站「工作台 → 下载」页,按你的系统下载安装包:
|
|
15
|
-
|
|
16
|
-
| 平台 | 形态 |
|
|
17
|
-
|---|---|
|
|
18
|
-
| Windows 10/11 | `ysagc-agent.exe`(单文件,免装 Node) |
|
|
19
|
-
| macOS 12+ | 单文件二进制(未签名,首次需右键「打开」) |
|
|
20
|
-
| Linux (Ubuntu 20.04+/Debian 11+) | 单文件二进制(AppImage 敬请期待) |
|
|
21
|
-
|
|
22
|
-
Windows 若被杀软拦截:选择「仍要运行」,或在 Windows 安全中心放行。
|
|
23
|
-
|
|
24
|
-
### ② npm(开发者用户)
|
|
14
|
+
要求 Node.js ≥ 20(Windows / macOS / Linux 均可)。
|
|
25
15
|
|
|
26
16
|
```bash
|
|
27
|
-
#
|
|
17
|
+
# 方式一:免安装,直接跑(推荐,首次会下载依赖包)
|
|
28
18
|
npx ysagc-agent
|
|
29
19
|
|
|
30
|
-
#
|
|
20
|
+
# 方式二:全局安装后运行
|
|
31
21
|
npm i -g ysagc-agent
|
|
32
22
|
ysagc-agent
|
|
33
23
|
```
|
|
34
24
|
|
|
35
|
-
|
|
25
|
+
启动后终端会显示 `ysagc-agent 运行中: ws://127.0.0.1:37890`,保持该终端窗口开启即可(最小化没问题,不要关闭)。
|
|
36
26
|
|
|
37
27
|
## 使用流程
|
|
38
28
|
|
|
39
|
-
1.
|
|
40
|
-
2.
|
|
41
|
-
3.
|
|
29
|
+
1. 打开终端执行 `npx ysagc-agent`(保持运行)。
|
|
30
|
+
2. 打开网站「工作台」,点击「新增工作区」→ 选择本机文件夹。
|
|
31
|
+
3. 在聊天中把话题「绑定工作区」后,AI 就能在对应文件夹里读文件、改代码、跑命令了。
|
|
32
|
+
|
|
33
|
+
> 旧版(0.1.x)安装包渠道与配对码流程已不再需要;如需卸载:`npm rm -g ysagc-agent`。
|
|
42
34
|
|
|
43
35
|
## 配置(agent.json)
|
|
44
36
|
|
|
@@ -47,8 +39,9 @@ ysagc-agent
|
|
|
47
39
|
```jsonc
|
|
48
40
|
{
|
|
49
41
|
"port": 37890, // 端口被占用自动 +1(最多 5 次)
|
|
50
|
-
"allowedRoots": [], //
|
|
51
|
-
"deviceTokenWhitelist": [], //
|
|
42
|
+
"allowedRoots": [], // 授权项目根目录(网站添加工作区时自动写入)
|
|
43
|
+
"deviceTokenWhitelist": [], // 旧版配对令牌(sha256,兼容保留)
|
|
44
|
+
"originWhitelist": [], // 允许直连的浏览器 Origin;留空=自动信任站点与 localhost
|
|
52
45
|
"trashEnabled": true, // 删除进回收站
|
|
53
46
|
"trashRetentionDays": 7,
|
|
54
47
|
"enableTerminal": true, // 终端开关
|
|
@@ -64,13 +57,15 @@ ysagc-agent
|
|
|
64
57
|
|
|
65
58
|
**Q:端口被占用怎么办?** 自动尝试 37891–37894;也可 `ysagc-agent --port <端口>` 指定。
|
|
66
59
|
|
|
67
|
-
**Q
|
|
60
|
+
**Q:网页提示"未检测到本机代理"?** 确认 Agent 已在终端运行(能看到 `运行中: ws://127.0.0.1:37890`);首次 `npx` 会先下载依赖,稍等片刻。
|
|
68
61
|
|
|
69
|
-
**Q
|
|
62
|
+
**Q:macOS 提示"无法打开"?** npm 方式无需处理;仅安装包渠道需要右键 → 打开。
|
|
70
63
|
|
|
71
64
|
**Q:日志在哪?** `~/.ysagc-agent/logs/agent.log`。日志**永不记录**密钥文件内容与 Git 凭证。
|
|
72
65
|
|
|
73
|
-
**Q:Agent 会读取我电脑上的所有文件吗?** 不会。Agent
|
|
66
|
+
**Q:Agent 会读取我电脑上的所有文件吗?** 不会。Agent 只能访问你在网站显式添加的工作区目录(allowlist),越界操作一律拒绝并记录审计;终端除外(终端等同于你的命令行,可操作整台电脑——首次使用会在网页端弹安全确认)。
|
|
67
|
+
|
|
68
|
+
**Q:其他网站能控制我的 Agent 吗?** 不能。Agent 只信任站点域名与 localhost 来源(Origin 白名单),其他网页发起的连接会被拒绝。
|
|
74
69
|
|
|
75
70
|
## 开发
|
|
76
71
|
|
|
@@ -81,7 +76,7 @@ npm run smoke # 冒烟测试(WS 鉴权 / RPC / 终端全链路)
|
|
|
81
76
|
node scripts/smoke.js <token> <port> # 自定义参数
|
|
82
77
|
```
|
|
83
78
|
|
|
84
|
-
|
|
79
|
+
打包(单文件,可选):
|
|
85
80
|
|
|
86
81
|
```bash
|
|
87
82
|
npx @yao-pkg/pkg . --target node22-win-x64 --output dist/ysagc-agent.exe
|
|
@@ -93,4 +88,4 @@ CI:`.gitlab-ci.yml`(build 产物 + tag 触发发布到 Package Registry / np
|
|
|
93
88
|
|
|
94
89
|
## 隐私
|
|
95
90
|
|
|
96
|
-
Agent
|
|
91
|
+
Agent 与网站后端的交互仅限:设备自动注册、版本握手、审计元数据。**项目文件内容、终端输入输出、Git 凭证一律不经过网站后端**。
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ysagc-agent",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "代码工作台本机代理:替浏览器在本机执行文件/Git/终端操作(JSON-RPC over WebSocket,直连 127.0.0.1
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "代码工作台本机代理:替浏览器在本机执行文件/Git/终端操作(JSON-RPC over WebSocket,直连 127.0.0.1,免配对码)",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"ysagc-agent": "src/index.js"
|
package/src/config.js
CHANGED
|
@@ -27,8 +27,13 @@ const DEFAULTS = {
|
|
|
27
27
|
// 终端权限:ask(每次询问)/ allow(始终允许)/ deny(拒绝)
|
|
28
28
|
terminalPermission: 'ask',
|
|
29
29
|
// 协议版本(与后端握手比对)
|
|
30
|
-
protocolVersion:
|
|
30
|
+
protocolVersion: 2,
|
|
31
31
|
agentVersion: null,
|
|
32
|
+
// ---- 免配对码直连(v0.2.0 起)----
|
|
33
|
+
// 允许的浏览器 Origin 列表(精确匹配 origin,如 https://chat.ysagc.top)。
|
|
34
|
+
// 为空时自动信任:backendUrl 对应的 origin + http://localhost / http://127.0.0.1 及其任意端口。
|
|
35
|
+
// 该列表之外的 Origin 发起的 WebSocket / 本地 HTTP 请求将被拒绝(防恶意网页操控本机代理)。
|
|
36
|
+
originWhitelist: [],
|
|
32
37
|
};
|
|
33
38
|
|
|
34
39
|
function ensureDir() {
|
package/src/http-fs.js
CHANGED
|
@@ -13,6 +13,8 @@ const path = require('path');
|
|
|
13
13
|
const crypto = require('crypto');
|
|
14
14
|
const url = require('url');
|
|
15
15
|
|
|
16
|
+
const { isOriginTrusted } = require('./origin');
|
|
17
|
+
|
|
16
18
|
const MAX_UPLOAD_SIZE = 512 * 1024 * 1024; // 512MB
|
|
17
19
|
const MAX_ZIP_FILES = 2000;
|
|
18
20
|
|
|
@@ -24,7 +26,12 @@ function createHttpFsHandlers(ctxRef) {
|
|
|
24
26
|
const sandbox = () => ctxRef().sandbox;
|
|
25
27
|
const cfg = () => ctxRef().config;
|
|
26
28
|
|
|
27
|
-
function checkToken(token) {
|
|
29
|
+
function checkToken(token, req) {
|
|
30
|
+
// 免配对码直连(v0.2.0):Origin 受信任的浏览器请求直接放行
|
|
31
|
+
if (req) {
|
|
32
|
+
const origin = req.headers && req.headers.origin;
|
|
33
|
+
if (origin && isOriginTrusted(origin, cfg())) return true;
|
|
34
|
+
}
|
|
28
35
|
if (!token) return false;
|
|
29
36
|
return (cfg().deviceTokenWhitelist || []).includes(sha256(token));
|
|
30
37
|
}
|
|
@@ -37,7 +44,7 @@ function createHttpFsHandlers(ctxRef) {
|
|
|
37
44
|
/** 上传:分片追加到临时文件,最后一片 rename 落盘 */
|
|
38
45
|
function handleUpload(req, res, query) {
|
|
39
46
|
const token = query.token;
|
|
40
|
-
if (!checkToken(token)) return sendJson(res, 401, { code: 'E_AUTH_FAILED', msg: '未授权' });
|
|
47
|
+
if (!checkToken(token, req)) return sendJson(res, 401, { code: 'E_AUTH_FAILED', msg: '未授权' });
|
|
41
48
|
const relPath = String(query.path || '');
|
|
42
49
|
if (!relPath) return sendJson(res, 400, { code: 'E_VALIDATION', msg: '缺少 path' });
|
|
43
50
|
let r;
|
|
@@ -89,7 +96,7 @@ function createHttpFsHandlers(ctxRef) {
|
|
|
89
96
|
/** 单文件下载(流式) */
|
|
90
97
|
function handleDownload(req, res, query) {
|
|
91
98
|
const token = query.token;
|
|
92
|
-
if (!checkToken(token)) return sendJson(res, 401, { code: 'E_AUTH_FAILED', msg: '未授权' });
|
|
99
|
+
if (!checkToken(token, req)) return sendJson(res, 401, { code: 'E_AUTH_FAILED', msg: '未授权' });
|
|
93
100
|
const relPath = String(query.path || '');
|
|
94
101
|
let r;
|
|
95
102
|
try {
|
|
@@ -111,7 +118,7 @@ function createHttpFsHandlers(ctxRef) {
|
|
|
111
118
|
/** zip 打包下载(yazl 流式) */
|
|
112
119
|
function handleZip(req, res, query) {
|
|
113
120
|
const token = query.token;
|
|
114
|
-
if (!checkToken(token)) return sendJson(res, 401, { code: 'E_AUTH_FAILED', msg: '未授权' });
|
|
121
|
+
if (!checkToken(token, req)) return sendJson(res, 401, { code: 'E_AUTH_FAILED', msg: '未授权' });
|
|
115
122
|
const paths = String(query.paths || '')
|
|
116
123
|
.split(',')
|
|
117
124
|
.map((p) => p.trim())
|
package/src/index.js
CHANGED
|
@@ -20,6 +20,7 @@ const fs = require('fs');
|
|
|
20
20
|
const { loadConfig, saveConfig, CONFIG_DIR } = require('./config');
|
|
21
21
|
const logger = require('./logger');
|
|
22
22
|
const { register, handleRequest } = require('./rpc');
|
|
23
|
+
const { isOriginTrusted } = require('./origin');
|
|
23
24
|
const { registerSystemMethods } = require('./methods/system');
|
|
24
25
|
const { createTerminalMethods } = require('./methods/terminal');
|
|
25
26
|
const { createProjectMethods } = require('./methods/project');
|
|
@@ -27,9 +28,11 @@ const { createFsMethods } = require('./methods/fs');
|
|
|
27
28
|
const { createSkillMethods } = require('./methods/skill');
|
|
28
29
|
const { createGitMethods } = require('./methods/git');
|
|
29
30
|
const { createScaffoldMethods } = require('./methods/scaffold');
|
|
31
|
+
const { createDialogMethods } = require('./methods/dialog');
|
|
32
|
+
const { createBrowseMethods } = require('./methods/browse');
|
|
30
33
|
const { createHttpFsHandlers } = require('./http-fs');
|
|
31
34
|
const { Sandbox } = require('./sandbox');
|
|
32
|
-
const { startPairing, stopPairing } = require('./pairing');
|
|
35
|
+
const { startPairing, stopPairing, agentId } = require('./pairing');
|
|
33
36
|
|
|
34
37
|
const DEFAULT_PORT = 37890;
|
|
35
38
|
const MAX_PORT_TRIES = 5;
|
|
@@ -47,6 +50,26 @@ function getArg(name) {
|
|
|
47
50
|
return i >= 0 ? argv[i + 1] : null;
|
|
48
51
|
}
|
|
49
52
|
|
|
53
|
+
const AGENT_VERSION = require('../package.json').version;
|
|
54
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
55
|
+
console.log(`ysagc-agent v${AGENT_VERSION} — 代码工作台本机代理
|
|
56
|
+
用法: npx ysagc-agent [选项]
|
|
57
|
+
|
|
58
|
+
选项:
|
|
59
|
+
--port N 指定监听端口(默认 37890,被占用自动 +1)
|
|
60
|
+
--init-token <t> 把指定 token 加入白名单(兼容旧版配对,一般无需使用)
|
|
61
|
+
--help, -h 显示帮助
|
|
62
|
+
--version, -v 显示版本
|
|
63
|
+
|
|
64
|
+
启动后无需配对码:浏览器打开网站「工作台」即可自动识别本机代理并连接。
|
|
65
|
+
代理仅监听 127.0.0.1,替浏览器在本机执行文件 / Git / 终端操作。`);
|
|
66
|
+
process.exit(0);
|
|
67
|
+
}
|
|
68
|
+
if (argv.includes('--version') || argv.includes('-v')) {
|
|
69
|
+
console.log(AGENT_VERSION);
|
|
70
|
+
process.exit(0);
|
|
71
|
+
}
|
|
72
|
+
|
|
50
73
|
// ---------------- 配置 ----------------
|
|
51
74
|
let config = loadConfig();
|
|
52
75
|
logger.setLevel(config.logLevel);
|
|
@@ -80,6 +103,10 @@ const gitMethods = createGitMethods(() => configRef);
|
|
|
80
103
|
gitMethods.register(register);
|
|
81
104
|
const scaffoldMethods = createScaffoldMethods(() => configRef);
|
|
82
105
|
scaffoldMethods.register(register);
|
|
106
|
+
const dialogMethods = createDialogMethods();
|
|
107
|
+
dialogMethods.register(register);
|
|
108
|
+
const browseMethods = createBrowseMethods();
|
|
109
|
+
browseMethods.register(register);
|
|
83
110
|
|
|
84
111
|
// ---------------- WS 服务 ----------------
|
|
85
112
|
const state = {
|
|
@@ -136,6 +163,8 @@ function setupConnection(ws, req) {
|
|
|
136
163
|
ws.isAlive = true;
|
|
137
164
|
ws.authed = false;
|
|
138
165
|
ws.deviceId = null;
|
|
166
|
+
// 免配对码直连:Origin 可信的浏览器连接无需 deviceToken(v0.2.0)
|
|
167
|
+
ws.originTrusted = isOriginTrusted(req.headers.origin, configRef.config);
|
|
139
168
|
ws.authTimer = setTimeout(() => {
|
|
140
169
|
if (!ws.authed) closeWith(ws, 4401, 'auth timeout');
|
|
141
170
|
}, AUTH_TIMEOUT_MS);
|
|
@@ -167,9 +196,10 @@ function setupConnection(ws, req) {
|
|
|
167
196
|
// ---- 鉴权握手 ----
|
|
168
197
|
if (msg && msg.type === 'auth') {
|
|
169
198
|
if (ws.authed) return;
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
199
|
+
const tokenOk = checkAuthToken(msg.token);
|
|
200
|
+
const autoOk = ws.originTrusted && (msg.mode === 'auto' || !msg.token);
|
|
201
|
+
if (!tokenOk && !autoOk) {
|
|
202
|
+
logger.warn(`[ws] 鉴权失败:token 不在白名单且 Origin 不受信任(origin=${req.headers.origin || 'none'} remote=${req.socket.remoteAddress})`);
|
|
173
203
|
closeWith(ws, 4401, 'unauthorized');
|
|
174
204
|
return;
|
|
175
205
|
}
|
|
@@ -275,20 +305,52 @@ function setupConnection(ws, req) {
|
|
|
275
305
|
function tryListen(port, triesLeft) {
|
|
276
306
|
const httpFs = createHttpFsHandlers(() => configRef);
|
|
277
307
|
const server = http.createServer((req, res) => {
|
|
278
|
-
// 本地 HTTP:上传/下载/zip(T-M1-05)
|
|
279
308
|
const parsed = url.parse(req.url, true);
|
|
280
309
|
const p = parsed.pathname || '';
|
|
310
|
+
const origin = req.headers.origin || '';
|
|
311
|
+
|
|
312
|
+
// CORS 预检(浏览器跨源 fetch 到 http://127.0.0.1 需要)
|
|
313
|
+
if (req.method === 'OPTIONS') {
|
|
314
|
+
res.writeHead(204, {
|
|
315
|
+
'Access-Control-Allow-Origin': origin || '*',
|
|
316
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
317
|
+
'Access-Control-Allow-Headers': 'Content-Type, X-Offset, X-Total, Authorization',
|
|
318
|
+
'Access-Control-Max-Age': '86400',
|
|
319
|
+
});
|
|
320
|
+
res.end();
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
const corsHeaders = (h) => {
|
|
324
|
+
h['Access-Control-Allow-Origin'] = origin || '*';
|
|
325
|
+
return h;
|
|
326
|
+
};
|
|
281
327
|
try {
|
|
328
|
+
// 免配对码直连:浏览器探测本机 Agent 信息(v0.2.0)
|
|
329
|
+
if (req.method === 'GET' && p === '/api/bootstrap') {
|
|
330
|
+
res.writeHead(200, corsHeaders({ 'Content-Type': 'application/json' }));
|
|
331
|
+
res.end(JSON.stringify({
|
|
332
|
+
agentId: agentId(),
|
|
333
|
+
agentVersion: require('../package.json').version,
|
|
334
|
+
protocolVersion: configRef.config.protocolVersion || 2,
|
|
335
|
+
port: configRef.config.port,
|
|
336
|
+
platform: `${process.platform} ${process.arch}`,
|
|
337
|
+
nodeVersion: process.version,
|
|
338
|
+
hostname: require('os').hostname(),
|
|
339
|
+
paired: (configRef.config.deviceTokenWhitelist || []).length > 0,
|
|
340
|
+
allowAuto: true,
|
|
341
|
+
}));
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
282
344
|
if (req.method === 'POST' && p === '/api/fs/upload') return httpFs.handleUpload(req, res, parsed.query);
|
|
283
345
|
if (req.method === 'GET' && p === '/api/fs/download') return httpFs.handleDownload(req, res, parsed.query);
|
|
284
346
|
if (req.method === 'GET' && p === '/api/fs/zip') return httpFs.handleZip(req, res, parsed.query);
|
|
285
347
|
} catch (e) {
|
|
286
348
|
logger.error(`[http] 处理 ${p} 失败: ${e && e.message}`);
|
|
287
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
349
|
+
res.writeHead(500, corsHeaders({ 'Content-Type': 'application/json' }));
|
|
288
350
|
res.end(JSON.stringify({ code: 'E_INTERNAL', msg: e && e.message }));
|
|
289
351
|
return;
|
|
290
352
|
}
|
|
291
|
-
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
353
|
+
res.writeHead(404, corsHeaders({ 'Content-Type': 'application/json' }));
|
|
292
354
|
res.end(JSON.stringify({ error: 'not found' }));
|
|
293
355
|
});
|
|
294
356
|
|
|
@@ -343,7 +405,9 @@ function tryListen(port, triesLeft) {
|
|
|
343
405
|
return server;
|
|
344
406
|
}
|
|
345
407
|
|
|
346
|
-
console.log(`ysagc-agent v${
|
|
408
|
+
console.log(`ysagc-agent v${AGENT_VERSION} 启动中(配置目录: ${CONFIG_DIR},端口: ${config.port})`);
|
|
409
|
+
console.log(`免配对码直连已启用:打开网站「工作台」即可自动识别本机代理(agentId: ${agentId().slice(0, 8)}...)`);
|
|
410
|
+
console.log('提示: 输入 --help 查看完整选项。');
|
|
347
411
|
tryListen(config.port, MAX_PORT_TRIES);
|
|
348
412
|
|
|
349
413
|
process.on('SIGINT', () => {
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fs.browse RPC:本机目录树浏览(v0.3.0)
|
|
3
|
+
* 用途:新增工作区时在网页内逐级点选文件夹,替代系统原生文件夹选择器(跨平台、无原生弹窗依赖)。
|
|
4
|
+
* - fs.browse {} → 返回常见根目录(Windows 盘符 + Home;POSIX / + Home)
|
|
5
|
+
* - fs.browse { path } → 返回该目录下的子目录
|
|
6
|
+
* 安全:仅列出目录名(只读),不做 allowlist 校验(选择后加入 allowlist 才可操作);
|
|
7
|
+
* 仅 Origin 受信任的浏览器连接可调用(与其他 RPC 相同的鉴权门槛)。
|
|
8
|
+
*/
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const os = require('os');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const { RpcError } = require('../rpc');
|
|
15
|
+
|
|
16
|
+
// 目录树浏览时默认隐藏的目录(避免噪声/系统目录)
|
|
17
|
+
const HIDDEN = new Set([
|
|
18
|
+
'.git', '.svn', '.hg', '.idea', '.vscode', '.vs', '.next', '.nuxt',
|
|
19
|
+
'node_modules', 'dist', 'build', 'out', 'target', '.cache', '.npm',
|
|
20
|
+
'$RECYCLE.BIN', 'System Volume Information', 'Windows', 'Program Files',
|
|
21
|
+
'Program Files (x86)', 'ProgramData', 'AppData', '.gradle', '.m2',
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
function listRoots() {
|
|
25
|
+
const roots = [];
|
|
26
|
+
const home = os.homedir();
|
|
27
|
+
if (process.platform === 'win32') {
|
|
28
|
+
for (let c = 65; c <= 90; c++) {
|
|
29
|
+
const letter = String.fromCharCode(c);
|
|
30
|
+
try {
|
|
31
|
+
if (fs.existsSync(`${letter}:/`)) roots.push({ name: `${letter}:`, path: `${letter}:/` });
|
|
32
|
+
} catch { /* ignore */ }
|
|
33
|
+
}
|
|
34
|
+
} else {
|
|
35
|
+
roots.push({ name: '/', path: '/' });
|
|
36
|
+
}
|
|
37
|
+
if (home) roots.push({ name: 'Home', path: home.replace(/\\/g, '/') });
|
|
38
|
+
return roots;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function browse(dirPath) {
|
|
42
|
+
// 无路径 → 根目录
|
|
43
|
+
if (!dirPath || String(dirPath).trim() === '') {
|
|
44
|
+
return { path: null, parent: null, roots: listRoots(), entries: [] };
|
|
45
|
+
}
|
|
46
|
+
const abs = path.resolve(String(dirPath).trim());
|
|
47
|
+
let st;
|
|
48
|
+
try {
|
|
49
|
+
st = fs.statSync(abs);
|
|
50
|
+
} catch {
|
|
51
|
+
throw new RpcError('E_PATH_NOT_FOUND', '目录不存在');
|
|
52
|
+
}
|
|
53
|
+
if (!st.isDirectory()) throw new RpcError('E_PATH_NOT_DIR', '不是目录');
|
|
54
|
+
const parent = path.dirname(abs);
|
|
55
|
+
const atRoot = parent === abs;
|
|
56
|
+
const entries = [];
|
|
57
|
+
let raw;
|
|
58
|
+
try {
|
|
59
|
+
raw = fs.readdirSync(abs, { withFileTypes: true });
|
|
60
|
+
} catch (e) {
|
|
61
|
+
throw new RpcError('E_PATH_NO_PERMISSION', `无法读取目录: ${e.message}`);
|
|
62
|
+
}
|
|
63
|
+
for (const e of raw) {
|
|
64
|
+
if (!e.isDirectory()) continue;
|
|
65
|
+
if (e.isSymbolicLink()) continue; // 防符号链接循环
|
|
66
|
+
if (e.name.startsWith('.') || HIDDEN.has(e.name)) continue;
|
|
67
|
+
const child = path.join(abs, e.name);
|
|
68
|
+
try {
|
|
69
|
+
if (fs.statSync(child).isDirectory()) {
|
|
70
|
+
entries.push({ name: e.name, path: child.replace(/\\/g, '/') });
|
|
71
|
+
}
|
|
72
|
+
} catch { /* 无权限跳过 */ }
|
|
73
|
+
}
|
|
74
|
+
entries.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'));
|
|
75
|
+
return {
|
|
76
|
+
path: abs.replace(/\\/g, '/'),
|
|
77
|
+
parent: atRoot ? null : parent.replace(/\\/g, '/'),
|
|
78
|
+
roots: atRoot ? listRoots() : [],
|
|
79
|
+
entries,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function createBrowseMethods() {
|
|
84
|
+
return {
|
|
85
|
+
register(reg) {
|
|
86
|
+
reg('fs.browse', async (params = {}) => browse(params && params.path));
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = { createBrowseMethods };
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dialog.* RPC 方法:本机原生对话框(T-M3-xx)
|
|
3
|
+
* - dialog.pickFolder {} → { path } 打开系统文件夹选择器,返回绝对路径(取消返回 E_DIALOG_CANCELED)
|
|
4
|
+
* 实现:
|
|
5
|
+
* Windows: PowerShell FolderBrowserDialog(Agent 运行在用户本机,可弹原生窗口)
|
|
6
|
+
* macOS: osascript choose folder
|
|
7
|
+
* Linux: zenity / kdialog(依次尝试)
|
|
8
|
+
* 用途:添加项目时"点击选择文件夹"替代手输绝对路径(需求 4.3.1 方式 A)
|
|
9
|
+
*/
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const { execFile } = require('child_process');
|
|
13
|
+
const { RpcError } = require('../rpc');
|
|
14
|
+
|
|
15
|
+
const DIALOG_TIMEOUT_MS = 5 * 60 * 1000; // 用户可能挑很久,5 分钟上限
|
|
16
|
+
|
|
17
|
+
function run(cmd, args, timeoutMs) {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
execFile(
|
|
20
|
+
cmd,
|
|
21
|
+
args,
|
|
22
|
+
{ timeout: timeoutMs || DIALOG_TIMEOUT_MS, windowsHide: true, maxBuffer: 4 * 1024 * 1024 },
|
|
23
|
+
(err, stdout) => {
|
|
24
|
+
if (err) return reject(err);
|
|
25
|
+
resolve(String(stdout || ''));
|
|
26
|
+
}
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Windows:PowerShell FolderBrowserDialog */
|
|
32
|
+
async function pickFolderWindows() {
|
|
33
|
+
const ps = [
|
|
34
|
+
'Add-Type -AssemblyName System.Windows.Forms',
|
|
35
|
+
"$f = New-Object System.Windows.Forms.FolderBrowserDialog",
|
|
36
|
+
"$f.Description = '选择要添加到工作台的项目文件夹'",
|
|
37
|
+
'$f.ShowNewFolderButton = $true',
|
|
38
|
+
"if ($f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { Write-Output $f.SelectedPath }",
|
|
39
|
+
].join('; ');
|
|
40
|
+
const out = await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-STA', '-Command', ps]);
|
|
41
|
+
const p = String(out || '').trim().split(/\r?\n/).filter(Boolean).pop() || '';
|
|
42
|
+
return p;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** macOS:osascript choose folder */
|
|
46
|
+
async function pickFolderMac() {
|
|
47
|
+
const out = await run('osascript', ['-e', 'POSIX path of (choose folder with prompt "选择要添加到工作台的项目文件夹")']);
|
|
48
|
+
return String(out || '').trim();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Linux:zenity → kdialog 依次尝试 */
|
|
52
|
+
async function pickFolderLinux() {
|
|
53
|
+
for (const [cmd, args] of [
|
|
54
|
+
['zenity', ['--file-selection', '--directory', '--title=选择要添加到工作台的项目文件夹']],
|
|
55
|
+
['kdialog', ['--getexistingdirectory', '选择要添加到工作台的项目文件夹']],
|
|
56
|
+
]) {
|
|
57
|
+
try {
|
|
58
|
+
const out = await run(cmd, args, 30000);
|
|
59
|
+
const p = String(out || '').trim();
|
|
60
|
+
if (p) return p;
|
|
61
|
+
} catch {
|
|
62
|
+
/* 尝试下一种 */
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
throw new RpcError('E_DIALOG_UNSUPPORTED', '当前系统未安装文件夹选择工具(zenity/kdialog),请手动输入路径');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function createDialogMethods() {
|
|
69
|
+
return {
|
|
70
|
+
register(reg) {
|
|
71
|
+
reg('dialog.pickFolder', async () => {
|
|
72
|
+
let p = '';
|
|
73
|
+
if (process.platform === 'win32') p = await pickFolderWindows();
|
|
74
|
+
else if (process.platform === 'darwin') p = await pickFolderMac();
|
|
75
|
+
else p = await pickFolderLinux();
|
|
76
|
+
p = String(p || '').trim();
|
|
77
|
+
if (!p) throw new RpcError('E_DIALOG_CANCELED', '已取消选择文件夹');
|
|
78
|
+
return { path: p.replace(/\\/g, '/') };
|
|
79
|
+
});
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = { createDialogMethods };
|
package/src/methods/skill.js
CHANGED
|
@@ -48,7 +48,10 @@ function saveState(state) {
|
|
|
48
48
|
|
|
49
49
|
/** 解析 SKILL.md:frontmatter + 正文 */
|
|
50
50
|
function parseSkillDoc(content) {
|
|
51
|
-
|
|
51
|
+
// 兼容带 BOM 的文件(常见于 Windows 编辑器/Compress-Archive 产物)
|
|
52
|
+
let text = String(content || '');
|
|
53
|
+
if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);
|
|
54
|
+
const m = text.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
|
|
52
55
|
if (!m) return null;
|
|
53
56
|
const meta = {};
|
|
54
57
|
for (const line of m[1].split('\n')) {
|
|
@@ -220,6 +223,100 @@ function fetchMarketRaw(backendUrl, skillId, ticket) {
|
|
|
220
223
|
});
|
|
221
224
|
}
|
|
222
225
|
|
|
226
|
+
/**
|
|
227
|
+
* 从任意 http/https 链接下载技能(v0.3.0:skill.installFromUrl)
|
|
228
|
+
* 支持两种形态:
|
|
229
|
+
* - .zip / 压缩包(含 SKILL.md 的技能包,可带 scripts/resources)
|
|
230
|
+
* - 单个 SKILL.md / .md 文本(视为单个技能)
|
|
231
|
+
* 安全:仅 http/https;大小 ≤ MAX_TOTAL_BYTES;SKILL.md frontmatter + 名称白名单校验;
|
|
232
|
+
* 解压文件防路径穿越;校验失败整体回滚。
|
|
233
|
+
*/
|
|
234
|
+
function downloadUrl(url, maxBytes) {
|
|
235
|
+
return new Promise((resolve, reject) => {
|
|
236
|
+
let u;
|
|
237
|
+
try {
|
|
238
|
+
u = new URL(String(url || '').trim());
|
|
239
|
+
} catch {
|
|
240
|
+
return reject(new RpcError('E_VALIDATION', '链接无效'));
|
|
241
|
+
}
|
|
242
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
|
243
|
+
return reject(new RpcError('E_VALIDATION', '仅支持 http/https 链接'));
|
|
244
|
+
}
|
|
245
|
+
const lib = u.protocol === 'https:' ? https : http;
|
|
246
|
+
const chunks = [];
|
|
247
|
+
let total = 0;
|
|
248
|
+
let settled = false;
|
|
249
|
+
const fail = (err) => {
|
|
250
|
+
if (settled) return;
|
|
251
|
+
settled = true;
|
|
252
|
+
reject(err);
|
|
253
|
+
};
|
|
254
|
+
const req = lib.get(u, { timeout: 20000, headers: { 'User-Agent': 'ysagc-agent/0.3' } }, (res) => {
|
|
255
|
+
if (res.statusCode >= 400) {
|
|
256
|
+
let body = '';
|
|
257
|
+
res.on('data', (c) => (body += c));
|
|
258
|
+
res.on('end', () => {
|
|
259
|
+
let msg = `下载失败(HTTP ${res.statusCode})`;
|
|
260
|
+
try { msg = JSON.parse(body).msg || msg; } catch { /* ignore */ }
|
|
261
|
+
fail(new RpcError('E_SKILL_DOWNLOAD_FAILED', msg));
|
|
262
|
+
});
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
res.on('data', (c) => {
|
|
266
|
+
total += c.length;
|
|
267
|
+
if (total > maxBytes) {
|
|
268
|
+
req.destroy();
|
|
269
|
+
fail(new RpcError('E_SKILL_DOWNLOAD_FAILED', `技能包过大(>${Math.floor(maxBytes / 1024 / 1024)}MB)`));
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
chunks.push(c);
|
|
273
|
+
});
|
|
274
|
+
res.on('end', () => {
|
|
275
|
+
if (settled) return;
|
|
276
|
+
settled = true;
|
|
277
|
+
const base = path.basename(u.pathname || '').split('?')[0];
|
|
278
|
+
resolve({ buffer: Buffer.concat(chunks), contentType: res.headers['content-type'] || '', fileName: base });
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
req.on('error', (e) => fail(new RpcError('E_SKILL_DOWNLOAD_FAILED', `下载失败: ${e.message}`)));
|
|
282
|
+
req.on('timeout', () => { req.destroy(); fail(new RpcError('E_SKILL_DOWNLOAD_FAILED', '下载超时')); });
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** 解压 zip(防路径穿越),返回 [{relPath, content}] */
|
|
287
|
+
function extractZipToFiles(buffer) {
|
|
288
|
+
return new Promise((resolve, reject) => {
|
|
289
|
+
let yauzl;
|
|
290
|
+
try {
|
|
291
|
+
yauzl = require('yauzl');
|
|
292
|
+
} catch {
|
|
293
|
+
return reject(new RpcError('E_SKILL_INVALID', '本机缺少解压组件,请更新 Agent'));
|
|
294
|
+
}
|
|
295
|
+
yauzl.fromBuffer(buffer, { lazyEntries: true, decodeStrings: true }, (err, zipfile) => {
|
|
296
|
+
if (err || !zipfile) return reject(new RpcError('E_SKILL_INVALID', '压缩包解析失败'));
|
|
297
|
+
const files = [];
|
|
298
|
+
zipfile.on('entry', (entry) => {
|
|
299
|
+
const name = entry.fileName;
|
|
300
|
+
if (/\/$/.test(name)) { zipfile.readEntry(); return; }
|
|
301
|
+
if (name.includes('..') || name.startsWith('/') || name.includes('\\')) { zipfile.readEntry(); return; }
|
|
302
|
+
zipfile.openReadStream(entry, (e, stream) => {
|
|
303
|
+
if (e || !stream) { zipfile.readEntry(); return; }
|
|
304
|
+
const chunks = [];
|
|
305
|
+
stream.on('data', (c) => chunks.push(c));
|
|
306
|
+
stream.on('end', () => {
|
|
307
|
+
files.push({ relPath: name, content: Buffer.concat(chunks).toString('utf8') });
|
|
308
|
+
zipfile.readEntry();
|
|
309
|
+
});
|
|
310
|
+
stream.on('error', () => zipfile.readEntry());
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
zipfile.on('end', () => resolve(files));
|
|
314
|
+
zipfile.on('error', (e) => reject(new RpcError('E_SKILL_INVALID', `压缩包读取失败: ${e.message}`)));
|
|
315
|
+
zipfile.readEntry();
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
223
320
|
function createSkillMethods(ctxRef) {
|
|
224
321
|
const cfg = () => ctxRef().config;
|
|
225
322
|
|
|
@@ -296,6 +393,34 @@ function createSkillMethods(ctxRef) {
|
|
|
296
393
|
return { ok: true, skill: readSkillInfo(dir) };
|
|
297
394
|
});
|
|
298
395
|
|
|
396
|
+
// ---- 从链接安装(v0.3.0)----
|
|
397
|
+
reg('skill.installFromUrl', async (params = {}) => {
|
|
398
|
+
const url = String(params.url || '').trim();
|
|
399
|
+
if (!url) throw new RpcError('E_VALIDATION', '请输入技能链接');
|
|
400
|
+
const { buffer, fileName } = await downloadUrl(url, MAX_TOTAL_BYTES);
|
|
401
|
+
const isZip = /\.zip$/i.test(fileName) || (buffer.length >= 2 && buffer[0] === 0x50 && buffer[1] === 0x4b);
|
|
402
|
+
let files = [];
|
|
403
|
+
if (isZip) {
|
|
404
|
+
files = await extractZipToFiles(buffer);
|
|
405
|
+
} else {
|
|
406
|
+
files = [{ relPath: 'SKILL.md', content: buffer.toString('utf8') }];
|
|
407
|
+
}
|
|
408
|
+
const sm = files.find((f) => f.relPath === 'SKILL.md');
|
|
409
|
+
if (!sm) throw new RpcError('E_SKILL_INVALID', '技能包缺少 SKILL.md');
|
|
410
|
+
const parsed = parseSkillDoc(sm.content);
|
|
411
|
+
if (!parsed) throw new RpcError('E_SKILL_INVALID', 'SKILL.md 格式非法(需要 name/description frontmatter)');
|
|
412
|
+
const name = parsed.name;
|
|
413
|
+
const dir = writeSkillFiles(name, files);
|
|
414
|
+
const check = validateSkillDir(dir, name);
|
|
415
|
+
if (!check.ok) {
|
|
416
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
417
|
+
throw new RpcError('E_SKILL_INVALID', `技能校验失败: ${check.errors.join('; ')}`);
|
|
418
|
+
}
|
|
419
|
+
audit('skill.install', name, 'ok');
|
|
420
|
+
logger.info(`[skill] 已从链接安装技能 ${name}(${url})`);
|
|
421
|
+
return { ok: true, skill: readSkillInfo(dir) };
|
|
422
|
+
});
|
|
423
|
+
|
|
299
424
|
// ---- 卸载(危险级:前端二次确认)----
|
|
300
425
|
reg('skill.uninstall', async (params = {}) => {
|
|
301
426
|
const name = String(params.name || '');
|
package/src/origin.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Origin 信任判断(免配对码直连,v0.2.0)
|
|
3
|
+
* 浏览器 WebSocket / fetch 会带 Origin 头;恶意网页发起的请求 Origin 不在白名单时被拒绝。
|
|
4
|
+
* originWhitelist 为空时自动信任:backendUrl 的 origin + localhost/127.0.0.1 任意端口。
|
|
5
|
+
*/
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {string|null|undefined} origin 请求的 Origin 头
|
|
10
|
+
* @param {{backendUrl?: string, originWhitelist?: string[]}} config 当前配置
|
|
11
|
+
* @returns {boolean}
|
|
12
|
+
*/
|
|
13
|
+
function isOriginTrusted(origin, config) {
|
|
14
|
+
if (!origin || typeof origin !== 'string') return false;
|
|
15
|
+
let o;
|
|
16
|
+
try {
|
|
17
|
+
o = new URL(origin).origin;
|
|
18
|
+
} catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
const whitelist = (config && config.originWhitelist) || [];
|
|
22
|
+
if (Array.isArray(whitelist) && whitelist.length > 0) {
|
|
23
|
+
return whitelist.some((w) => String(w).replace(/\/$/, '') === o);
|
|
24
|
+
}
|
|
25
|
+
// 默认策略:站点 origin + 本机开发源
|
|
26
|
+
const backend = String((config && config.backendUrl) || '').replace(/\/$/, '');
|
|
27
|
+
if (backend) {
|
|
28
|
+
try {
|
|
29
|
+
if (new URL(backend).origin === o) return true;
|
|
30
|
+
} catch {
|
|
31
|
+
/* ignore */
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (o.startsWith('http://localhost') || o.startsWith('http://127.0.0.1')) return true;
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = { isOriginTrusted };
|
package/src/pairing.js
CHANGED
|
@@ -93,7 +93,18 @@ function postJson(url, body) {
|
|
|
93
93
|
|
|
94
94
|
function rotateCode() {
|
|
95
95
|
current = { code: generateCode(), agentId: agentId(), createdAt: Date.now() };
|
|
96
|
-
|
|
96
|
+
const W = 46;
|
|
97
|
+
const bar = '═'.repeat(W);
|
|
98
|
+
const pad = (s) => ` ${String(s).padEnd(W - 2)}`;
|
|
99
|
+
console.log('');
|
|
100
|
+
console.log(' ' + bar);
|
|
101
|
+
console.log(pad('代码工作台 · 配对码'));
|
|
102
|
+
console.log(' ' + '─'.repeat(W));
|
|
103
|
+
console.log(pad(`🔑 配对码: ${current.code}`));
|
|
104
|
+
console.log(pad('有效期: 10 分钟 · 单次使用'));
|
|
105
|
+
console.log(pad('请在网站「设备管理」或「首次配置」中输入'));
|
|
106
|
+
console.log(' ' + bar);
|
|
107
|
+
console.log('');
|
|
97
108
|
logger.info(`[pairing] 生成新配对码 ${current.code}`);
|
|
98
109
|
report();
|
|
99
110
|
return current;
|