ysagc-agent 0.2.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
@@ -7,6 +7,7 @@
7
7
  - 路径沙箱:只能操作 allowlist(用户显式添加的项目根目录)内路径
8
8
  - 终端:node-pty(Windows: PowerShell / macOS·Linux: 默认 shell),输入输出**不经过网站后端**
9
9
  - **免配对码**(v0.2.0):浏览器打开网站「工作台」自动识别并连接本机代理,无需输入配对码、无需安装 exe
10
+ - **目录树浏览 + 技能链接安装**(v0.3.0):新增 `fs.browse`(网页内逐级选文件夹)与 `skill.installFromUrl`(从链接下载安装技能)
10
11
 
11
12
  ## 安装(npm 方式,全球通用)
12
13
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ysagc-agent",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "代码工作台本机代理:替浏览器在本机执行文件/Git/终端操作(JSON-RPC over WebSocket,直连 127.0.0.1,免配对码)",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/index.js CHANGED
@@ -29,6 +29,7 @@ const { createSkillMethods } = require('./methods/skill');
29
29
  const { createGitMethods } = require('./methods/git');
30
30
  const { createScaffoldMethods } = require('./methods/scaffold');
31
31
  const { createDialogMethods } = require('./methods/dialog');
32
+ const { createBrowseMethods } = require('./methods/browse');
32
33
  const { createHttpFsHandlers } = require('./http-fs');
33
34
  const { Sandbox } = require('./sandbox');
34
35
  const { startPairing, stopPairing, agentId } = require('./pairing');
@@ -104,6 +105,8 @@ const scaffoldMethods = createScaffoldMethods(() => configRef);
104
105
  scaffoldMethods.register(register);
105
106
  const dialogMethods = createDialogMethods();
106
107
  dialogMethods.register(register);
108
+ const browseMethods = createBrowseMethods();
109
+ browseMethods.register(register);
107
110
 
108
111
  // ---------------- WS 服务 ----------------
109
112
  const state = {
@@ -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 };
@@ -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 || '');