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/index.js ADDED
@@ -0,0 +1,378 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ysagc-agent 入口
4
+ * - WS 服务仅绑定 127.0.0.1(浏览器可信源)
5
+ * - 端口占用自动 +1(最多 5 次)
6
+ * - 控制通道 JSON-RPC 2.0;终端二进制分帧
7
+ * - 鉴权握手 3s 超时;心跳 15s、3 次无响应断开
8
+ * - 同设备最多 2 个控制连接(T-M0-15)
9
+ * 用法:npx ysagc-agent [--init-token <token>] [--port N]
10
+ */
11
+ 'use strict';
12
+
13
+ const http = require('http');
14
+ const crypto = require('crypto');
15
+ const url = require('url');
16
+ const { WebSocketServer } = require('ws');
17
+ const path = require('path');
18
+ const fs = require('fs');
19
+
20
+ const { loadConfig, saveConfig, CONFIG_DIR } = require('./config');
21
+ const logger = require('./logger');
22
+ const { register, handleRequest } = require('./rpc');
23
+ const { registerSystemMethods } = require('./methods/system');
24
+ const { createTerminalMethods } = require('./methods/terminal');
25
+ const { createProjectMethods } = require('./methods/project');
26
+ const { createFsMethods } = require('./methods/fs');
27
+ const { createSkillMethods } = require('./methods/skill');
28
+ const { createGitMethods } = require('./methods/git');
29
+ const { createScaffoldMethods } = require('./methods/scaffold');
30
+ const { createHttpFsHandlers } = require('./http-fs');
31
+ const { Sandbox } = require('./sandbox');
32
+ const { startPairing, stopPairing } = require('./pairing');
33
+
34
+ const DEFAULT_PORT = 37890;
35
+ const MAX_PORT_TRIES = 5;
36
+ const AUTH_TIMEOUT_MS = 3000;
37
+ const HEARTBEAT_INTERVAL_MS = 15000;
38
+ const HEARTBEAT_MISS_LIMIT = 3;
39
+ const MAX_CONNS_PER_DEVICE = 2;
40
+ const RPC_RATE_LIMIT = 60; // RPC ≤ 60/min/设备(5.20.3)
41
+ const RPC_RATE_WINDOW = 60000;
42
+
43
+ // ---------------- CLI ----------------
44
+ const argv = process.argv.slice(2);
45
+ function getArg(name) {
46
+ const i = argv.indexOf(name);
47
+ return i >= 0 ? argv[i + 1] : null;
48
+ }
49
+
50
+ // ---------------- 配置 ----------------
51
+ let config = loadConfig();
52
+ logger.setLevel(config.logLevel);
53
+
54
+ const initToken = getArg('--init-token');
55
+ if (initToken) {
56
+ const hash = crypto.createHash('sha256').update(String(initToken)).digest('hex');
57
+ if (!config.deviceTokenWhitelist.includes(hash)) {
58
+ config = saveConfig({ deviceTokenWhitelist: [...config.deviceTokenWhitelist, hash] });
59
+ logger.info(`[init-token] 已加入设备令牌白名单(sha256: ${hash.slice(0, 16)}...)`);
60
+ }
61
+ }
62
+ if (getArg('--port')) {
63
+ config = saveConfig({ port: Number(getArg('--port')) || DEFAULT_PORT });
64
+ }
65
+ const configRef = { config }; // 供方法层读取最新配置
66
+ const sandbox = new Sandbox(config.allowedRoots || []);
67
+ configRef.sandbox = sandbox;
68
+
69
+ // ---------------- RPC 注册 ----------------
70
+ registerSystemMethods(register, () => configRef);
71
+ const terminal = createTerminalMethods(() => configRef);
72
+ terminal.register(register);
73
+ const projectMethods = createProjectMethods(() => configRef);
74
+ projectMethods.register(register);
75
+ const fsMethods = createFsMethods(() => configRef);
76
+ fsMethods.register(register);
77
+ const skillMethods = createSkillMethods(() => configRef);
78
+ skillMethods.register(register);
79
+ const gitMethods = createGitMethods(() => configRef);
80
+ gitMethods.register(register);
81
+ const scaffoldMethods = createScaffoldMethods(() => configRef);
82
+ scaffoldMethods.register(register);
83
+
84
+ // ---------------- WS 服务 ----------------
85
+ const state = {
86
+ /** deviceId -> Set<ws> 控制连接 */
87
+ deviceConns: new Map(),
88
+ };
89
+
90
+ function sha256(s) {
91
+ return crypto.createHash('sha256').update(String(s)).digest('hex');
92
+ }
93
+
94
+ function checkAuthToken(token) {
95
+ if (!token || typeof token !== 'string') return false;
96
+ return configRef.config.deviceTokenWhitelist.includes(sha256(token));
97
+ }
98
+
99
+ /**
100
+ * 客户端 → Agent 的二进制帧解析(终端输入):
101
+ * 4 字节大端头长 + JSON 头 {channel:'terminal', sessionId, type} + payload
102
+ */
103
+ function parseBinaryFrame(buffer) {
104
+ if (buffer.length < 5) return null;
105
+ const headerLen = buffer.readUInt32BE(0);
106
+ if (buffer.length < 4 + headerLen) return null;
107
+ let header;
108
+ try {
109
+ header = JSON.parse(buffer.slice(4, 4 + headerLen).toString('utf8'));
110
+ } catch {
111
+ return null;
112
+ }
113
+ return { header, payload: buffer.slice(4 + headerLen) };
114
+ }
115
+
116
+ function buildBinaryFrame(header, payload) {
117
+ const h = Buffer.from(JSON.stringify(header), 'utf8');
118
+ const len = Buffer.alloc(4);
119
+ len.writeUInt32BE(h.length, 0);
120
+ return Buffer.concat([len, h, payload]);
121
+ }
122
+
123
+ function sendJson(ws, obj) {
124
+ if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(obj));
125
+ }
126
+
127
+ function closeWith(ws, code, reason) {
128
+ try {
129
+ ws.close(code, reason);
130
+ } catch {
131
+ ws.terminate();
132
+ }
133
+ }
134
+
135
+ function setupConnection(ws, req) {
136
+ ws.isAlive = true;
137
+ ws.authed = false;
138
+ ws.deviceId = null;
139
+ ws.authTimer = setTimeout(() => {
140
+ if (!ws.authed) closeWith(ws, 4401, 'auth timeout');
141
+ }, AUTH_TIMEOUT_MS);
142
+
143
+ ws.on('pong', () => {
144
+ ws.isAlive = true;
145
+ ws.missed = 0;
146
+ });
147
+ ws.missed = 0;
148
+
149
+ ws.on('message', async (data, isBinary) => {
150
+ if (isBinary) {
151
+ const frame = parseBinaryFrame(data);
152
+ if (!frame || !ws.authed) return;
153
+ if (frame.header && frame.header.channel === 'terminal' && frame.header.sessionId) {
154
+ // 终端输入 → 对应 pty 会话
155
+ const s = terminal.sessions.get(frame.header.sessionId);
156
+ if (s && frame.payload.length > 0) s.pty.write(frame.payload.toString('utf8'));
157
+ }
158
+ return;
159
+ }
160
+ let msg;
161
+ try {
162
+ msg = JSON.parse(data.toString('utf8'));
163
+ } catch {
164
+ return;
165
+ }
166
+
167
+ // ---- 鉴权握手 ----
168
+ if (msg && msg.type === 'auth') {
169
+ if (ws.authed) return;
170
+ const ok = checkAuthToken(msg.token);
171
+ if (!ok) {
172
+ logger.warn(`[ws] 鉴权失败:token 不在白名单(remote=${req.socket.remoteAddress})`);
173
+ closeWith(ws, 4401, 'unauthorized');
174
+ return;
175
+ }
176
+ ws.authed = true;
177
+ ws.deviceId = msg.deviceId || 'dev-default';
178
+ if (ws.authTimer) clearTimeout(ws.authTimer);
179
+ // ---- 多连接限制(T-M0-15)----
180
+ let set = state.deviceConns.get(ws.deviceId);
181
+ if (!set) {
182
+ set = new Set();
183
+ state.deviceConns.set(ws.deviceId, set);
184
+ }
185
+ const existing = [...set].filter((c) => c !== ws && c.readyState === c.OPEN);
186
+ if (existing.length >= MAX_CONNS_PER_DEVICE) {
187
+ sendJson(ws, { jsonrpc: '2.0', method: 'event.connection.limit', params: { max: MAX_CONNS_PER_DEVICE, code: 'E_CONNECTION_LIMIT' } });
188
+ closeWith(ws, 4403, 'connection limit');
189
+ return;
190
+ }
191
+ set.add(ws);
192
+ if (existing.length > 0) {
193
+ // 旧连接收到提示
194
+ sendJson(existing[0], { jsonrpc: '2.0', method: 'event.connection.duplicated', params: { hint: '已在其他窗口打开' } });
195
+ }
196
+ sendJson(ws, {
197
+ jsonrpc: '2.0',
198
+ method: 'event.auth.ok',
199
+ params: {
200
+ agentVersion: require('../package.json').version,
201
+ protocolVersion: 1,
202
+ port: configRef.config.port,
203
+ platform: `${process.platform} ${process.arch}`,
204
+ },
205
+ });
206
+ logger.info(`[ws] 设备 ${ws.deviceId} 已连接(当前 ${existing.length + 1} 连接)`);
207
+ return;
208
+ }
209
+
210
+ if (!ws.authed) {
211
+ // 未鉴权连接仅放行配对握手(新设备尚无 deviceToken,用配对码换取令牌)
212
+ if (msg && msg.jsonrpc === '2.0' && msg.method === 'pairing.handshake') {
213
+ const resp = await handleRequest(msg, {
214
+ socket: req.socket,
215
+ ws,
216
+ deviceId: null,
217
+ send: (obj) => sendJson(ws, obj),
218
+ sendBinary: null,
219
+ });
220
+ if (resp) sendJson(ws, resp);
221
+ }
222
+ return;
223
+ }
224
+ // ---- JSON-RPC(5.20.3 频率限制:RPC ≤60/min/设备)----
225
+ if (msg && msg.jsonrpc === '2.0' && msg.method) {
226
+ const now = Date.now();
227
+ if (!ws.rpcWindow) {
228
+ ws.rpcWindow = now;
229
+ ws.rpcCount = 0;
230
+ }
231
+ if (now - ws.rpcWindow > RPC_RATE_WINDOW) {
232
+ ws.rpcWindow = now;
233
+ ws.rpcCount = 0;
234
+ }
235
+ ws.rpcCount++;
236
+ if (ws.rpcCount > RPC_RATE_LIMIT) {
237
+ logger.warn(`[ws] RPC 频率超限(${ws.rpcCount}/${RPC_RATE_LIMIT} per min),拒绝: ${msg.method}`);
238
+ sendJson(ws, {
239
+ jsonrpc: '2.0',
240
+ id: msg.id ?? null,
241
+ error: { code: -32603, message: '请求过于频繁,请稍后再试', data: { code: 'E_RATE_LIMIT' } },
242
+ });
243
+ return;
244
+ }
245
+ const resp = await handleRequest(msg, {
246
+ socket: req.socket,
247
+ ws,
248
+ deviceId: ws.deviceId,
249
+ send: (obj) => sendJson(ws, obj),
250
+ sendBinary: (buf, opts) => {
251
+ if (ws.readyState === ws.OPEN) ws.send(buf, { binary: true });
252
+ },
253
+ });
254
+ if (resp) sendJson(ws, resp);
255
+ }
256
+ });
257
+
258
+ ws.on('close', () => {
259
+ if (ws.authTimer) clearTimeout(ws.authTimer);
260
+ if (ws.deviceId) {
261
+ const set = state.deviceConns.get(ws.deviceId);
262
+ if (set) {
263
+ set.delete(ws);
264
+ if (set.size === 0) state.deviceConns.delete(ws.deviceId);
265
+ }
266
+ logger.info(`[ws] 设备 ${ws.deviceId} 连接断开(剩余 ${set ? set.size : 0} 连接)`);
267
+ }
268
+ });
269
+
270
+ ws.on('error', () => {
271
+ /* 连接错误由 close 统一处理 */
272
+ });
273
+ }
274
+
275
+ function tryListen(port, triesLeft) {
276
+ const httpFs = createHttpFsHandlers(() => configRef);
277
+ const server = http.createServer((req, res) => {
278
+ // 本地 HTTP:上传/下载/zip(T-M1-05)
279
+ const parsed = url.parse(req.url, true);
280
+ const p = parsed.pathname || '';
281
+ try {
282
+ if (req.method === 'POST' && p === '/api/fs/upload') return httpFs.handleUpload(req, res, parsed.query);
283
+ if (req.method === 'GET' && p === '/api/fs/download') return httpFs.handleDownload(req, res, parsed.query);
284
+ if (req.method === 'GET' && p === '/api/fs/zip') return httpFs.handleZip(req, res, parsed.query);
285
+ } catch (e) {
286
+ logger.error(`[http] 处理 ${p} 失败: ${e && e.message}`);
287
+ res.writeHead(500, { 'Content-Type': 'application/json' });
288
+ res.end(JSON.stringify({ code: 'E_INTERNAL', msg: e && e.message }));
289
+ return;
290
+ }
291
+ res.writeHead(404, { 'Content-Type': 'application/json' });
292
+ res.end(JSON.stringify({ error: 'not found' }));
293
+ });
294
+
295
+ const wss = new WebSocketServer({ server, maxPayload: 1024 * 1024 });
296
+ wss.on('connection', setupConnection);
297
+ // 广播通道(fs 监听等事件推送到所有已鉴权连接)
298
+ configRef.broadcast = (obj) => {
299
+ for (const c of wss.clients) {
300
+ if (c.authed && c.readyState === c.OPEN) sendJson(c, obj);
301
+ }
302
+ };
303
+
304
+ server.on('error', (err) => {
305
+ if (err.code === 'EADDRINUSE' && triesLeft > 0) {
306
+ logger.warn(`[ws] 端口 ${port} 被占用,尝试 ${port + 1}`);
307
+ tryListen(port + 1, triesLeft - 1);
308
+ } else {
309
+ logger.error(`[ws] 启动失败: ${err.message}`);
310
+ process.exit(1);
311
+ }
312
+ });
313
+
314
+ server.listen(port, '127.0.0.1', () => {
315
+ configRef.config = saveConfig({ port });
316
+ logger.info(`[ws] Agent 已启动 ws://127.0.0.1:${port}`);
317
+ console.log(`ysagc-agent 运行中: ws://127.0.0.1:${port}`);
318
+ // ---- 心跳 ----
319
+ const heartbeat = setInterval(() => {
320
+ for (const ws of wss.clients) {
321
+ if (ws.isAlive === false) {
322
+ ws.terminate();
323
+ continue;
324
+ }
325
+ ws.isAlive = false;
326
+ ws.missed = (ws.missed || 0) + 1;
327
+ if (ws.missed > HEARTBEAT_MISS_LIMIT) {
328
+ ws.terminate();
329
+ continue;
330
+ }
331
+ try {
332
+ ws.ping();
333
+ } catch {
334
+ /* ignore */
335
+ }
336
+ }
337
+ }, HEARTBEAT_INTERVAL_MS);
338
+ heartbeat.unref();
339
+ // ---- 配对码 ----
340
+ startPairing(() => configRef.config);
341
+ });
342
+
343
+ return server;
344
+ }
345
+
346
+ console.log(`ysagc-agent v${require('../package.json').version} 启动中(配置目录: ${CONFIG_DIR})`);
347
+ tryListen(config.port, MAX_PORT_TRIES);
348
+
349
+ process.on('SIGINT', () => {
350
+ stopPairing();
351
+ logger.info('[agent] 收到 SIGINT,退出');
352
+ process.exit(0);
353
+ });
354
+ process.on('SIGTERM', () => {
355
+ stopPairing();
356
+ logger.info('[agent] 收到 SIGTERM,退出');
357
+ process.exit(0);
358
+ });
359
+
360
+ // Agent 守护进程韧性:单个会话/pty 的异步错误不得打崩整个 Agent。
361
+ // 快速连续崩溃(如 10 秒内 5 次)视为致命,退出。
362
+ let crashCount = 0;
363
+ let crashWindowStart = 0;
364
+ function guardCrash(level, err) {
365
+ const now = Date.now();
366
+ if (now - crashWindowStart > 10000) {
367
+ crashWindowStart = now;
368
+ crashCount = 0;
369
+ }
370
+ crashCount++;
371
+ logger.error(`[guard:${level}]`, err);
372
+ if (crashCount >= 5) {
373
+ logger.error('[guard] 短时间多次致命异常,Agent 退出');
374
+ process.exit(1);
375
+ }
376
+ }
377
+ process.on('uncaughtException', (err) => guardCrash('uncaughtException', err));
378
+ process.on('unhandledRejection', (reason) => guardCrash('unhandledRejection', reason));
package/src/logger.js ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * 滚动日志:~/.ysagc-agent/logs/agent.log(按日滚动,保留 7 份)
3
+ * 红线:永不记录密钥文件内容、Git 凭证、密码类数据
4
+ */
5
+ 'use strict';
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const { CONFIG_DIR } = require('./config');
10
+
11
+ const LOG_DIR = path.join(CONFIG_DIR, 'logs');
12
+ const LOG_FILE = path.join(LOG_DIR, 'agent.log');
13
+ const MAX_KEEP = 7;
14
+ const MAX_SIZE = 5 * 1024 * 1024; // 5MB 滚动
15
+
16
+ let levelRank = { debug: 10, info: 20, warn: 30, error: 40 };
17
+ let currentLevel = 'info';
18
+
19
+ function ensureLogDir() {
20
+ try {
21
+ fs.mkdirSync(LOG_DIR, { recursive: true });
22
+ } catch {
23
+ /* 日志目录创建失败时降级为 console */
24
+ }
25
+ }
26
+
27
+ function rotateIfNeeded() {
28
+ try {
29
+ if (!fs.existsSync(LOG_FILE)) return;
30
+ const stat = fs.statSync(LOG_FILE);
31
+ if (stat.size < MAX_SIZE) return;
32
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
33
+ fs.renameSync(LOG_FILE, path.join(LOG_DIR, `agent.log.${stamp}`));
34
+ // 清理旧日志,保留最近 MAX_KEEP 份
35
+ const backups = fs
36
+ .readdirSync(LOG_DIR)
37
+ .filter((f) => f.startsWith('agent.log.') && f !== LOG_FILE)
38
+ .map((f) => ({ f, t: fs.statSync(path.join(LOG_DIR, f)).mtimeMs }))
39
+ .sort((a, b) => b.t - a.t);
40
+ for (const b of backups.slice(MAX_KEEP)) {
41
+ try {
42
+ fs.unlinkSync(path.join(LOG_DIR, b.f));
43
+ } catch {
44
+ /* ignore */
45
+ }
46
+ }
47
+ } catch {
48
+ /* ignore */
49
+ }
50
+ }
51
+
52
+ function sanitize(msg) {
53
+ // 脱敏:常见的密钥/凭证形态(预防性兜底,正常流程不应写入)
54
+ return String(msg).replace(
55
+ /(sk-[A-Za-z0-9_\-]{8,}|ghp_[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16}|xox[bap]-[A-Za-z0-9\-]{10,}|password\s*[:=]\s*\S+)/gi,
56
+ '[REDACTED]'
57
+ );
58
+ }
59
+
60
+ function write(level, args) {
61
+ if (levelRank[level] < levelRank[currentLevel]) return;
62
+ ensureLogDir();
63
+ rotateIfNeeded();
64
+ const line = `[${new Date().toISOString()}] [${level.toUpperCase()}] ${args
65
+ .map((a) => (a instanceof Error ? a.stack || a.message : typeof a === 'object' ? JSON.stringify(a) : String(a)))
66
+ .join(' ')}`;
67
+ try {
68
+ fs.appendFileSync(LOG_FILE, sanitize(line) + '\n');
69
+ } catch {
70
+ /* ignore */
71
+ }
72
+ }
73
+
74
+ const logger = {
75
+ setLevel(level) {
76
+ if (levelRank[level]) currentLevel = level;
77
+ },
78
+ debug: (...a) => write('debug', a),
79
+ info: (...a) => write('info', a),
80
+ warn: (...a) => write('warn', a),
81
+ error: (...a) => write('error', a),
82
+ };
83
+
84
+ module.exports = logger;