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 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
- 要求 Node.js 20。
25
+ 启动后终端会显示 `ysagc-agent 运行中: ws://127.0.0.1:37890`,保持该终端窗口开启即可(最小化没问题,不要关闭)。
36
26
 
37
27
  ## 使用流程
38
28
 
39
- 1. 启动后终端会显示 **6 位配对码**(10 分钟有效)。
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": [], // 已配对设备令牌(sha256
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:macOS 提示"无法打开"?** 未签名应用:右键(或 Control+点击)→ 打开;或 系统设置 隐私与安全性 → 仍要打开。
60
+ **Q:网页提示"未检测到本机代理"?** 确认 Agent 已在终端运行(能看到 `运行中: ws://127.0.0.1:37890`);首次 `npx` 会先下载依赖,稍等片刻。
68
61
 
69
- **Q:连不上网站?** 确认 Agent 正在运行、配对码未过期;网站「设备管理」页有一键自检。
62
+ **Q:macOS 提示"无法打开"?** npm 方式无需处理;仅安装包渠道需要右键 → 打开。
70
63
 
71
64
  **Q:日志在哪?** `~/.ysagc-agent/logs/agent.log`。日志**永不记录**密钥文件内容与 Git 凭证。
72
65
 
73
- **Q:Agent 会读取我电脑上的所有文件吗?** 不会。Agent 只能访问你在网站显式添加的项目目录(allowlist),越界操作一律拒绝并记录审计;终端除外(终端等同于你的命令行,可操作整台电脑——首次使用会在网页端弹安全确认)。
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 与网站后端的交互仅限:配对码上报、版本握手、审计元数据。**项目文件内容、终端输入输出、Git 凭证一律不经过网站后端**。
91
+ Agent 与网站后端的交互仅限:设备自动注册、版本握手、审计元数据。**项目文件内容、终端输入输出、Git 凭证一律不经过网站后端**。
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ysagc-agent",
3
- "version": "0.1.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: 1,
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 ok = checkAuthToken(msg.token);
171
- if (!ok) {
172
- logger.warn(`[ws] 鉴权失败:token 不在白名单(remote=${req.socket.remoteAddress})`);
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${require('../package.json').version} 启动中(配置目录: ${CONFIG_DIR})`);
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 };
@@ -48,7 +48,10 @@ function saveState(state) {
48
48
 
49
49
  /** 解析 SKILL.md:frontmatter + 正文 */
50
50
  function parseSkillDoc(content) {
51
- const m = String(content || '').match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
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
- console.log(`\n🔑 配对码: ${current.code}(10 分钟内有效,请在网站“设备管理”输入)\n`);
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;