ysagc-agent 0.1.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 ADDED
@@ -0,0 +1,96 @@
1
+ # ysagc-agent
2
+
3
+ 代码工作台本机代理:替浏览器在本机执行**文件 / Git / 终端**操作。只处理工作台发来的指令,不偷传数据、不常驻收集信息。
4
+
5
+ - 仅监听 `127.0.0.1`(浏览器可信源),HTTPS 页面不拦截
6
+ - 通信:JSON-RPC 2.0 over WebSocket(控制通道)+ 二进制帧(终端通道)
7
+ - 路径沙箱:只能操作 allowlist(用户显式添加的项目根目录)内路径
8
+ - 终端:node-pty(Windows: PowerShell / macOS·Linux: 默认 shell),输入输出**不经过网站后端**
9
+
10
+ ## 安装(两种渠道任选)
11
+
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(开发者用户)
25
+
26
+ ```bash
27
+ # 免安装,直接跑
28
+ npx ysagc-agent
29
+
30
+ # 或全局安装
31
+ npm i -g ysagc-agent
32
+ ysagc-agent
33
+ ```
34
+
35
+ 要求 Node.js ≥ 20。
36
+
37
+ ## 使用流程
38
+
39
+ 1. 启动后终端会显示 **6 位配对码**(10 分钟有效)。
40
+ 2. 打开网站 → 设置 → 设备管理 → 输入配对码 → 配对成功。
41
+ 3. 在网站添加项目(本机文件夹 / 克隆仓库 / 从零创建),即可开始使用工作台。
42
+
43
+ ## 配置(agent.json)
44
+
45
+ 位置:`~/.ysagc-agent/agent.json`(Windows 为 `C:\Users\<你>\.ysagc-agent`)。非法 JSON 会自动备份恢复。
46
+
47
+ ```jsonc
48
+ {
49
+ "port": 37890, // 端口被占用自动 +1(最多 5 次)
50
+ "allowedRoots": [], // 授权项目根目录(网站添加项目时自动写入)
51
+ "deviceTokenWhitelist": [], // 已配对设备令牌(sha256)
52
+ "trashEnabled": true, // 删除进回收站
53
+ "trashRetentionDays": 7,
54
+ "enableTerminal": true, // 终端开关
55
+ "terminalMaxSessions": 3,
56
+ "terminalIdleTimeoutMin": 30,
57
+ "terminalPermission": "ask", // ask=每次询问 / allow=始终允许 / deny=拒绝
58
+ "backendUrl": "https://chat.ysagc.top",
59
+ "logLevel": "info"
60
+ }
61
+ ```
62
+
63
+ ## 常见问题
64
+
65
+ **Q:端口被占用怎么办?** 自动尝试 37891–37894;也可 `ysagc-agent --port <端口>` 指定。
66
+
67
+ **Q:macOS 提示"无法打开"?** 未签名应用:右键(或 Control+点击)→ 打开;或 系统设置 → 隐私与安全性 → 仍要打开。
68
+
69
+ **Q:连不上网站?** 确认 Agent 正在运行、配对码未过期;网站「设备管理」页有一键自检。
70
+
71
+ **Q:日志在哪?** `~/.ysagc-agent/logs/agent.log`。日志**永不记录**密钥文件内容与 Git 凭证。
72
+
73
+ **Q:Agent 会读取我电脑上的所有文件吗?** 不会。Agent 只能访问你在网站显式添加的项目目录(allowlist),越界操作一律拒绝并记录审计;终端除外(终端等同于你的命令行,可操作整台电脑——首次使用会在网页端弹安全确认)。
74
+
75
+ ## 开发
76
+
77
+ ```bash
78
+ npm ci
79
+ npm start # 启动
80
+ npm run smoke # 冒烟测试(WS 鉴权 / RPC / 终端全链路)
81
+ node scripts/smoke.js <token> <port> # 自定义参数
82
+ ```
83
+
84
+ 打包(单文件):
85
+
86
+ ```bash
87
+ npx @yao-pkg/pkg . --target node22-win-x64 --output dist/ysagc-agent.exe
88
+ # 三平台(Linux 环境可交叉产出):
89
+ npx @yao-pkg/pkg . --target node22-win-x64,node22-macos-x64,node22-linux-x64 --output dist/ysagc-agent
90
+ ```
91
+
92
+ CI:`.gitlab-ci.yml`(build 产物 + tag 触发发布到 Package Registry / npm)。
93
+
94
+ ## 隐私
95
+
96
+ Agent 与网站后端的交互仅限:配对码上报、版本握手、审计元数据。**项目文件内容、终端输入输出、Git 凭证一律不经过网站后端**。
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "ysagc-agent",
3
+ "version": "0.1.0",
4
+ "description": "代码工作台本机代理:替浏览器在本机执行文件/Git/终端操作(JSON-RPC over WebSocket,直连 127.0.0.1)",
5
+ "main": "src/index.js",
6
+ "bin": {
7
+ "ysagc-agent": "src/index.js"
8
+ },
9
+ "files": [
10
+ "src",
11
+ "README.md"
12
+ ],
13
+ "engines": {
14
+ "node": ">=20"
15
+ },
16
+ "scripts": {
17
+ "start": "node src/index.js",
18
+ "smoke": "node scripts/smoke.js"
19
+ },
20
+ "keywords": [
21
+ "workspace",
22
+ "agent",
23
+ "local-files",
24
+ "terminal"
25
+ ],
26
+ "license": "UNLICENSED",
27
+ "private": false,
28
+ "dependencies": {
29
+ "chokidar": "^4.0.3",
30
+ "iconv-lite": "^0.6.3",
31
+ "jschardet": "^3.1.4",
32
+ "node-pty": "^1.1.0",
33
+ "ws": "^8.18.0",
34
+ "yauzl": "^3.2.0",
35
+ "yazl": "^2.5.1"
36
+ }
37
+ }
package/src/config.js ADDED
@@ -0,0 +1,89 @@
1
+ /**
2
+ * ysagc-agent 配置加载(agent.json)
3
+ * 位置:~/.ysagc-agent/agent.json
4
+ * 非法 JSON 自动备份恢复(agent.json.bak-<时间戳>)
5
+ */
6
+ 'use strict';
7
+
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
11
+
12
+ const CONFIG_DIR = process.env.YSAGC_AGENT_HOME || path.join(os.homedir(), '.ysagc-agent');
13
+ const CONFIG_PATH = path.join(CONFIG_DIR, 'agent.json');
14
+
15
+ const DEFAULTS = {
16
+ port: 37890,
17
+ allowedRoots: [],
18
+ deviceTokenWhitelist: [],
19
+ trashEnabled: true,
20
+ trashRetentionDays: 7,
21
+ autoApprovePaths: true,
22
+ enableTerminal: true,
23
+ terminalMaxSessions: 3,
24
+ terminalIdleTimeoutMin: 30,
25
+ backendUrl: 'https://chat.ysagc.top',
26
+ logLevel: 'info',
27
+ // 终端权限:ask(每次询问)/ allow(始终允许)/ deny(拒绝)
28
+ terminalPermission: 'ask',
29
+ // 协议版本(与后端握手比对)
30
+ protocolVersion: 1,
31
+ agentVersion: null,
32
+ };
33
+
34
+ function ensureDir() {
35
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
36
+ }
37
+
38
+ function backupCorruptConfig() {
39
+ if (!fs.existsSync(CONFIG_PATH)) return;
40
+ const bak = path.join(CONFIG_DIR, `agent.json.bak-${Date.now()}`);
41
+ try {
42
+ fs.copyFileSync(CONFIG_PATH, bak);
43
+ console.error(`[config] agent.json 解析失败,已备份到 ${bak},恢复默认配置`);
44
+ } catch {
45
+ /* 备份失败不阻塞 */
46
+ }
47
+ }
48
+
49
+ function loadConfig() {
50
+ ensureDir();
51
+ let config = {};
52
+ if (fs.existsSync(CONFIG_PATH)) {
53
+ try {
54
+ const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
55
+ const parsed = JSON.parse(raw);
56
+ if (parsed && typeof parsed === 'object') config = parsed;
57
+ else backupCorruptConfig();
58
+ } catch {
59
+ backupCorruptConfig();
60
+ }
61
+ }
62
+ const merged = { ...DEFAULTS, ...config };
63
+ // 写入规范化后的配置(含缺失字段默认值),后续更新配置也走 saveConfig
64
+ saveConfig(merged, { silent: true });
65
+ return merged;
66
+ }
67
+
68
+ function saveConfig(patch, opts = {}) {
69
+ ensureDir();
70
+ const current = loadConfigRaw();
71
+ const next = { ...DEFAULTS, ...current, ...patch };
72
+ const tmp = CONFIG_PATH + '.tmp';
73
+ fs.writeFileSync(tmp, JSON.stringify(next, null, 2));
74
+ fs.renameSync(tmp, CONFIG_PATH); // 原子替换,防损坏
75
+ if (!opts.silent) console.log(`[config] 配置已保存: ${CONFIG_PATH}`);
76
+ return next;
77
+ }
78
+
79
+ function loadConfigRaw() {
80
+ try {
81
+ if (!fs.existsSync(CONFIG_PATH)) return {};
82
+ const parsed = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
83
+ return parsed && typeof parsed === 'object' ? parsed : {};
84
+ } catch {
85
+ return {};
86
+ }
87
+ }
88
+
89
+ module.exports = { CONFIG_DIR, CONFIG_PATH, DEFAULTS, loadConfig, saveConfig };
package/src/http-fs.js ADDED
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Agent 本地 HTTP:上传/下载(T-M1-05)
3
+ * - POST /api/fs/upload?token=&path=&overwrite=1 分片上传(X-Offset / X-Total 头)
4
+ * - GET /api/fs/download?token=&path= 单文件下载(流式)
5
+ * - GET /api/fs/zip?token=&paths=a,b,c zip 打包下载(yazl 流式)
6
+ * - 鉴权:deviceToken sha256 与白名单比对;路径经沙箱校验(allowlist)
7
+ * - 分片:临时文件 append,达到 X-Total 后原子 rename;覆盖需 overwrite=1(前端先 409 确认)
8
+ */
9
+ 'use strict';
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const crypto = require('crypto');
14
+ const url = require('url');
15
+
16
+ const MAX_UPLOAD_SIZE = 512 * 1024 * 1024; // 512MB
17
+ const MAX_ZIP_FILES = 2000;
18
+
19
+ function sha256(s) {
20
+ return crypto.createHash('sha256').update(String(s)).digest('hex');
21
+ }
22
+
23
+ function createHttpFsHandlers(ctxRef) {
24
+ const sandbox = () => ctxRef().sandbox;
25
+ const cfg = () => ctxRef().config;
26
+
27
+ function checkToken(token) {
28
+ if (!token) return false;
29
+ return (cfg().deviceTokenWhitelist || []).includes(sha256(token));
30
+ }
31
+
32
+ function sendJson(res, status, obj) {
33
+ res.writeHead(status, { 'Content-Type': 'application/json' });
34
+ res.end(JSON.stringify(obj));
35
+ }
36
+
37
+ /** 上传:分片追加到临时文件,最后一片 rename 落盘 */
38
+ function handleUpload(req, res, query) {
39
+ const token = query.token;
40
+ if (!checkToken(token)) return sendJson(res, 401, { code: 'E_AUTH_FAILED', msg: '未授权' });
41
+ const relPath = String(query.path || '');
42
+ if (!relPath) return sendJson(res, 400, { code: 'E_VALIDATION', msg: '缺少 path' });
43
+ let r;
44
+ try {
45
+ r = sandbox().resolve(relPath, '');
46
+ } catch (e) {
47
+ return sendJson(res, 403, { code: 'E_PATH_ESCAPE', msg: e.message || '路径越界' });
48
+ }
49
+ if (r.rel === '' || r.rel === '.') return sendJson(res, 400, { code: 'E_VALIDATION', msg: '不能上传到项目根' });
50
+
51
+ const offset = Math.max(0, Number(req.headers['x-offset']) || 0);
52
+ const total = Math.max(0, Number(req.headers['x-total']) || 0);
53
+ if (total > MAX_UPLOAD_SIZE) return sendJson(res, 413, { code: 'E_FILE_TOO_LARGE', msg: '文件超过 512MB 限制' });
54
+ const overwrite = String(query.overwrite || '') === '1';
55
+
56
+ // 目标已存在 → 409 需确认
57
+ if (offset === 0 && !overwrite && fs.existsSync(r.abs)) {
58
+ return sendJson(res, 409, { code: 'E_PATH_EXISTS', msg: '目标文件已存在,请确认覆盖', needConfirm: true });
59
+ }
60
+
61
+ const tmpPath = path.join(r.root, `.upload-tmp-${sha256(relPath).slice(0, 16)}`);
62
+ try {
63
+ // 首片:若需覆盖且临时文件残留则清理
64
+ if (offset === 0 && fs.existsSync(tmpPath)) fs.rmSync(tmpPath);
65
+ const fd = fs.openSync(tmpPath, offset === 0 ? 'w' : 'a');
66
+ const chunks = [];
67
+ req.on('data', (c) => chunks.push(c));
68
+ req.on('end', () => {
69
+ const buf = Buffer.concat(chunks);
70
+ fs.writeSync(fd, buf, 0, buf.length, offset);
71
+ fs.closeSync(fd);
72
+ const curSize = offset + buf.length;
73
+ if (total > 0 && curSize >= total) {
74
+ // 完成:原子 rename
75
+ fs.renameSync(tmpPath, r.abs);
76
+ return sendJson(res, 200, { code: 0, data: { ok: true, size: curSize, relPath: r.rel }, msg: 'success' });
77
+ }
78
+ return sendJson(res, 200, { code: 0, data: { ok: true, received: curSize, total }, msg: 'chunk' });
79
+ });
80
+ req.on('error', () => {
81
+ try { fs.closeSync(fd); } catch { /* ignore */ }
82
+ sendJson(res, 500, { code: 'E_INTERNAL', msg: '上传中断' });
83
+ });
84
+ } catch (e) {
85
+ return sendJson(res, 500, { code: 'E_INTERNAL', msg: e.message });
86
+ }
87
+ }
88
+
89
+ /** 单文件下载(流式) */
90
+ function handleDownload(req, res, query) {
91
+ const token = query.token;
92
+ if (!checkToken(token)) return sendJson(res, 401, { code: 'E_AUTH_FAILED', msg: '未授权' });
93
+ const relPath = String(query.path || '');
94
+ let r;
95
+ try {
96
+ r = sandbox().resolve(relPath, '');
97
+ } catch (e) {
98
+ return sendJson(res, 403, { code: 'E_PATH_ESCAPE', msg: e.message || '路径越界' });
99
+ }
100
+ if (!fs.existsSync(r.abs) || !fs.statSync(r.abs).isFile()) {
101
+ return sendJson(res, 404, { code: 'E_PATH_NOT_FOUND', msg: '文件不存在' });
102
+ }
103
+ res.writeHead(200, {
104
+ 'Content-Type': 'application/octet-stream',
105
+ 'Content-Disposition': `attachment; filename="${encodeURIComponent(path.basename(r.abs))}"`,
106
+ 'Content-Length': fs.statSync(r.abs).size,
107
+ });
108
+ fs.createReadStream(r.abs).pipe(res);
109
+ }
110
+
111
+ /** zip 打包下载(yazl 流式) */
112
+ function handleZip(req, res, query) {
113
+ const token = query.token;
114
+ if (!checkToken(token)) return sendJson(res, 401, { code: 'E_AUTH_FAILED', msg: '未授权' });
115
+ const paths = String(query.paths || '')
116
+ .split(',')
117
+ .map((p) => p.trim())
118
+ .filter(Boolean);
119
+ if (paths.length === 0 || paths.length > MAX_ZIP_FILES) {
120
+ return sendJson(res, 400, { code: 'E_VALIDATION', msg: 'paths 数量需在 1-2000 之间' });
121
+ }
122
+ const yazl = require('yazl');
123
+ const zipfile = new yazl.ZipFile();
124
+ let added = 0;
125
+ for (const rel of paths) {
126
+ let r;
127
+ try {
128
+ r = sandbox().resolve(rel, '');
129
+ } catch {
130
+ return sendJson(res, 403, { code: 'E_PATH_ESCAPE', msg: '路径越界: ' + rel });
131
+ }
132
+ if (!fs.existsSync(r.abs)) continue;
133
+ const st = fs.statSync(r.abs);
134
+ if (st.isDirectory()) {
135
+ // 递归目录
136
+ const walk = (dir, relBase) => {
137
+ let entries;
138
+ try {
139
+ entries = fs.readdirSync(dir, { withFileTypes: true });
140
+ } catch {
141
+ return;
142
+ }
143
+ for (const e of entries) {
144
+ const abs = path.join(dir, e.name);
145
+ const relP = relBase ? `${relBase}/${e.name}` : e.name;
146
+ if (e.isDirectory()) walk(abs, relP);
147
+ else if (e.isFile()) {
148
+ zipfile.addFile(abs, relP);
149
+ added++;
150
+ }
151
+ }
152
+ };
153
+ walk(r.abs, path.basename(r.abs));
154
+ } else if (st.isFile()) {
155
+ zipfile.addFile(r.abs, path.basename(r.abs));
156
+ added++;
157
+ }
158
+ }
159
+ if (added === 0) return sendJson(res, 404, { code: 'E_PATH_NOT_FOUND', msg: '没有可打包的文件' });
160
+ res.writeHead(200, {
161
+ 'Content-Type': 'application/zip',
162
+ 'Content-Disposition': `attachment; filename="workspace-${Date.now()}.zip"`,
163
+ });
164
+ zipfile.outputStream.pipe(res);
165
+ zipfile.end();
166
+ }
167
+
168
+ return { handleUpload, handleDownload, handleZip };
169
+ }
170
+
171
+ module.exports = { createHttpFsHandlers };