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/src/pairing.js ADDED
@@ -0,0 +1,157 @@
1
+ /**
2
+ * 配对流程(Agent 侧,T-M0-12)
3
+ * - 生成 6 位一次性配对码(10 分钟过期)
4
+ * - 定时上报后端 POST {backendUrl}/api/agent/pairing(预共享密钥签名,后端 T-M0-04 实现)
5
+ * - 收到 pairing.handshake → 签发 256 位 deviceToken → 写入本地白名单 + 持久化
6
+ * 后端未就绪/不可达时:码仍在本机显示(console),不阻塞使用
7
+ */
8
+ 'use strict';
9
+
10
+ const crypto = require('crypto');
11
+ const https = require('https');
12
+ const http = require('http');
13
+ const logger = require('./logger');
14
+ const { saveConfig } = require('./config');
15
+
16
+ const CODE_TTL_MS = 10 * 60 * 1000;
17
+ const REPORT_INTERVAL_MS = 60 * 1000;
18
+ const PRE_SHARED_SECRET = process.env.YSAGC_AGENT_PAIR_SECRET || 'ysagc-agent-pairing-v1';
19
+
20
+ let current = null; // { code, agentId, createdAt }
21
+ let reportTimer = null;
22
+ let configRef = null;
23
+
24
+ function generateCode() {
25
+ // 排除易混淆字符
26
+ const alphabet = '23456789';
27
+ let code = '';
28
+ const bytes = crypto.randomBytes(6);
29
+ for (let i = 0; i < 6; i++) code += alphabet[bytes[i] % alphabet.length];
30
+ return code;
31
+ }
32
+
33
+ function agentId() {
34
+ // 稳定 agentId:存于配置目录,无则生成
35
+ const { CONFIG_DIR } = require('./config');
36
+ const fs = require('fs');
37
+ const path = require('path');
38
+ const p = path.join(CONFIG_DIR, 'agent-id');
39
+ try {
40
+ if (fs.existsSync(p)) return fs.readFileSync(p, 'utf8').trim();
41
+ } catch {
42
+ /* ignore */
43
+ }
44
+ const id = crypto.randomUUID();
45
+ try {
46
+ fs.writeFileSync(p, id);
47
+ } catch {
48
+ /* ignore */
49
+ }
50
+ return id;
51
+ }
52
+
53
+ function sign(body) {
54
+ return crypto.createHmac('sha256', PRE_SHARED_SECRET).update(JSON.stringify(body)).digest('hex');
55
+ }
56
+
57
+ function postJson(url, body) {
58
+ return new Promise((resolve) => {
59
+ const data = JSON.stringify(body);
60
+ const mod = url.startsWith('https') ? https : http;
61
+ const u = new URL(url);
62
+ const req = mod.request(
63
+ {
64
+ hostname: u.hostname,
65
+ port: u.port || (u.protocol === 'https:' ? 443 : 80),
66
+ path: u.pathname + u.search,
67
+ method: 'POST',
68
+ headers: {
69
+ 'Content-Type': 'application/json',
70
+ 'Content-Length': Buffer.byteLength(data),
71
+ 'x-agent-signature': sign(body),
72
+ },
73
+ timeout: 8000,
74
+ },
75
+ (res) => {
76
+ let buf = '';
77
+ res.on('data', (c) => (buf += c));
78
+ res.on('end', () => {
79
+ try {
80
+ resolve({ status: res.statusCode, body: JSON.parse(buf || '{}') });
81
+ } catch {
82
+ resolve({ status: res.statusCode, body: {} });
83
+ }
84
+ });
85
+ }
86
+ );
87
+ req.on('timeout', () => req.destroy());
88
+ req.on('error', (e) => resolve({ status: 0, error: e.message }));
89
+ req.write(data);
90
+ req.end();
91
+ });
92
+ }
93
+
94
+ function rotateCode() {
95
+ current = { code: generateCode(), agentId: agentId(), createdAt: Date.now() };
96
+ console.log(`\n🔑 配对码: ${current.code}(10 分钟内有效,请在网站“设备管理”输入)\n`);
97
+ logger.info(`[pairing] 生成新配对码 ${current.code}`);
98
+ report();
99
+ return current;
100
+ }
101
+
102
+ async function report() {
103
+ const cfg = configRef ? configRef() : {};
104
+ const url = (cfg.backendUrl || '').replace(/\/$/, '') + '/api/agent/pairing';
105
+ if (!cfg.backendUrl) return;
106
+ try {
107
+ const res = await postJson(url, {
108
+ code: current.code,
109
+ agentId: current.agentId,
110
+ meta: { platform: `${process.platform} ${process.arch}`, hostname: require('os').hostname() },
111
+ });
112
+ if (res.status === 200) logger.info(`[pairing] 配对码已上报后端 ${url}`);
113
+ else logger.debug(`[pairing] 上报后端返回 ${res.status}`);
114
+ } catch (e) {
115
+ logger.debug(`[pairing] 上报后端失败: ${e.message}`);
116
+ }
117
+ }
118
+
119
+ function startPairing(getConfig) {
120
+ configRef = getConfig;
121
+ rotateCode();
122
+ reportTimer = setInterval(() => {
123
+ if (!current || Date.now() - current.createdAt > CODE_TTL_MS) rotateCode();
124
+ else report();
125
+ }, REPORT_INTERVAL_MS);
126
+ reportTimer.unref && reportTimer.unref();
127
+ // 注册 RPC:pairing.handshake(网站配对成功后由前端直连触发)
128
+ const { register } = require('./rpc');
129
+ register('pairing.handshake', async (params = {}) => {
130
+ const { code } = params;
131
+ if (!current || code !== current.code) {
132
+ throw new (require('./rpc').RpcError)('E_PAIRING_CODE_INVALID', '配对码无效或已过期');
133
+ }
134
+ if (Date.now() - current.createdAt > CODE_TTL_MS) {
135
+ throw new (require('./rpc').RpcError)('E_PAIRING_CODE_EXPIRED', '配对码已过期,请重新获取');
136
+ }
137
+ // 签发 deviceToken(256 位)
138
+ const deviceToken = crypto.randomBytes(32).toString('hex');
139
+ const hash = crypto.createHash('sha256').update(deviceToken).digest('hex');
140
+ const cfg = configRef();
141
+ if (!cfg.deviceTokenWhitelist.includes(hash)) {
142
+ // 1) 内存同步(auth 校验读内存配置)2) 持久化到 agent.json
143
+ cfg.deviceTokenWhitelist = [...cfg.deviceTokenWhitelist, hash];
144
+ saveConfig({ deviceTokenWhitelist: cfg.deviceTokenWhitelist });
145
+ }
146
+ current = null; // 单次使用
147
+ logger.info('[pairing] 握手成功,已签发 deviceToken 并加入白名单');
148
+ return { deviceToken, agentId: agentId(), agentVersion: require('../package.json').version, protocolVersion: 1 };
149
+ });
150
+ }
151
+
152
+ function stopPairing() {
153
+ if (reportTimer) clearInterval(reportTimer);
154
+ reportTimer = null;
155
+ }
156
+
157
+ module.exports = { startPairing, stopPairing, generateCode, agentId };
package/src/rpc.js ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * JSON-RPC 2.0 分发框架(控制通道)
3
+ * - 请求:{jsonrpc:"2.0", id, method, params}
4
+ * - 响应:{jsonrpc:"2.0", id, result} | {jsonrpc:"2.0", id, error:{code, message, data:{code}}}
5
+ * - 事件:{jsonrpc:"2.0", method:"event.xxx", params}(服务端主动推送)
6
+ * 错误码见需求 7.4:data.code 为业务错误码(E_*)
7
+ */
8
+ 'use strict';
9
+
10
+ const methods = new Map();
11
+
12
+ /** 注册 RPC 方法 */
13
+ function register(name, handler) {
14
+ if (methods.has(name)) throw new Error(`duplicate rpc method: ${name}`);
15
+ methods.set(name, handler);
16
+ }
17
+
18
+ function has(name) {
19
+ return methods.has(name);
20
+ }
21
+
22
+ /**
23
+ * 业务错误:throw new RpcError('E_PATH_ESCAPE', '越界路径', -32603)
24
+ */
25
+ class RpcError extends Error {
26
+ constructor(code, message, rpcCode = -32603) {
27
+ super(message);
28
+ this.name = 'RpcError';
29
+ this.code = code; // E_* 业务错误码
30
+ this.rpcCode = rpcCode;
31
+ }
32
+ }
33
+
34
+ /**
35
+ * 处理一条 JSON-RPC 请求
36
+ * @param {object} msg 已 JSON.parse 的帧
37
+ * @param {object} ctx { socket, ws, deviceId, send, sendBinary }
38
+ * @returns {Promise<object|null>} 响应对象(null 表示无需响应)
39
+ */
40
+ async function handleRequest(msg, ctx) {
41
+ const { id, method, params } = msg;
42
+ if (!method || typeof method !== 'string') {
43
+ return { jsonrpc: '2.0', id: id ?? null, error: { code: -32600, message: 'Invalid Request', data: { code: 'E_INVALID_REQUEST' } } };
44
+ }
45
+ if (!methods.has(method)) {
46
+ return { jsonrpc: '2.0', id: id ?? null, error: { code: -32601, message: `Method not found: ${method}`, data: { code: 'E_METHOD_NOT_FOUND' } } };
47
+ }
48
+ try {
49
+ const result = await methods.get(method)(params || {}, ctx);
50
+ // 通知类(id 为 null/undefined)无响应
51
+ if (id === null || id === undefined) return null;
52
+ return { jsonrpc: '2.0', id, result: result === undefined ? null : result };
53
+ } catch (e) {
54
+ const rpcCode = e && e.rpcCode ? e.rpcCode : -32603;
55
+ const code = e && e.code ? e.code : 'E_INTERNAL';
56
+ const message = e && e.message ? e.message : 'Internal error';
57
+ if (id === null || id === undefined) return null;
58
+ return { jsonrpc: '2.0', id, error: { code: rpcCode, message, data: { code } } };
59
+ }
60
+ }
61
+
62
+ module.exports = { register, has, handleRequest, RpcError };
package/src/sandbox.js ADDED
@@ -0,0 +1,178 @@
1
+ /**
2
+ * 路径沙箱(T-M0-13)
3
+ * - allowlist(用户显式添加的项目根目录)内才能操作
4
+ * - realpath 校验,符号链接逃逸一律拒绝
5
+ * - .git 内部路径拒绝(文件操作层面;Git 操作走 git.* 方法)
6
+ * - 越界一律记审计日志(本地 + 上报后端由后端接口负责)
7
+ * 原则:前端不拼路径,一切路径由本模块校验后再执行。
8
+ */
9
+ 'use strict';
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const { RpcError } = require('./rpc');
14
+ const logger = require('./logger');
15
+
16
+ function normalizeRoot(root) {
17
+ if (typeof root !== 'string' || !root.trim()) return null;
18
+ const abs = path.isAbsolute(root) ? path.normalize(root) : path.resolve(root);
19
+ try {
20
+ const real = fs.realpathSync(abs);
21
+ return real;
22
+ } catch {
23
+ return abs;
24
+ }
25
+ }
26
+
27
+ class Sandbox {
28
+ constructor(roots = []) {
29
+ this.roots = new Set();
30
+ for (const r of roots) {
31
+ const n = normalizeRoot(r);
32
+ if (n) this.roots.add(n);
33
+ }
34
+ }
35
+
36
+ listRoots() {
37
+ return [...this.roots];
38
+ }
39
+
40
+ addRoot(root, opts = {}) {
41
+ const n = normalizeRoot(root);
42
+ if (!n) throw new RpcError('E_INVALID_PATH', '无效的项目目录');
43
+ if (!opts.force && !fs.existsSync(n)) {
44
+ throw new RpcError('E_PATH_NOT_FOUND', `目录不存在: ${root}`);
45
+ }
46
+ if (!fs.statSync(n).isDirectory()) {
47
+ throw new RpcError('E_PATH_NOT_DIR', `不是目录: ${root}`);
48
+ }
49
+ // 防过宽路径
50
+ const blocked = ['C:\\', 'C:/', '/', path.parse(n).root];
51
+ if (blocked.includes(n.replace(/[\\/]+$/, ''))) {
52
+ throw new RpcError('E_PATH_TOO_WIDE', '不允许授权过宽的路径(磁盘根目录等)');
53
+ }
54
+ // 禁止与已有项目互为父子目录
55
+ for (const r of this.roots) {
56
+ if (r === n || r.startsWith(n + path.sep) || n.startsWith(r + path.sep)) {
57
+ throw new RpcError('E_PATH_OVERLAP', '该目录与已添加的项目互为父子目录,不允许');
58
+ }
59
+ }
60
+ this.roots.add(n);
61
+ logger.info(`[sandbox] 已授权项目目录: ${n}`);
62
+ return n;
63
+ }
64
+
65
+ removeRoot(root) {
66
+ const n = normalizeRoot(root);
67
+ if (n && this.roots.has(n)) {
68
+ this.roots.delete(n);
69
+ logger.info(`[sandbox] 已移除项目目录授权: ${n}`);
70
+ return true;
71
+ }
72
+ return false;
73
+ }
74
+
75
+ hasRoots() {
76
+ return this.roots.size > 0;
77
+ }
78
+
79
+ auditEscape(target, reason) {
80
+ logger.warn(`[sandbox][越界拒绝] target=${target} reason=${reason}`);
81
+ // 审计记录透传给后端(workspace_audit_logs),由后端接口负责写入
82
+ return { opType: 'fs.access', target: String(target).slice(0, 500), result: 'denied', errorCode: 'E_PATH_ESCAPE', reason };
83
+ }
84
+
85
+ /**
86
+ * 解析并校验目标路径
87
+ * @param {string} target 绝对路径或相对路径
88
+ * @param {string} [base] 相对路径基准(项目根)
89
+ * @param {{allowMissing?:boolean}} [opts]
90
+ * @returns {{abs:string, root:string, rel:string}}
91
+ */
92
+ resolve(target, base, opts = {}) {
93
+ if (typeof target !== 'string') target = '';
94
+ // 空路径 = 项目根目录(fs.list 根 / trash 根等场景)
95
+ if (!target.trim() && (!base || !String(base).trim())) {
96
+ const first = [...this.roots][0];
97
+ if (!first) throw new RpcError('E_INVALID_PATH', '路径为空');
98
+ target = first;
99
+ }
100
+ // 相对路径:以 base 为基准;未传 base 时以已授权项目根为基准(前端不拼路径,只传相对路径)
101
+ let abs;
102
+ if (path.isAbsolute(target)) {
103
+ abs = path.normalize(target);
104
+ } else if (base && String(base).trim()) {
105
+ abs = path.resolve(base, target);
106
+ } else {
107
+ const roots = [...this.roots];
108
+ if (roots.length === 1) {
109
+ abs = path.join(roots[0], target);
110
+ } else if (roots.length > 1) {
111
+ const hit = roots.find((r) => fs.existsSync(path.join(r, target)));
112
+ if (hit) abs = path.join(hit, target);
113
+ else abs = path.join(roots[0], target);
114
+ } else {
115
+ abs = path.resolve(process.cwd(), target);
116
+ }
117
+ }
118
+
119
+ // 1. 词法检查(快速拒绝越界)
120
+ const lexicalRoot = [...this.roots].find((r) => abs === r || abs.startsWith(r + path.sep));
121
+ if (!lexicalRoot) {
122
+ this.auditEscape(target, 'lexical escape');
123
+ throw new RpcError('E_PATH_ESCAPE', `路径越界(不在任何已授权项目目录内): ${target}`);
124
+ }
125
+
126
+ // 2. realpath 校验(防符号链接逃逸)
127
+ let real;
128
+ try {
129
+ real = fs.realpathSync(abs);
130
+ } catch {
131
+ real = null;
132
+ }
133
+ if (real) {
134
+ const realRoot = [...this.roots].find((r) => real === r || real.startsWith(r + path.sep));
135
+ if (!realRoot) {
136
+ this.auditEscape(target, 'symlink escape');
137
+ throw new RpcError('E_PATH_ESCAPE', '路径经符号链接指向项目目录之外,已拒绝');
138
+ }
139
+ return this.finalize(real, realRoot);
140
+ }
141
+
142
+ // 3. 目标不存在(新建场景):最近的已存在父目录必须落在沙箱内
143
+ let parent = path.dirname(abs);
144
+ for (let i = 0; i < 64; i++) {
145
+ let rp;
146
+ try {
147
+ rp = fs.realpathSync(parent);
148
+ } catch {
149
+ rp = null;
150
+ }
151
+ if (rp) {
152
+ const okRoot = [...this.roots].find((r) => rp === r || rp.startsWith(r + path.sep));
153
+ if (!okRoot) {
154
+ this.auditEscape(target, 'parent symlink escape');
155
+ throw new RpcError('E_PATH_ESCAPE', '路径的父目录经符号链接指向项目目录之外,已拒绝');
156
+ }
157
+ return this.finalize(abs, okRoot);
158
+ }
159
+ const next = path.dirname(parent);
160
+ if (next === parent) break;
161
+ parent = next;
162
+ }
163
+ this.auditEscape(target, 'unresolvable');
164
+ throw new RpcError('E_PATH_ESCAPE', '路径无法解析到任何已授权项目目录');
165
+ }
166
+
167
+ finalize(abs, root) {
168
+ const rel = path.relative(root, abs);
169
+ // .git 内部拒绝(Git 操作走 git.*,不允许文件层直改 .git)
170
+ if (rel === '.git' || rel.startsWith('.git' + path.sep) || rel.split(path.sep).includes('.git')) {
171
+ this.auditEscape(abs, 'git internal');
172
+ throw new RpcError('E_PATH_GIT_INTERNAL', '禁止直接操作 .git 内部文件,请使用 Git 面板');
173
+ }
174
+ return { abs, root, rel };
175
+ }
176
+ }
177
+
178
+ module.exports = { Sandbox, normalizeRoot };