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 +96 -0
- package/package.json +37 -0
- package/src/config.js +89 -0
- package/src/http-fs.js +171 -0
- package/src/index.js +378 -0
- package/src/logger.js +84 -0
- package/src/methods/fs.js +674 -0
- package/src/methods/git.js +391 -0
- package/src/methods/project.js +57 -0
- package/src/methods/scaffold.js +131 -0
- package/src/methods/skill.js +347 -0
- package/src/methods/system.js +82 -0
- package/src/methods/terminal.js +234 -0
- package/src/pairing.js +157 -0
- package/src/rpc.js +62 -0
- package/src/sandbox.js +178 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* skill.* RPC 方法(T-M1-17,需求 5.22 Skills 技能安装)
|
|
3
|
+
* - skill.list / skill.install / skill.uninstall / skill.setEnabled / skill.detail / skill.context
|
|
4
|
+
* - 技能目录:<配置目录>/skills/<name>/SKILL.md(frontmatter + 正文)
|
|
5
|
+
* - 启停状态:<配置目录>/skills/.state.json
|
|
6
|
+
* - 市场安装:Agent 直连后端下载 raw(ticket 一次性票据)→ 校验 → 写入
|
|
7
|
+
* - 本地导入:复制本机文件夹 → 校验
|
|
8
|
+
* - 安全:SKILL.md 大小 ≤8K 字符、技能名白名单 [a-z0-9-]、安装/卸载/启停记审计
|
|
9
|
+
*/
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const http = require('http');
|
|
15
|
+
const https = require('https');
|
|
16
|
+
const { RpcError } = require('../rpc');
|
|
17
|
+
const { CONFIG_DIR } = require('../config');
|
|
18
|
+
const logger = require('../logger');
|
|
19
|
+
|
|
20
|
+
const SKILLS_ROOT = path.join(CONFIG_DIR, 'skills');
|
|
21
|
+
const STATE_FILE = path.join(SKILLS_ROOT, '.state.json');
|
|
22
|
+
const NAME_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
23
|
+
const MAX_BODY_CHARS = 8000; // SKILL.md 正文 ≤ 8K 字符
|
|
24
|
+
const MAX_FILES = 50;
|
|
25
|
+
const MAX_TOTAL_BYTES = 10 * 1024 * 1024; // 技能包 ≤ 10MB
|
|
26
|
+
|
|
27
|
+
function ensureRoot() {
|
|
28
|
+
fs.mkdirSync(SKILLS_ROOT, { recursive: true });
|
|
29
|
+
return SKILLS_ROOT;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function stateFile() {
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
|
|
35
|
+
} catch {
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function saveState(state) {
|
|
41
|
+
try {
|
|
42
|
+
ensureRoot();
|
|
43
|
+
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
|
|
44
|
+
} catch (e) {
|
|
45
|
+
logger.warn(`[skill] 状态保存失败: ${e.message}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 解析 SKILL.md:frontmatter + 正文 */
|
|
50
|
+
function parseSkillDoc(content) {
|
|
51
|
+
const m = String(content || '').match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
|
|
52
|
+
if (!m) return null;
|
|
53
|
+
const meta = {};
|
|
54
|
+
for (const line of m[1].split('\n')) {
|
|
55
|
+
const kv = line.match(/^([a-zA-Z_]+):\s*(.*)$/);
|
|
56
|
+
if (kv) {
|
|
57
|
+
let v = kv[2].trim();
|
|
58
|
+
if (v.startsWith('[') && v.endsWith(']')) {
|
|
59
|
+
v = v.slice(1, -1).split(',').map((s) => s.trim()).filter(Boolean);
|
|
60
|
+
}
|
|
61
|
+
meta[kv[1]] = v;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const name = String(meta.name || '').trim();
|
|
65
|
+
if (!NAME_RE.test(name)) return null;
|
|
66
|
+
const body = m[2].trim();
|
|
67
|
+
if (body.length > MAX_BODY_CHARS) return null;
|
|
68
|
+
return {
|
|
69
|
+
name,
|
|
70
|
+
description: String(meta.description || '').slice(0, 200),
|
|
71
|
+
version: String(meta.version || '1.0.0'),
|
|
72
|
+
author: String(meta.author || ''),
|
|
73
|
+
tags: Array.isArray(meta.tags) ? meta.tags : [],
|
|
74
|
+
body,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function readSkillInfo(dir) {
|
|
79
|
+
const p = path.join(dir, 'SKILL.md');
|
|
80
|
+
if (!fs.existsSync(p)) return null;
|
|
81
|
+
let content;
|
|
82
|
+
try {
|
|
83
|
+
content = fs.readFileSync(p, 'utf8');
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
const parsed = parseSkillDoc(content);
|
|
88
|
+
if (!parsed) return null;
|
|
89
|
+
const st = stateFile();
|
|
90
|
+
return {
|
|
91
|
+
name: parsed.name,
|
|
92
|
+
description: parsed.description,
|
|
93
|
+
version: parsed.version,
|
|
94
|
+
author: parsed.author,
|
|
95
|
+
tags: parsed.tags,
|
|
96
|
+
enabled: st[parsed.name] === undefined ? true : !!st[parsed.name],
|
|
97
|
+
installedAt: (() => {
|
|
98
|
+
try {
|
|
99
|
+
return new Date(fs.statSync(dir).birthtimeMs).toISOString();
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
})(),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 校验技能目录:路径穿越、大小、文件数 */
|
|
108
|
+
function validateSkillDir(root, name) {
|
|
109
|
+
const errors = [];
|
|
110
|
+
let fileCount = 0;
|
|
111
|
+
let totalBytes = 0;
|
|
112
|
+
const walk = (dir, relBase) => {
|
|
113
|
+
let entries;
|
|
114
|
+
try {
|
|
115
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
116
|
+
} catch {
|
|
117
|
+
errors.push(`无法读取目录 ${relBase}`);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
for (const e of entries) {
|
|
121
|
+
const abs = path.join(dir, e.name);
|
|
122
|
+
const rel = relBase ? `${relBase}/${e.name}` : e.name;
|
|
123
|
+
if (rel.includes('..') || rel.startsWith('/')) {
|
|
124
|
+
errors.push(`非法路径: ${rel}`);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (e.isDirectory()) walk(abs, rel);
|
|
128
|
+
else if (e.isFile()) {
|
|
129
|
+
fileCount++;
|
|
130
|
+
totalBytes += fs.statSync(abs).size;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
walk(root, '');
|
|
135
|
+
if (fileCount > MAX_FILES) errors.push(`文件数超限(>${MAX_FILES})`);
|
|
136
|
+
if (totalBytes > MAX_TOTAL_BYTES) errors.push(`体积超限(>10MB)`);
|
|
137
|
+
return { ok: errors.length === 0, errors, fileCount, totalBytes };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 获取技能目录下的所有文件(相对路径 → 内容),供市场安装落地用 */
|
|
141
|
+
function readSkillFiles(dir) {
|
|
142
|
+
const out = [];
|
|
143
|
+
const walk = (d, relBase) => {
|
|
144
|
+
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
145
|
+
const abs = path.join(d, e.name);
|
|
146
|
+
const rel = relBase ? `${relBase}/${e.name}` : e.name;
|
|
147
|
+
if (e.isDirectory()) walk(abs, rel);
|
|
148
|
+
else if (e.isFile()) out.push({ relPath: rel, content: fs.readFileSync(abs, 'utf8') });
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
walk(dir, '');
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 写技能文件(含路径穿越校验) */
|
|
156
|
+
function writeSkillFiles(name, files) {
|
|
157
|
+
const root = path.join(SKILLS_ROOT, name);
|
|
158
|
+
ensureRoot();
|
|
159
|
+
if (fs.existsSync(root)) throw new RpcError('E_SKILL_EXISTS', `技能 ${name} 已安装`);
|
|
160
|
+
for (const f of files) {
|
|
161
|
+
const rel = String(f.relPath || '');
|
|
162
|
+
if (!rel || rel.includes('..') || rel.startsWith('/') || rel.includes('\\')) {
|
|
163
|
+
throw new RpcError('E_SKILL_INVALID', `非法文件路径: ${rel}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
fs.mkdirSync(root, { recursive: true });
|
|
167
|
+
try {
|
|
168
|
+
for (const f of files) {
|
|
169
|
+
const abs = path.join(root, f.relPath);
|
|
170
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
171
|
+
fs.writeFileSync(abs, String(f.content ?? ''));
|
|
172
|
+
}
|
|
173
|
+
} catch (e) {
|
|
174
|
+
// 失败回滚
|
|
175
|
+
try { fs.rmSync(root, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
176
|
+
throw new RpcError('E_SKILL_INVALID', `写入失败: ${e.message}`);
|
|
177
|
+
}
|
|
178
|
+
return root;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** 市场下载:GET backendUrl/api/workspace/skills/market/:id/raw?ticket= */
|
|
182
|
+
function fetchMarketRaw(backendUrl, skillId, ticket) {
|
|
183
|
+
return new Promise((resolve, reject) => {
|
|
184
|
+
let u;
|
|
185
|
+
try {
|
|
186
|
+
u = new URL(`${String(backendUrl).replace(/\/$/, '')}/api/workspace/skills/market/${encodeURIComponent(skillId)}/raw`);
|
|
187
|
+
if (ticket) u.searchParams.set('ticket', ticket);
|
|
188
|
+
} catch {
|
|
189
|
+
return reject(new RpcError('E_SKILL_MARKET_OFFLINE', '技能市场地址无效'));
|
|
190
|
+
}
|
|
191
|
+
const lib = u.protocol === 'https:' ? https : http;
|
|
192
|
+
const req = lib.get(u, { timeout: 15000 }, (res) => {
|
|
193
|
+
if (res.statusCode !== 200) {
|
|
194
|
+
let body = '';
|
|
195
|
+
res.on('data', (c) => (body += c));
|
|
196
|
+
res.on('end', () => {
|
|
197
|
+
let msg = `下载失败(HTTP ${res.statusCode})`;
|
|
198
|
+
try { msg = JSON.parse(body).msg || msg; } catch { /* ignore */ }
|
|
199
|
+
reject(new RpcError('E_SKILL_MARKET_OFFLINE', msg));
|
|
200
|
+
});
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
let body = '';
|
|
204
|
+
res.on('data', (c) => (body += c));
|
|
205
|
+
res.on('end', () => {
|
|
206
|
+
try {
|
|
207
|
+
const json = JSON.parse(body);
|
|
208
|
+
if (json.code !== 0 || !Array.isArray(json.data.files)) {
|
|
209
|
+
reject(new RpcError('E_SKILL_MARKET_OFFLINE', json.msg || '技能数据异常'));
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
resolve(json.data);
|
|
213
|
+
} catch {
|
|
214
|
+
reject(new RpcError('E_SKILL_MARKET_OFFLINE', '技能数据解析失败'));
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
req.on('error', (e) => reject(new RpcError('E_SKILL_MARKET_OFFLINE', `技能市场不可用: ${e.message}`)));
|
|
219
|
+
req.on('timeout', () => { req.destroy(); reject(new RpcError('E_SKILL_MARKET_OFFLINE', '技能市场下载超时')); });
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function createSkillMethods(ctxRef) {
|
|
224
|
+
const cfg = () => ctxRef().config;
|
|
225
|
+
|
|
226
|
+
function audit(opType, target, result, errorCode) {
|
|
227
|
+
try {
|
|
228
|
+
const br = ctxRef().broadcast;
|
|
229
|
+
if (br) br({ jsonrpc: '2.0', method: 'event.audit', params: { opType, target, result, errorCode } });
|
|
230
|
+
} catch { /* ignore */ }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function register(reg) {
|
|
234
|
+
// ---- 已安装列表 ----
|
|
235
|
+
reg('skill.list', async () => {
|
|
236
|
+
ensureRoot();
|
|
237
|
+
const items = [];
|
|
238
|
+
for (const entry of fs.readdirSync(SKILLS_ROOT, { withFileTypes: true })) {
|
|
239
|
+
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
|
|
240
|
+
const info = readSkillInfo(path.join(SKILLS_ROOT, entry.name));
|
|
241
|
+
if (info) items.push(info);
|
|
242
|
+
}
|
|
243
|
+
return { skills: items };
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// ---- 详情 ----
|
|
247
|
+
reg('skill.detail', async (params = {}) => {
|
|
248
|
+
const name = String(params.name || '');
|
|
249
|
+
const dir = path.join(SKILLS_ROOT, name);
|
|
250
|
+
const p = path.join(dir, 'SKILL.md');
|
|
251
|
+
if (!NAME_RE.test(name) || !fs.existsSync(p)) throw new RpcError('E_SKILL_NOT_FOUND', '技能不存在');
|
|
252
|
+
const content = fs.readFileSync(p, 'utf8');
|
|
253
|
+
const parsed = parseSkillDoc(content);
|
|
254
|
+
if (!parsed) throw new RpcError('E_SKILL_INVALID', 'SKILL.md 格式非法');
|
|
255
|
+
return { ...parsed, files: readSkillFiles(dir) };
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// ---- 安装(market / local)----
|
|
259
|
+
reg('skill.install', async (params = {}) => {
|
|
260
|
+
const source = String(params.source || '');
|
|
261
|
+
if (source !== 'market' && source !== 'local') throw new RpcError('E_VALIDATION', 'source 必须为 market 或 local');
|
|
262
|
+
let name = '';
|
|
263
|
+
if (source === 'market') {
|
|
264
|
+
const skillId = String(params.ref || '');
|
|
265
|
+
const ticket = String(params.ticket || '');
|
|
266
|
+
const data = await fetchMarketRaw(cfg().backendUrl, skillId, ticket);
|
|
267
|
+
const files = data.files || [];
|
|
268
|
+
// 取 SKILL.md 里的 name
|
|
269
|
+
const sm = files.find((f) => f.relPath === 'SKILL.md');
|
|
270
|
+
if (!sm) throw new RpcError('E_SKILL_INVALID', '技能包缺少 SKILL.md');
|
|
271
|
+
const parsed = parseSkillDoc(sm.content);
|
|
272
|
+
if (!parsed) throw new RpcError('E_SKILL_INVALID', 'SKILL.md 格式非法');
|
|
273
|
+
name = parsed.name;
|
|
274
|
+
writeSkillFiles(name, files);
|
|
275
|
+
} else {
|
|
276
|
+
// local:本机路径
|
|
277
|
+
const p = String(params.path || '');
|
|
278
|
+
if (!p) throw new RpcError('E_VALIDATION', '缺少技能路径');
|
|
279
|
+
const smPath = path.join(p, 'SKILL.md');
|
|
280
|
+
if (!fs.existsSync(smPath)) throw new RpcError('E_SKILL_INVALID', '目录缺少 SKILL.md');
|
|
281
|
+
const content = fs.readFileSync(smPath, 'utf8');
|
|
282
|
+
const parsed = parseSkillDoc(content);
|
|
283
|
+
if (!parsed) throw new RpcError('E_SKILL_INVALID', 'SKILL.md 格式非法');
|
|
284
|
+
name = parsed.name;
|
|
285
|
+
const files = readSkillFiles(p);
|
|
286
|
+
writeSkillFiles(name, files);
|
|
287
|
+
}
|
|
288
|
+
const dir = path.join(SKILLS_ROOT, name);
|
|
289
|
+
const check = validateSkillDir(dir, name);
|
|
290
|
+
if (!check.ok) {
|
|
291
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
292
|
+
throw new RpcError('E_SKILL_INVALID', `技能校验失败: ${check.errors.join('; ')}`);
|
|
293
|
+
}
|
|
294
|
+
audit('skill.install', name, 'ok');
|
|
295
|
+
logger.info(`[skill] 已安装技能 ${name}(来源 ${source})`);
|
|
296
|
+
return { ok: true, skill: readSkillInfo(dir) };
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// ---- 卸载(危险级:前端二次确认)----
|
|
300
|
+
reg('skill.uninstall', async (params = {}) => {
|
|
301
|
+
const name = String(params.name || '');
|
|
302
|
+
const dir = path.join(SKILLS_ROOT, name);
|
|
303
|
+
if (!NAME_RE.test(name) || !fs.existsSync(dir)) throw new RpcError('E_SKILL_NOT_FOUND', '技能不存在');
|
|
304
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
305
|
+
const st = stateFile();
|
|
306
|
+
delete st[name];
|
|
307
|
+
saveState(st);
|
|
308
|
+
audit('skill.uninstall', name, 'ok');
|
|
309
|
+
return { ok: true };
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// ---- 启用/禁用 ----
|
|
313
|
+
reg('skill.setEnabled', async (params = {}) => {
|
|
314
|
+
const name = String(params.name || '');
|
|
315
|
+
const dir = path.join(SKILLS_ROOT, name);
|
|
316
|
+
if (!NAME_RE.test(name) || !fs.existsSync(dir)) throw new RpcError('E_SKILL_NOT_FOUND', '技能不存在');
|
|
317
|
+
const st = stateFile();
|
|
318
|
+
st[name] = !!params.enabled;
|
|
319
|
+
saveState(st);
|
|
320
|
+
audit('skill.setEnabled', name, 'ok');
|
|
321
|
+
return { ok: true, enabled: !!params.enabled };
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// ---- AI 上下文注入(≤2K token,5.22.4)----
|
|
325
|
+
reg('skill.context', async () => {
|
|
326
|
+
ensureRoot();
|
|
327
|
+
const st = stateFile();
|
|
328
|
+
const parts = [];
|
|
329
|
+
let budget = 2000; // 2K token ≈ 约 6000 字符(中文保守按 3 字/token)
|
|
330
|
+
for (const entry of fs.readdirSync(SKILLS_ROOT, { withFileTypes: true })) {
|
|
331
|
+
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
|
|
332
|
+
if (st[entry.name] === false) continue; // 已禁用
|
|
333
|
+
const info = readSkillInfo(path.join(SKILLS_ROOT, entry.name));
|
|
334
|
+
if (!info) continue;
|
|
335
|
+
const snippet = `[技能 ${info.name}] ${info.description}(v${info.version})`;
|
|
336
|
+
if (budget - snippet.length < 0 && parts.length > 0) break;
|
|
337
|
+
parts.push(snippet);
|
|
338
|
+
budget -= snippet.length;
|
|
339
|
+
}
|
|
340
|
+
return { skills: parts };
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return { register };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
module.exports = { createSkillMethods, SKILLS_ROOT };
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* system.* RPC 方法:system.ping / system.info / system.check
|
|
3
|
+
* (T-M0-14:系统信息 + git 检测 + 版本上报)
|
|
4
|
+
*/
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const { execFile } = require('child_process');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
|
|
11
|
+
const AGENT_VERSION = require('../../package.json').version;
|
|
12
|
+
const PROTOCOL_VERSION = 1;
|
|
13
|
+
const MIN_NODE_VERSION = 20;
|
|
14
|
+
|
|
15
|
+
let startupAt = Date.now();
|
|
16
|
+
|
|
17
|
+
function detectPlatform() {
|
|
18
|
+
return `${process.platform} ${process.arch}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* git --version 检测(spawn 参数数组,绝不拼字符串命令)
|
|
23
|
+
*/
|
|
24
|
+
function gitVersion(timeoutMs = 5000) {
|
|
25
|
+
return new Promise((resolve) => {
|
|
26
|
+
try {
|
|
27
|
+
const child = execFile(
|
|
28
|
+
'git',
|
|
29
|
+
['--version'],
|
|
30
|
+
{ timeout: timeoutMs, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] },
|
|
31
|
+
(err, stdout) => {
|
|
32
|
+
if (err) return resolve({ installed: false, version: '' });
|
|
33
|
+
const m = String(stdout).match(/git version\s+([^\s]+)/i);
|
|
34
|
+
resolve({ installed: true, version: m ? m[1] : String(stdout).trim() });
|
|
35
|
+
}
|
|
36
|
+
);
|
|
37
|
+
child.on('error', () => resolve({ installed: false, version: '' }));
|
|
38
|
+
} catch {
|
|
39
|
+
resolve({ installed: false, version: '' });
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 收集一次 gitVersion 结果并缓存 30s */
|
|
45
|
+
let gitCache = null;
|
|
46
|
+
let gitCacheAt = 0;
|
|
47
|
+
async function gitVersionCached() {
|
|
48
|
+
if (gitCache && Date.now() - gitCacheAt < 30000) return gitCache;
|
|
49
|
+
gitCache = await gitVersion();
|
|
50
|
+
gitCacheAt = Date.now();
|
|
51
|
+
return gitCache;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function registerSystemMethods(register, ctxRef) {
|
|
55
|
+
register('system.ping', async () => ({ pong: true, time: Date.now() }));
|
|
56
|
+
|
|
57
|
+
register('system.info', async () => {
|
|
58
|
+
const cfg = ctxRef().config;
|
|
59
|
+
return {
|
|
60
|
+
agentVersion: AGENT_VERSION,
|
|
61
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
62
|
+
platform: detectPlatform(),
|
|
63
|
+
nodeVersion: process.version,
|
|
64
|
+
hostname: os.hostname(),
|
|
65
|
+
uptimeSec: Math.floor((Date.now() - startupAt) / 1000),
|
|
66
|
+
port: cfg.port,
|
|
67
|
+
enableTerminal: !!cfg.enableTerminal,
|
|
68
|
+
terminalPermission: cfg.terminalPermission || 'ask',
|
|
69
|
+
trashEnabled: !!cfg.trashEnabled,
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
register('system.check', async (params = {}) => {
|
|
74
|
+
const checks = {};
|
|
75
|
+
if (params.git !== false) checks.git = await gitVersionCached();
|
|
76
|
+
checks.node = { installed: true, version: process.version, ok: Number(process.versions.node.split('.')[0]) >= MIN_NODE_VERSION };
|
|
77
|
+
checks.agent = { version: AGENT_VERSION, protocolVersion: PROTOCOL_VERSION, ok: true };
|
|
78
|
+
return { checks };
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = { registerSystemMethods, AGENT_VERSION, PROTOCOL_VERSION, gitVersion };
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* terminal.* RPC 方法(node-pty)
|
|
3
|
+
* - terminal.spawn {shell?, cwd} → {sessionId, pid}
|
|
4
|
+
* - terminal.write {sessionId, data}
|
|
5
|
+
* - terminal.resize {sessionId, cols, rows}
|
|
6
|
+
* - terminal.kill {sessionId}
|
|
7
|
+
* 事件:event.terminal.data(二进制帧 + JSON 头)/ event.terminal.exit
|
|
8
|
+
* 安全:仅直连模式;会话上限、输出截断、空闲超时;高危命令黄色提示(前端做,Agent 透传 cwd)
|
|
9
|
+
*/
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const os = require('os');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const crypto = require('crypto');
|
|
15
|
+
const { RpcError } = require('../rpc');
|
|
16
|
+
|
|
17
|
+
let pty = null;
|
|
18
|
+
try {
|
|
19
|
+
pty = require('node-pty');
|
|
20
|
+
} catch (e) {
|
|
21
|
+
pty = null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const DEFAULT_SHELL = process.platform === 'win32' ? 'powershell.exe' : process.env.SHELL || '/bin/bash';
|
|
25
|
+
|
|
26
|
+
const MAX_OUTPUT_PER_SESSION = 2 * 1024 * 1024; // 单会话输出缓冲 2MB(超出截断提示)
|
|
27
|
+
const OUTPUT_TRUNCATE_NOTICE = '\r\n\x1b[33m[输出过长,已截断]\x1b[0m\r\n';
|
|
28
|
+
const RECENT_BUFFER_LINES = 2000; // 断线重连恢复的最近输出行数(T-M2-10)
|
|
29
|
+
|
|
30
|
+
function createTerminalMethods(ctxRef) {
|
|
31
|
+
/** @type {Map<string, {id, pty, pending:string, truncated:boolean, idleTimer, lastActivity, recent:string[]}>} */
|
|
32
|
+
const sessions = new Map();
|
|
33
|
+
/** "允许本次" 的会话级放行(重启 Agent 后失效,符合 5.6.2) */
|
|
34
|
+
let onceAllowed = false;
|
|
35
|
+
|
|
36
|
+
function pushData(ctx, sessionId, data) {
|
|
37
|
+
// 二进制分帧:4 字节头长 + JSON 头 {channel:'terminal', sessionId, type:'data'} + payload
|
|
38
|
+
const header = Buffer.from(JSON.stringify({ channel: 'terminal', sessionId, type: 'data' }), 'utf8');
|
|
39
|
+
const frame = Buffer.concat([Buffer.from([0, 0, 0, header.length]), header, Buffer.from(data)]);
|
|
40
|
+
if (ctx.sendBinary) ctx.sendBinary(frame, { binary: true });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 断线恢复缓冲:保留最近 N 行(T-M2-10) */
|
|
44
|
+
function appendRecent(s, data) {
|
|
45
|
+
if (!s.recent) s.recent = [];
|
|
46
|
+
const lines = String(data).split(/\r?\n/);
|
|
47
|
+
for (const ln of lines) {
|
|
48
|
+
s.recent.push(ln);
|
|
49
|
+
if (s.recent.length > RECENT_BUFFER_LINES) s.recent.shift();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function pushExit(ctx, sessionId, exitCode) {
|
|
54
|
+
ctx.send({ jsonrpc: '2.0', method: 'event.terminal.exit', params: { sessionId, exitCode } });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function getSession(sessionId) {
|
|
58
|
+
const s = sessions.get(sessionId);
|
|
59
|
+
if (!s) throw new RpcError('E_TERMINAL_NOT_FOUND', `终端会话不存在: ${sessionId}`);
|
|
60
|
+
return s;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function resetIdleTimer(ctx, s) {
|
|
64
|
+
if (s.idleTimer) clearTimeout(s.idleTimer);
|
|
65
|
+
const cfg = ctxRef().config;
|
|
66
|
+
const minutes = Number(cfg.terminalIdleTimeoutMin || 30);
|
|
67
|
+
s.idleTimer = setTimeout(() => {
|
|
68
|
+
const cur = sessions.get(s.id);
|
|
69
|
+
if (!cur) return;
|
|
70
|
+
pushData(ctx, s.id, `\r\n\x1b[33m[终端空闲超时(${minutes} 分钟),会话已自动关闭]\x1b[0m\r\n`);
|
|
71
|
+
cur.pty.kill();
|
|
72
|
+
}, minutes * 60 * 1000);
|
|
73
|
+
s.idleTimer.unref && s.idleTimer.unref();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function register(reg) {
|
|
77
|
+
// ---- 终端权限(5.6.2:ask/allow/deny 三态,存 agent.json)----
|
|
78
|
+
reg('terminal.permission', async (params = {}) => {
|
|
79
|
+
const cfg = ctxRef().config;
|
|
80
|
+
const value = String(params.value || '');
|
|
81
|
+
if (value === 'get') {
|
|
82
|
+
return { permission: cfg.terminalPermission || 'ask' };
|
|
83
|
+
}
|
|
84
|
+
if (value === 'allow' || value === 'deny' || value === 'ask') {
|
|
85
|
+
const { saveConfig } = require('../config');
|
|
86
|
+
saveConfig({ terminalPermission: value });
|
|
87
|
+
ctxRef().config.terminalPermission = value;
|
|
88
|
+
if (value === 'allow') onceAllowed = true;
|
|
89
|
+
if (value === 'deny') onceAllowed = false;
|
|
90
|
+
return { permission: value };
|
|
91
|
+
}
|
|
92
|
+
if (value === 'once') {
|
|
93
|
+
onceAllowed = true;
|
|
94
|
+
return { permission: 'ask', once: true };
|
|
95
|
+
}
|
|
96
|
+
throw new RpcError('E_VALIDATION', 'value 必须为 get/allow/once/deny/ask');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
reg('terminal.spawn', async (params = {}, ctx) => {
|
|
100
|
+
const cfg = ctxRef().config;
|
|
101
|
+
if (!cfg.enableTerminal) throw new RpcError('E_TERMINAL_DISABLED', '终端已由本机代理配置关闭');
|
|
102
|
+
if (!pty) throw new RpcError('E_TERMINAL_DISABLED', 'node-pty 不可用(未安装或平台不支持)');
|
|
103
|
+
// 权限检查(5.6.2:首次使用权限卡三选一)
|
|
104
|
+
const permission = cfg.terminalPermission || 'ask';
|
|
105
|
+
if (permission === 'deny' && !onceAllowed) {
|
|
106
|
+
throw new RpcError('E_TERMINAL_DENIED', '终端权限已被拒绝,可在设置中撤销');
|
|
107
|
+
}
|
|
108
|
+
if (permission === 'ask' && !onceAllowed) {
|
|
109
|
+
return { needPermission: true, permission: 'ask' };
|
|
110
|
+
}
|
|
111
|
+
if (sessions.size >= Number(cfg.terminalMaxSessions || 3)) {
|
|
112
|
+
throw new RpcError('E_TERMINAL_LIMIT', `终端会话数已达上限(${cfg.terminalMaxSessions})`);
|
|
113
|
+
}
|
|
114
|
+
const shell = params.shell || DEFAULT_SHELL;
|
|
115
|
+
const cwd = params.cwd && typeof params.cwd === 'string' ? params.cwd : os.homedir();
|
|
116
|
+
const sessionId = crypto.randomUUID();
|
|
117
|
+
let p;
|
|
118
|
+
try {
|
|
119
|
+
p = pty.spawn(shell, [], {
|
|
120
|
+
name: 'xterm-256color',
|
|
121
|
+
cols: Number(params.cols) || 80,
|
|
122
|
+
rows: Number(params.rows) || 24,
|
|
123
|
+
cwd,
|
|
124
|
+
env: { ...process.env, TERM: 'xterm-256color' },
|
|
125
|
+
});
|
|
126
|
+
} catch (e) {
|
|
127
|
+
// pty 启动失败(如系统禁用了 ConPTY/管道)不得打崩 Agent
|
|
128
|
+
throw new RpcError('E_TERMINAL_SPAWN_FAILED', `终端启动失败: ${e.message}`);
|
|
129
|
+
}
|
|
130
|
+
const s = { id: sessionId, pty: p, pending: '', truncated: false, idleTimer: null, lastActivity: Date.now(), recent: [] };
|
|
131
|
+
sessions.set(sessionId, s);
|
|
132
|
+
resetIdleTimer(ctx, s);
|
|
133
|
+
p.onData((data) => {
|
|
134
|
+
s.lastActivity = Date.now();
|
|
135
|
+
resetIdleTimer(ctx, s);
|
|
136
|
+
appendRecent(s, data);
|
|
137
|
+
if (!s.truncated) {
|
|
138
|
+
const cur = (s.pending.length + data.length);
|
|
139
|
+
if (cur > MAX_OUTPUT_PER_SESSION) {
|
|
140
|
+
s.pending = s.pending.slice(-MAX_OUTPUT_PER_SESSION / 2) + OUTPUT_TRUNCATE_NOTICE;
|
|
141
|
+
s.truncated = true;
|
|
142
|
+
pushData(ctx, sessionId, s.pending);
|
|
143
|
+
s.pending = '';
|
|
144
|
+
} else {
|
|
145
|
+
pushData(ctx, sessionId, data);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
p.onExit(({ exitCode }) => {
|
|
150
|
+
sessions.delete(sessionId);
|
|
151
|
+
if (s.idleTimer) clearTimeout(s.idleTimer);
|
|
152
|
+
pushExit(ctx, sessionId, exitCode);
|
|
153
|
+
});
|
|
154
|
+
return { sessionId, pid: p.pid };
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
reg('terminal.write', async (params = {}, ctx) => {
|
|
158
|
+
const s = getSession(params.sessionId);
|
|
159
|
+
if (typeof params.data === 'string' && params.data.length > 0) s.pty.write(params.data);
|
|
160
|
+
return { ok: true };
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
reg('terminal.resize', async (params = {}) => {
|
|
164
|
+
const s = getSession(params.sessionId);
|
|
165
|
+
try {
|
|
166
|
+
s.pty.resize(Number(params.cols) || 80, Number(params.rows) || 24);
|
|
167
|
+
} catch {
|
|
168
|
+
/* 部分 shell 不支持 resize,忽略 */
|
|
169
|
+
}
|
|
170
|
+
return { ok: true };
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
reg('terminal.kill', async (params = {}) => {
|
|
174
|
+
const s = getSession(params.sessionId);
|
|
175
|
+
try {
|
|
176
|
+
s.pty.kill();
|
|
177
|
+
} catch {
|
|
178
|
+
/* 已退出 */
|
|
179
|
+
}
|
|
180
|
+
sessions.delete(params.sessionId);
|
|
181
|
+
return { ok: true };
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
reg('terminal.list', async () => {
|
|
185
|
+
return {
|
|
186
|
+
sessions: [...sessions.values()].map((s) => ({ sessionId: s.id, pid: s.pty.pid })),
|
|
187
|
+
};
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// ---- 一次性命令执行(T-M3-21:AI 触发,前端必须危险级确认后调用)----
|
|
191
|
+
// 返回命令输出尾部(≤50KB);不创建持久会话;超时 30s
|
|
192
|
+
reg('terminal.exec', async (params = {}) => {
|
|
193
|
+
const cfg = ctxRef().config;
|
|
194
|
+
if (!cfg.enableTerminal) throw new RpcError('E_TERMINAL_DISABLED', '终端已由本机代理配置关闭');
|
|
195
|
+
const cmd = String(params.cmd || '').trim();
|
|
196
|
+
if (!cmd) throw new RpcError('E_VALIDATION', '命令为空');
|
|
197
|
+
if (cmd.length > 2000) throw new RpcError('E_VALIDATION', '命令过长');
|
|
198
|
+
const cwd = params.cwd && typeof params.cwd === 'string' ? params.cwd : os.homedir();
|
|
199
|
+
const execFile = require('child_process').execFile;
|
|
200
|
+
return new Promise((resolve, reject) => {
|
|
201
|
+
const shell = process.platform === 'win32' ? 'powershell.exe' : '/bin/sh';
|
|
202
|
+
const args = process.platform === 'win32' ? ['-NoProfile', '-Command', cmd] : ['-c', cmd];
|
|
203
|
+
const child = execFile(shell, args, {
|
|
204
|
+
cwd,
|
|
205
|
+
timeout: 30000,
|
|
206
|
+
maxBuffer: 64 * 1024,
|
|
207
|
+
windowsHide: true,
|
|
208
|
+
env: { ...process.env, TERM: 'dumb' },
|
|
209
|
+
}, (err, stdout, stderr) => {
|
|
210
|
+
const out = String(stdout || '').slice(-50000);
|
|
211
|
+
const errOut = String(stderr || '').slice(-50000);
|
|
212
|
+
resolve({
|
|
213
|
+
exitCode: err && typeof err.code === 'number' ? err.code : err ? -1 : 0,
|
|
214
|
+
stdout: out,
|
|
215
|
+
stderr: errOut,
|
|
216
|
+
output: (out + (errOut ? '\n[stderr]\n' + errOut : '')).slice(-50000),
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
child.on('error', (e) => reject(new RpcError('E_TERMINAL_EXEC_FAILED', `命令执行失败: ${e.message}`)));
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// ---- 断线恢复:最近输出缓冲(T-M2-10)----
|
|
224
|
+
reg('terminal.buffer', async (params = {}) => {
|
|
225
|
+
const s = getSession(params.sessionId);
|
|
226
|
+
const n = Math.min(RECENT_BUFFER_LINES, Math.max(1, Number(params.lines) || RECENT_BUFFER_LINES));
|
|
227
|
+
return { sessionId: s.id, buffer: (s.recent || []).slice(-n).join('\r\n') };
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return { register, sessions };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
module.exports = { createTerminalMethods, DEFAULT_SHELL };
|