ysagc-agent 0.9.5 → 0.9.14
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/dist/agent.d.ts +1 -1
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +137 -10
- package/dist/agent.js.map +1 -1
- package/dist/browser.d.ts +31 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +609 -0
- package/dist/browser.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +69 -0
- package/dist/cli.js.map +1 -1
- package/dist/executor.d.ts +6 -0
- package/dist/executor.d.ts.map +1 -1
- package/dist/executor.js +46 -0
- package/dist/executor.js.map +1 -1
- package/dist/hooks.d.ts +29 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +211 -0
- package/dist/hooks.js.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp.d.ts +92 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +746 -0
- package/dist/mcp.js.map +1 -0
- package/dist/memory.d.ts +14 -0
- package/dist/memory.d.ts.map +1 -0
- package/dist/memory.js +148 -0
- package/dist/memory.js.map +1 -0
- package/dist/protocol-handler.d.ts +8 -0
- package/dist/protocol-handler.d.ts.map +1 -0
- package/dist/protocol-handler.js +127 -0
- package/dist/protocol-handler.js.map +1 -0
- package/dist/protocol.d.ts +60 -3
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +1 -1
- package/dist/protocol.js.map +1 -1
- package/dist/screenshot.d.ts +15 -0
- package/dist/screenshot.d.ts.map +1 -0
- package/dist/screenshot.js +385 -0
- package/dist/screenshot.js.map +1 -0
- package/dist/security.d.ts.map +1 -1
- package/dist/security.js +18 -4
- package/dist/security.js.map +1 -1
- package/dist/tools.d.ts +22 -1
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +470 -78
- package/dist/tools.js.map +1 -1
- package/package.json +1 -1
package/dist/tools.js
CHANGED
|
@@ -38,14 +38,23 @@ exports.walkTree = walkTree;
|
|
|
38
38
|
exports.globToRegex = globToRegex;
|
|
39
39
|
exports.preExecute = preExecute;
|
|
40
40
|
exports.postExecute = postExecute;
|
|
41
|
+
exports.listMcpTools = listMcpTools;
|
|
42
|
+
exports.mcpFailuresFor = mcpFailuresFor;
|
|
43
|
+
exports.shutdownMcp = shutdownMcp;
|
|
41
44
|
exports.createToolRegistry = createToolRegistry;
|
|
42
45
|
const fs = __importStar(require("node:fs"));
|
|
46
|
+
const os = __importStar(require("node:os"));
|
|
43
47
|
const path = __importStar(require("node:path"));
|
|
44
48
|
const childProcess = __importStar(require("node:child_process"));
|
|
45
49
|
const path_utils_1 = require("./path-utils");
|
|
46
50
|
const security_1 = require("./security");
|
|
47
51
|
const skills_1 = require("./skills");
|
|
48
52
|
const executor_1 = require("./executor");
|
|
53
|
+
const screenshot_1 = require("./screenshot");
|
|
54
|
+
const browser_1 = require("./browser");
|
|
55
|
+
const memory_1 = require("./memory");
|
|
56
|
+
const hooks_1 = require("./hooks");
|
|
57
|
+
const mcp_1 = require("./mcp");
|
|
49
58
|
/** Thrown when a tool invocation is invalid or unsafe. */
|
|
50
59
|
class ToolError extends Error {
|
|
51
60
|
constructor(code, message) {
|
|
@@ -76,7 +85,11 @@ function optionalString(args, key, fallback) {
|
|
|
76
85
|
return v;
|
|
77
86
|
}
|
|
78
87
|
function optionalInt(args, key, fallback, opts = {}) {
|
|
79
|
-
|
|
88
|
+
// 兼容平台下发的同义字段:平台工具 schema 用 timeoutMs,本机历史配置用 timeout
|
|
89
|
+
let v = args[key];
|
|
90
|
+
if ((v === undefined || v === null) && key === 'timeout' && args.timeoutMs !== undefined) {
|
|
91
|
+
v = args.timeoutMs;
|
|
92
|
+
}
|
|
80
93
|
if (v === undefined || v === null)
|
|
81
94
|
return fallback;
|
|
82
95
|
const n = typeof v === 'number' ? v : Number(v);
|
|
@@ -142,6 +155,64 @@ function readUpTo(target, length, offset = 0) {
|
|
|
142
155
|
fs.closeSync(fd);
|
|
143
156
|
}
|
|
144
157
|
}
|
|
158
|
+
/**
|
|
159
|
+
* 备份目录:备份放在授权根的 `.ysagc/backups/` 下,**保持原相对路径**,而不是写在文件旁边。
|
|
160
|
+
*
|
|
161
|
+
* 为什么改:原来写 `<name>.bak`(和源文件同目录),等于往用户的项目里塞文件 ——
|
|
162
|
+
* 实测用户的 Vue 项目根目录出现 `README.md.bak`、`report.html.bak`,
|
|
163
|
+
* 一不小心就被提交进 git。平台虽然有「本次改动 + 一键还原」能回滚,
|
|
164
|
+
* 但备份本身还是该放在一个明显属于工具、不会被误提交的地方。
|
|
165
|
+
*/
|
|
166
|
+
function backupPathFor(root, target) {
|
|
167
|
+
const rel = path.relative(root, target);
|
|
168
|
+
const safe = rel.startsWith('..') || path.isAbsolute(rel) ? path.basename(target) : rel;
|
|
169
|
+
return path.join(root, '.ysagc', 'backups', `${safe}.bak`);
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* 原子写入:先写同目录临时文件并 fsync,再 rename 覆盖目标。
|
|
173
|
+
* rename 在同一文件系统上是原子操作,因此「目标文件要么是旧内容、要么是新内容」,
|
|
174
|
+
* 不会出现写一半的破损文件(进程被杀 / 断电 / 磁盘写满都安全)。
|
|
175
|
+
* 目标原本存在时先留一份备份到 .ysagc/backups/(仅保留最近一次),便于误改后恢复。
|
|
176
|
+
*/
|
|
177
|
+
function atomicWrite(target, data, opts = {}) {
|
|
178
|
+
const dir = path.dirname(target);
|
|
179
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
180
|
+
const tmp = path.join(dir, `.${path.basename(target)}.${process.pid}.${Date.now().toString(36)}.tmp`);
|
|
181
|
+
let fd = null;
|
|
182
|
+
try {
|
|
183
|
+
fd = fs.openSync(tmp, 'w', opts.mode ?? 0o644);
|
|
184
|
+
fs.writeFileSync(fd, data);
|
|
185
|
+
try {
|
|
186
|
+
fs.fsyncSync(fd);
|
|
187
|
+
}
|
|
188
|
+
catch { /* 某些文件系统不支持,忽略 */ }
|
|
189
|
+
fs.closeSync(fd);
|
|
190
|
+
fd = null;
|
|
191
|
+
// 覆盖已有文件前留一份备份(只在目标确实存在且不是空文件时)
|
|
192
|
+
try {
|
|
193
|
+
if (fs.existsSync(target) && fs.statSync(target).size > 0 && opts.root) {
|
|
194
|
+
const bak = backupPathFor(opts.root, target);
|
|
195
|
+
fs.mkdirSync(path.dirname(bak), { recursive: true });
|
|
196
|
+
fs.copyFileSync(target, bak);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
catch { /* 备份失败不影响主流程 */ }
|
|
200
|
+
fs.renameSync(tmp, target);
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
try {
|
|
204
|
+
if (fd !== null)
|
|
205
|
+
fs.closeSync(fd);
|
|
206
|
+
}
|
|
207
|
+
catch { /* ignore */ }
|
|
208
|
+
try {
|
|
209
|
+
if (fs.existsSync(tmp))
|
|
210
|
+
fs.unlinkSync(tmp);
|
|
211
|
+
}
|
|
212
|
+
catch { /* ignore */ }
|
|
213
|
+
throw err;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
145
216
|
function rel(root, target) {
|
|
146
217
|
return path.relative(root, target).replace(/\\/g, '/');
|
|
147
218
|
}
|
|
@@ -294,6 +365,12 @@ const WRITE_OR_COMMAND_TOOLS = new Set([
|
|
|
294
365
|
'mv',
|
|
295
366
|
'cp',
|
|
296
367
|
'delete_file',
|
|
368
|
+
// 截图会往工作区里写文件,所以同样算写操作:只读模式下必须被拒
|
|
369
|
+
'screenshot',
|
|
370
|
+
// 浏览器操作会驱动真实页面(点按 / 输入 / 提交表单),同样按写操作对待
|
|
371
|
+
'browser',
|
|
372
|
+
// 写项目记忆文件(.ysagc/memory.md)
|
|
373
|
+
'remember',
|
|
297
374
|
]);
|
|
298
375
|
/** Reconstruct the shell command a command-tool intends to run, if any. */
|
|
299
376
|
function commandForTool(tool, args) {
|
|
@@ -325,8 +402,35 @@ function pathArgsForTool(tool, args) {
|
|
|
325
402
|
}
|
|
326
403
|
return out;
|
|
327
404
|
}
|
|
328
|
-
/**
|
|
329
|
-
|
|
405
|
+
/**
|
|
406
|
+
* 读取类工具的路径解析:允许落在**主授权根**或任一**只读参考根**内。
|
|
407
|
+
*
|
|
408
|
+
* 与 safeResolve 的区别只有一点:多了 readRoots。写入类工具一律继续用
|
|
409
|
+
* safeResolve(只看主根),所以参考目录天然是只读的——这是这个功能的安全边界。
|
|
410
|
+
* 相对路径仍按主根解析,参考目录里的文件用绝对路径访问。
|
|
411
|
+
*/
|
|
412
|
+
function resolveReadable(ctx, p) {
|
|
413
|
+
const primary = (0, path_utils_1.canonicalize)(ctx.root);
|
|
414
|
+
const raw = String(p ?? '').trim();
|
|
415
|
+
if (!raw)
|
|
416
|
+
throw new path_utils_1.PathError('ERR_EMPTY_PATH', 'Path is empty');
|
|
417
|
+
const abs = path.isAbsolute(raw) ? raw : path.resolve(primary, raw);
|
|
418
|
+
const cand = (0, path_utils_1.canonicalize)(abs);
|
|
419
|
+
if ((0, path_utils_1.isWithinRoot)(primary, cand, { allowEqual: true }))
|
|
420
|
+
return cand;
|
|
421
|
+
for (const r of ctx.readRoots || []) {
|
|
422
|
+
try {
|
|
423
|
+
const rr = (0, path_utils_1.canonicalize)(r);
|
|
424
|
+
if ((0, path_utils_1.isWithinRoot)(rr, cand, { allowEqual: true }))
|
|
425
|
+
return cand;
|
|
426
|
+
}
|
|
427
|
+
catch {
|
|
428
|
+
/* 无效的参考根(不存在/无权限)直接忽略 */
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
throw new path_utils_1.PathError('ERR_OUTSIDE_ROOT', `Path is outside the authorised root: ${p} (root: ${primary})`);
|
|
432
|
+
}
|
|
433
|
+
/** Default preset used when the caller did not specify one on the context. */ const DEFAULT_PRESET = 'workspace-write';
|
|
330
434
|
/** Whether the active preset permits a danger-flagged command without an
|
|
331
435
|
* approval prompt. Only the most-permissive mode allows non-critical danger
|
|
332
436
|
* commands; every other mode blocks them at the gate. */
|
|
@@ -347,7 +451,9 @@ function preExecute(ctx, tool, args) {
|
|
|
347
451
|
const preset = ctx.permissionPreset ?? DEFAULT_PRESET;
|
|
348
452
|
// 1) Permission preset: read-only denies anything that writes or executes.
|
|
349
453
|
if (preset === 'read-only') {
|
|
350
|
-
|
|
454
|
+
// MCP 工具对我们是黑盒:无法判断它到底写不写文件,所以保守地按写操作拦。
|
|
455
|
+
// 否则「只读模式」会变成一个能通过任意 MCP server 写盘的洞。
|
|
456
|
+
if (WRITE_OR_COMMAND_TOOLS.has(tool) || tool.startsWith('mcp__')) {
|
|
351
457
|
return {
|
|
352
458
|
ok: false,
|
|
353
459
|
error: `Tool "${tool}" is blocked in read-only mode (preset: read-only).`,
|
|
@@ -382,11 +488,20 @@ function preExecute(ctx, tool, args) {
|
|
|
382
488
|
}
|
|
383
489
|
}
|
|
384
490
|
// 3) Canonicalise + sensitive-path check for every path argument.
|
|
491
|
+
// 读取类工具允许落在「只读参考根」内;写入类工具一律只认主授权根——
|
|
492
|
+
// 参考目录因此天然只读,这是「让 AI 参考别的项目」时的安全边界。
|
|
493
|
+
const isWriteTool = WRITE_OR_COMMAND_TOOLS.has(tool);
|
|
385
494
|
let realRoot;
|
|
386
495
|
for (const p of pathArgsForTool(tool, args)) {
|
|
387
496
|
try {
|
|
388
|
-
|
|
389
|
-
|
|
497
|
+
let target;
|
|
498
|
+
if (isWriteTool) {
|
|
499
|
+
realRoot = realRoot ?? (0, path_utils_1.canonicalize)(ctx.root);
|
|
500
|
+
target = (0, path_utils_1.safeResolve)(ctx.root, p, realRoot, { allowEqual: true });
|
|
501
|
+
}
|
|
502
|
+
else {
|
|
503
|
+
target = resolveReadable(ctx, p);
|
|
504
|
+
}
|
|
390
505
|
if ((0, security_1.isSensitivePath)(target)) {
|
|
391
506
|
return {
|
|
392
507
|
ok: false,
|
|
@@ -442,7 +557,7 @@ const listDir = async (ctx, args) => {
|
|
|
442
557
|
const p = optionalString(args, 'path', '.') ?? '.';
|
|
443
558
|
const depth = optionalInt(args, 'depth', 1, { min: 0, max: 10 });
|
|
444
559
|
const realRoot = (0, path_utils_1.canonicalize)(ctx.root);
|
|
445
|
-
const target = (
|
|
560
|
+
const target = resolveReadable(ctx, p);
|
|
446
561
|
assertNotSensitive(target);
|
|
447
562
|
let stat;
|
|
448
563
|
try {
|
|
@@ -500,7 +615,7 @@ const readFile = async (ctx, args, signal) => {
|
|
|
500
615
|
const hasLength = args.length !== undefined && args.length !== null;
|
|
501
616
|
const length = optionalInt(args, hasLength ? 'length' : 'maxBytes', ctx.maxBytes, { min: 1, max: MAX_WRITE_BYTES });
|
|
502
617
|
const realRoot = (0, path_utils_1.canonicalize)(ctx.root);
|
|
503
|
-
const target = (
|
|
618
|
+
const target = resolveReadable(ctx, targetStr);
|
|
504
619
|
assertNotSensitive(target);
|
|
505
620
|
let stat;
|
|
506
621
|
try {
|
|
@@ -552,13 +667,59 @@ const writeFile = async (ctx, args, signal) => {
|
|
|
552
667
|
if (totalBytes > MAX_WRITE_BYTES) {
|
|
553
668
|
throw new ToolError('TOO_LARGE', `Content exceeds ${MAX_WRITE_BYTES} bytes.`);
|
|
554
669
|
}
|
|
555
|
-
|
|
556
|
-
fs.writeFileSync(target, content, { mode });
|
|
670
|
+
atomicWrite(target, content, { mode, root: ctx.root });
|
|
557
671
|
return {
|
|
558
672
|
ok: true,
|
|
559
673
|
result: { path: rel(realRoot, target), bytesWritten: totalBytes, mode },
|
|
560
674
|
};
|
|
561
675
|
};
|
|
676
|
+
/**
|
|
677
|
+
* 换行符归一化:Windows 上仓库里的文件普遍是 CRLF,而模型发过来的 oldText 是 LF。
|
|
678
|
+
*
|
|
679
|
+
* 实测踩到(用户报障的整条任务就是卡在这里):`admin/server/index.ts` 是 CRLF 文件,
|
|
680
|
+
* 模型给的多行 oldText 用的是 `\n`,逐字面比较必然 "oldText was not found in the file.",
|
|
681
|
+
* 于是模型反复重试、最后放弃并给了用户一个错误的结论(说工作区路径不对)。
|
|
682
|
+
* 单行编辑不受影响(没有换行符),所以这个 bug 只在多行编辑上暴露 —— 更难被发现。
|
|
683
|
+
*
|
|
684
|
+
* 规则:**匹配时统一按 LF 比**,**写回时还原文件原本的换行风格**(不让 git 出现整文件 diff)。
|
|
685
|
+
*/
|
|
686
|
+
function detectEol(text) {
|
|
687
|
+
const crlf = (text.match(/\r\n/g) || []).length;
|
|
688
|
+
const lf = (text.match(/(?<!\r)\n/g) || []).length;
|
|
689
|
+
return crlf > lf ? '\r\n' : '\n';
|
|
690
|
+
}
|
|
691
|
+
function toLf(text) {
|
|
692
|
+
return text.replace(/\r\n/g, '\n');
|
|
693
|
+
}
|
|
694
|
+
function applyEol(text, eol) {
|
|
695
|
+
return eol === '\r\n' ? text.replace(/\n/g, '\r\n') : text;
|
|
696
|
+
}
|
|
697
|
+
/** 读文件:返回「LF 归一化后的正文」与「该文件原本的换行符」 */
|
|
698
|
+
function readTextFile(target) {
|
|
699
|
+
const buf = fs.readFileSync(target);
|
|
700
|
+
if (!isUtf8(buf))
|
|
701
|
+
throw new ToolError('BINARY_FILE', 'only UTF-8 text files are supported.');
|
|
702
|
+
const raw = buf.toString('utf8');
|
|
703
|
+
return { text: toLf(raw), eol: detectEol(raw) };
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* 匹配失败时给模型**可操作**的信息,而不是干巴巴一句 not found。
|
|
707
|
+
* 做法:拿 oldText 的第一行去文件里找,找到就把那附近几行回给它 —— 模型据此就能
|
|
708
|
+
* 把 oldText 改对,而不是靠猜(实测它猜两轮就放弃了)。
|
|
709
|
+
*/
|
|
710
|
+
function noMatchHint(text, needle) {
|
|
711
|
+
const firstLine = needle.split('\n').map((l) => l.trim()).find((l) => l.length >= 8) || '';
|
|
712
|
+
const lines = text.split('\n');
|
|
713
|
+
const idx = firstLine ? lines.findIndex((l) => l.includes(firstLine)) : -1;
|
|
714
|
+
const head = 'oldText was not found in the file.';
|
|
715
|
+
if (idx < 0) {
|
|
716
|
+
return head + '(提示:oldText 必须与文件内容**逐字一致**,含缩进与标点;建议先用 read_file 或 str_replace_editor view 读一次再改)';
|
|
717
|
+
}
|
|
718
|
+
const from = Math.max(0, idx - 2);
|
|
719
|
+
const to = Math.min(lines.length, idx + 3);
|
|
720
|
+
const snippet = lines.slice(from, to).map((l, i) => `${from + i + 1}| ${l}`).join('\n');
|
|
721
|
+
return `${head}\n文件里最接近的位置在第 ${idx + 1} 行附近,实际内容如下(行号| 内容):\n${snippet}\n请按上面的真实内容修正 oldText 后重试。`;
|
|
722
|
+
}
|
|
562
723
|
const editFile = async (ctx, args, signal) => {
|
|
563
724
|
abortIfRequested(signal);
|
|
564
725
|
const targetStr = requireString(args, 'path');
|
|
@@ -578,16 +739,15 @@ const editFile = async (ctx, args, signal) => {
|
|
|
578
739
|
if (stat.isDirectory()) {
|
|
579
740
|
throw new ToolError('IS_DIR', `Path is a directory, not a file: ${targetStr}`);
|
|
580
741
|
}
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
const text = buf.toString('utf8');
|
|
586
|
-
const count = text.split(oldText).length - 1;
|
|
742
|
+
// 统一按 LF 匹配(CRLF 文件 + 模型给的 LF oldText 也能命中),写回时还原原文件的换行风格
|
|
743
|
+
const { text, eol } = readTextFile(target);
|
|
744
|
+
const needle = toLf(oldText);
|
|
745
|
+
const count = text.split(needle).length - 1;
|
|
587
746
|
if (count === 0) {
|
|
588
|
-
throw new ToolError('NO_MATCH',
|
|
747
|
+
throw new ToolError('NO_MATCH', noMatchHint(text, needle));
|
|
589
748
|
}
|
|
590
|
-
|
|
749
|
+
const updated = text.split(needle).join(toLf(newText));
|
|
750
|
+
atomicWrite(target, applyEol(updated, eol), { root: ctx.root });
|
|
591
751
|
return { ok: true, result: { path: rel(realRoot, target), replacements: count } };
|
|
592
752
|
};
|
|
593
753
|
const runCommandTool = async (ctx, args, signal, opts) => {
|
|
@@ -596,6 +756,21 @@ const runCommandTool = async (ctx, args, signal, opts) => {
|
|
|
596
756
|
const timeout = optionalInt(args, 'timeout', ctx.timeoutMs, { min: 1 });
|
|
597
757
|
const realRoot = (0, path_utils_1.canonicalize)(ctx.root);
|
|
598
758
|
const cwd = (0, path_utils_1.safeResolve)(ctx.root, cwdArg, realRoot, { allowEqual: true });
|
|
759
|
+
// 工作目录不存在时先说清楚。
|
|
760
|
+
// 实测踩过:用户把工作区目录删掉/改名之后,所有命令只回一个 `exit=127` 加空输出 ——
|
|
761
|
+
// 模型看不懂,用户更看不懂(127 是 shell 的「命令不存在」,这里的真实原因是
|
|
762
|
+
// 「进不去这个目录」)。宁可在这里明确拦住,也不要让它变成一串看不懂的退出码。
|
|
763
|
+
if (!fs.existsSync(cwd)) {
|
|
764
|
+
return {
|
|
765
|
+
ok: false,
|
|
766
|
+
error: `工作目录不存在:${cwd}。`
|
|
767
|
+
+ `(工作区根目录 ${realRoot} 可能已被删除或改名)`
|
|
768
|
+
+ '请在网页上重新选择/新建工作区后再试。',
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
if (!fs.statSync(cwd).isDirectory()) {
|
|
772
|
+
return { ok: false, error: `工作目录不是一个目录:${cwd}` };
|
|
773
|
+
}
|
|
599
774
|
const assessment = (0, security_1.isDangerCommand)(cmd);
|
|
600
775
|
if (assessment.danger && !permitsDangerCommand(ctx.permissionPreset, assessment.severity) && !opts?.preApproved) {
|
|
601
776
|
return {
|
|
@@ -613,6 +788,7 @@ const runCommandTool = async (ctx, args, signal, opts) => {
|
|
|
613
788
|
timeoutMs: timeout,
|
|
614
789
|
maxBytes: ctx.maxBytes,
|
|
615
790
|
signal,
|
|
791
|
+
...(opts?.onOutput ? { onOutput: opts.onOutput } : {}),
|
|
616
792
|
});
|
|
617
793
|
return {
|
|
618
794
|
ok: true,
|
|
@@ -754,7 +930,7 @@ const generate = async (ctx, args, signal) => {
|
|
|
754
930
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
755
931
|
const content = tpl.content.replace(/\{NAME\}/g, name);
|
|
756
932
|
const bytes = Buffer.byteLength(content);
|
|
757
|
-
|
|
933
|
+
atomicWrite(dest, content, { root: ctx.root });
|
|
758
934
|
written.push({ path: rel(realRoot, dest), bytes });
|
|
759
935
|
}
|
|
760
936
|
return {
|
|
@@ -779,7 +955,7 @@ const search = async (ctx, args, signal) => {
|
|
|
779
955
|
throw new ToolError('INVALID_REGEX', `Invalid search pattern: ${patternStr}`);
|
|
780
956
|
}
|
|
781
957
|
const realRoot = (0, path_utils_1.canonicalize)(ctx.root);
|
|
782
|
-
const target = (
|
|
958
|
+
const target = resolveReadable(ctx, targetStr);
|
|
783
959
|
assertNotSensitive(target);
|
|
784
960
|
let st;
|
|
785
961
|
try {
|
|
@@ -883,7 +1059,7 @@ const glob = async (ctx, args, signal) => {
|
|
|
883
1059
|
const baseStr = optionalString(args, 'path', '.') ?? '.';
|
|
884
1060
|
const maxResults = optionalInt(args, 'maxResults', 1000, { min: 1, max: 5000 });
|
|
885
1061
|
const realRoot = (0, path_utils_1.canonicalize)(ctx.root);
|
|
886
|
-
const base = (
|
|
1062
|
+
const base = resolveReadable(ctx, baseStr);
|
|
887
1063
|
assertNotSensitive(base);
|
|
888
1064
|
let st;
|
|
889
1065
|
try {
|
|
@@ -923,7 +1099,7 @@ const tree = async (ctx, args, signal) => {
|
|
|
923
1099
|
const maxEntries = optionalInt(args, 'maxEntries', 500, { min: 1, max: 5000 });
|
|
924
1100
|
const includeHidden = optionalBool(args, 'showHidden', false);
|
|
925
1101
|
const realRoot = (0, path_utils_1.canonicalize)(ctx.root);
|
|
926
|
-
const target = (
|
|
1102
|
+
const target = resolveReadable(ctx, targetStr);
|
|
927
1103
|
assertNotSensitive(target);
|
|
928
1104
|
let st;
|
|
929
1105
|
try {
|
|
@@ -1035,23 +1211,9 @@ const strReplaceEditor = async (ctx, args, signal) => {
|
|
|
1035
1211
|
throw new ToolError('ROOT_PATH', 'The authorisation root itself cannot be edited.');
|
|
1036
1212
|
}
|
|
1037
1213
|
};
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
st = fs.statSync(target);
|
|
1042
|
-
}
|
|
1043
|
-
catch {
|
|
1044
|
-
throw new ToolError('NOT_FOUND', `Path does not exist: ${pathStr}`);
|
|
1045
|
-
}
|
|
1046
|
-
if (st.isDirectory()) {
|
|
1047
|
-
throw new ToolError('IS_DIR', `Path is a directory, not a file: ${pathStr}`);
|
|
1048
|
-
}
|
|
1049
|
-
const buf = fs.readFileSync(target);
|
|
1050
|
-
if (!isUtf8(buf)) {
|
|
1051
|
-
throw new ToolError('BINARY_FILE', 'str_replace_editor only supports UTF-8 text files.');
|
|
1052
|
-
}
|
|
1053
|
-
return buf.toString('utf8');
|
|
1054
|
-
};
|
|
1214
|
+
// 注:读文件的统一入口改成了 readTextFile()(它顺带做换行归一化并返回原文件 EOL)。
|
|
1215
|
+
// 这里原先的 ensureFile() 已经没人用了 —— 它返回的是带 \r 的原始正文,
|
|
1216
|
+
// 正是「CRLF 文件上多行匹配必然失败」的根源,留着容易再被误用,所以删掉。
|
|
1055
1217
|
switch (command) {
|
|
1056
1218
|
case 'create': {
|
|
1057
1219
|
assertNotRootTarget();
|
|
@@ -1061,11 +1223,13 @@ const strReplaceEditor = async (ctx, args, signal) => {
|
|
|
1061
1223
|
throw new ToolError('EXISTS', `File already exists: ${pathStr} (pass allowOverwrite:true to replace it).`);
|
|
1062
1224
|
}
|
|
1063
1225
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
1064
|
-
|
|
1226
|
+
atomicWrite(target, fileText, { root: ctx.root });
|
|
1065
1227
|
return { ok: true, result: { path: rel(realRoot, target), bytesWritten: Buffer.byteLength(fileText) } };
|
|
1066
1228
|
}
|
|
1067
1229
|
case 'view': {
|
|
1068
|
-
|
|
1230
|
+
// 用 readTextFile:CRLF 文件的行尾 \r 会被归一化掉,否则 view 出来每行都带一个不可见字符,
|
|
1231
|
+
// 模型照着它拼 old_str 就更不可能匹配上了。
|
|
1232
|
+
const { text } = readTextFile(target);
|
|
1069
1233
|
const allLines = text === '' ? [] : text.split('\n');
|
|
1070
1234
|
const range = readLineRange(args);
|
|
1071
1235
|
const selected = range ? allLines.slice(range[0] - 1, range[1]) : allLines;
|
|
@@ -1085,36 +1249,36 @@ const strReplaceEditor = async (ctx, args, signal) => {
|
|
|
1085
1249
|
}
|
|
1086
1250
|
case 'str_replace': {
|
|
1087
1251
|
assertNotRootTarget();
|
|
1088
|
-
const text =
|
|
1089
|
-
const oldStr = requireString(args, 'old_str');
|
|
1090
|
-
const newStr = typeof args.new_str === 'string' ? args.new_str : '';
|
|
1252
|
+
const { text, eol } = readTextFile(target);
|
|
1253
|
+
const oldStr = toLf(requireString(args, 'old_str'));
|
|
1254
|
+
const newStr = toLf(typeof args.new_str === 'string' ? args.new_str : '');
|
|
1091
1255
|
const replaceAll = optionalBool(args, 'replace_all', false);
|
|
1092
1256
|
const count = text.split(oldStr).length - 1;
|
|
1093
1257
|
if (count === 0) {
|
|
1094
|
-
throw new ToolError('NO_MATCH',
|
|
1258
|
+
throw new ToolError('NO_MATCH', noMatchHint(text, oldStr));
|
|
1095
1259
|
}
|
|
1096
1260
|
if (!replaceAll && count !== 1) {
|
|
1097
1261
|
throw new ToolError('MULTIPLE_MATCHES', `old_str matched ${count} times; pass replace_all:true to replace all of them.`);
|
|
1098
1262
|
}
|
|
1099
1263
|
const updated = replaceAll ? text.split(oldStr).join(newStr) : text.replace(oldStr, newStr);
|
|
1100
|
-
|
|
1264
|
+
atomicWrite(target, applyEol(updated, eol), { root: ctx.root });
|
|
1101
1265
|
return { ok: true, result: { path: rel(realRoot, target), replacements: count } };
|
|
1102
1266
|
}
|
|
1103
1267
|
case 'insert': {
|
|
1104
1268
|
assertNotRootTarget();
|
|
1105
|
-
const text =
|
|
1106
|
-
const newStr = requireString(args, 'new_str');
|
|
1269
|
+
const { text, eol } = readTextFile(target);
|
|
1270
|
+
const newStr = toLf(requireString(args, 'new_str'));
|
|
1107
1271
|
const insertLine = optionalInt(args, 'insert_line', 0, { min: 0 });
|
|
1108
1272
|
const lines = text === '' ? [] : text.split('\n');
|
|
1109
1273
|
// insert_line is 1-based; 0 or out-of-range appends.
|
|
1110
1274
|
const pos = insertLine >= 1 && insertLine <= lines.length + 1 ? insertLine - 1 : lines.length;
|
|
1111
1275
|
lines.splice(pos, 0, newStr);
|
|
1112
|
-
|
|
1276
|
+
atomicWrite(target, applyEol(lines.join('\n'), eol), { root: ctx.root });
|
|
1113
1277
|
return { ok: true, result: { path: rel(realRoot, target), line: pos + 1, inserted: newStr } };
|
|
1114
1278
|
}
|
|
1115
1279
|
case 'delete': {
|
|
1116
1280
|
assertNotRootTarget();
|
|
1117
|
-
const text =
|
|
1281
|
+
const { text, eol } = readTextFile(target);
|
|
1118
1282
|
const range = readLineRange(args);
|
|
1119
1283
|
if (!range) {
|
|
1120
1284
|
throw new ToolError('INVALID_ARG', '"delete" requires a "view_range" [start, end].');
|
|
@@ -1126,7 +1290,7 @@ const strReplaceEditor = async (ctx, args, signal) => {
|
|
|
1126
1290
|
}
|
|
1127
1291
|
const removed = lines.slice(start - 1, Math.min(end, lines.length));
|
|
1128
1292
|
lines.splice(start - 1, Math.min(end, lines.length) - (start - 1));
|
|
1129
|
-
|
|
1293
|
+
atomicWrite(target, applyEol(lines.join('\n'), eol), { root: ctx.root });
|
|
1130
1294
|
return { ok: true, result: { path: rel(realRoot, target), lineStart: start, lineEnd: Math.min(end, start + removed.length - 1), removed: removed.length } };
|
|
1131
1295
|
}
|
|
1132
1296
|
default:
|
|
@@ -1404,13 +1568,18 @@ const fsDrives = async (_ctx) => {
|
|
|
1404
1568
|
* - 仍拦截敏感/凭证路径(.env、id_rsa、C:\Windows\System32 等);
|
|
1405
1569
|
* - 只返回元数据(名称/类型/大小/时间),不读文件内容。
|
|
1406
1570
|
*/
|
|
1407
|
-
const fsBrowse = async (
|
|
1571
|
+
const fsBrowse = async (ctx, args) => {
|
|
1408
1572
|
const p = String(args.path || '').trim();
|
|
1409
1573
|
if (!p)
|
|
1410
1574
|
return { ok: true, result: { drives: listDriveRoots(), prompt: 'drives' } };
|
|
1411
1575
|
let abs;
|
|
1412
1576
|
try {
|
|
1413
|
-
|
|
1577
|
+
// 相对路径必须按【授权根】解析,与 read_file/glob 等所有其它工具保持一致。
|
|
1578
|
+
// 历史实现直接 canonicalize(p)(= 进程 cwd),有两个真问题:
|
|
1579
|
+
// 1) 平台传相对路径(如 'gen/music')时会去找 <agent cwd>/gen/music,用户看到「路径不存在」;
|
|
1580
|
+
// 2) 前置校验按授权根放行,工具本体却按 cwd 读——两边解析的不是同一个路径,
|
|
1581
|
+
// 相对路径可以越出授权根去列别的目录。
|
|
1582
|
+
abs = resolveReadable(ctx, p);
|
|
1414
1583
|
}
|
|
1415
1584
|
catch (e) {
|
|
1416
1585
|
return { ok: false, error: `无效路径: ${e?.message || e}` };
|
|
@@ -1450,10 +1619,10 @@ const fsBrowse = async (_ctx, args) => {
|
|
|
1450
1619
|
return { ok: true, result: { path: abs, entries } };
|
|
1451
1620
|
};
|
|
1452
1621
|
/** 用系统原生"选择文件夹"对话框返回绝对路径(Windows/Linux/macOS)。取消或不可用返回 null。 */
|
|
1453
|
-
function execFileP(cmd, args, timeoutMs) {
|
|
1622
|
+
function execFileP(cmd, args, timeoutMs, windowsHide = true) {
|
|
1454
1623
|
return new Promise((resolve) => {
|
|
1455
1624
|
try {
|
|
1456
|
-
childProcess.execFile(cmd, args, { timeout: timeoutMs, windowsHide
|
|
1625
|
+
childProcess.execFile(cmd, args, { timeout: timeoutMs, windowsHide, maxBuffer: 1024 * 1024 }, (err, stdout, stderr) => {
|
|
1457
1626
|
resolve(String(stdout || '').trim());
|
|
1458
1627
|
});
|
|
1459
1628
|
}
|
|
@@ -1462,22 +1631,57 @@ function execFileP(cmd, args, timeoutMs) {
|
|
|
1462
1631
|
}
|
|
1463
1632
|
});
|
|
1464
1633
|
}
|
|
1634
|
+
async function pickFolderViaWindowsDialog() {
|
|
1635
|
+
/**
|
|
1636
|
+
* 用 WSH(wscript.exe 是 GUI 子系统,不会闪现控制台窗口)+ Shell.Application
|
|
1637
|
+
* 弹出资源管理器风格的「选择文件夹」对话框,选中的路径写入临时文件后返回。
|
|
1638
|
+
*
|
|
1639
|
+
* 注意:子进程不能带 windowsHide:true(或其它隐藏控制台的方式)启动——实测在该
|
|
1640
|
+
* 启动方式下模态对话框(WinForms FolderBrowserDialog / Shell BrowseForFolder)
|
|
1641
|
+
* 都不会显示窗口,ShowDialog 一直挂到超时,用户毫无感知。wscript 无控制台,
|
|
1642
|
+
* 用默认启动方式即可正常显示对话框。
|
|
1643
|
+
*/
|
|
1644
|
+
const base = path.join(os.tmpdir(), `ysagc-pick-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
|
1645
|
+
const vbsPath = `${base}.vbs`;
|
|
1646
|
+
const outPath = `${base}.txt`;
|
|
1647
|
+
const vbs = [
|
|
1648
|
+
'Set sh = CreateObject("Shell.Application")',
|
|
1649
|
+
'Set f = sh.BrowseForFolder(0, "选择工作区目录", 65, "")',
|
|
1650
|
+
'If Not f Is Nothing Then',
|
|
1651
|
+
' Set fs = CreateObject("Scripting.FileSystemObject")',
|
|
1652
|
+
// 65 = BIF_NEWDIALOGSTYLE(64) + BIF_RETURNONLYFSDIRS(1):现代风格 + 只选文件系统目录
|
|
1653
|
+
` Set out = fs.OpenTextFile("${outPath.replace(/"/g, '""')}", 2, True)`,
|
|
1654
|
+
' out.WriteLine f.Items().Item().Path',
|
|
1655
|
+
' out.Close',
|
|
1656
|
+
'End If',
|
|
1657
|
+
].join('\r\n');
|
|
1658
|
+
try {
|
|
1659
|
+
// WSH 按系统 ANSI 代码页解析 .vbs,中文标题必须以 UTF-16LE + BOM 写入
|
|
1660
|
+
fs.writeFileSync(vbsPath, Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(vbs, 'utf16le')]));
|
|
1661
|
+
// 3 分钟内选完(含折叠目录的时间);后端等待 200s,略大于此值
|
|
1662
|
+
await execFileP('wscript.exe', ['//Nologo', vbsPath], 180000, false);
|
|
1663
|
+
if (!fs.existsSync(outPath))
|
|
1664
|
+
return null; // 用户取消
|
|
1665
|
+
return fs.readFileSync(outPath, 'utf8').trim() || null;
|
|
1666
|
+
}
|
|
1667
|
+
catch {
|
|
1668
|
+
return null;
|
|
1669
|
+
}
|
|
1670
|
+
finally {
|
|
1671
|
+
try {
|
|
1672
|
+
fs.unlinkSync(vbsPath);
|
|
1673
|
+
}
|
|
1674
|
+
catch { /* best-effort */ }
|
|
1675
|
+
try {
|
|
1676
|
+
fs.unlinkSync(outPath);
|
|
1677
|
+
}
|
|
1678
|
+
catch { /* best-effort */ }
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1465
1681
|
async function pickFolderViaOsDialog() {
|
|
1466
1682
|
try {
|
|
1467
1683
|
if (process.platform === 'win32') {
|
|
1468
|
-
|
|
1469
|
-
// 用 -STA 单线程 + EnableVisualStyles,确保对话框在交互式桌面上真实弹出。
|
|
1470
|
-
const script = [
|
|
1471
|
-
'Add-Type -AssemblyName System.Windows.Forms',
|
|
1472
|
-
'[System.Windows.Forms.Application]::EnableVisualStyles()',
|
|
1473
|
-
'$d = New-Object System.Windows.Forms.FolderBrowserDialog',
|
|
1474
|
-
'$d.Description = "选择工作区目录"',
|
|
1475
|
-
'$d.ShowNewFolderButton = $true',
|
|
1476
|
-
'[System.Windows.Forms.DialogResult]$r = $d.ShowDialog()',
|
|
1477
|
-
'if ($r -eq [System.Windows.Forms.DialogResult]::OK) { $d.SelectedPath }',
|
|
1478
|
-
].join('; ');
|
|
1479
|
-
const out = await execFileP('powershell.exe', ['-NoProfile', '-STA', '-Command', script], 55000);
|
|
1480
|
-
return out || null;
|
|
1684
|
+
return await pickFolderViaWindowsDialog();
|
|
1481
1685
|
}
|
|
1482
1686
|
if (process.platform === 'linux') {
|
|
1483
1687
|
const out = await execFileP('zenity', ['--file-selection', '--directory', '--title=选择工作区目录'], 55000);
|
|
@@ -1531,7 +1735,7 @@ const writeBinary = async (ctx, args) => {
|
|
|
1531
1735
|
throw new ToolError('DENIED', `File type "."${ext || '(none)'}" is blocked by safety policy. Use install_app / run_command with approval for executables.`);
|
|
1532
1736
|
}
|
|
1533
1737
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
1534
|
-
|
|
1738
|
+
atomicWrite(target, buf, { root: ctx.root });
|
|
1535
1739
|
return { ok: true, result: { path: rel(realRoot, target), bytes: buf.length, written: true } };
|
|
1536
1740
|
};
|
|
1537
1741
|
// --- open_app: 在本机打开网页/应用/文件夹(无副作用,默认浏览器/系统打开)---
|
|
@@ -1542,6 +1746,19 @@ const DENIED_APPS = new Set([
|
|
|
1542
1746
|
'shutdown', 'sdelete', 'wevtutil', 'schtasks', 'taskkill', 'format', 'fdisk',
|
|
1543
1747
|
]);
|
|
1544
1748
|
const ALLOWED_BIN_EXT = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'wav', 'mp3', 'json', 'html', 'htm', 'txt', 'md']);
|
|
1749
|
+
/**
|
|
1750
|
+
* open_app 拒绝直接拉起的可执行/脚本扩展名。
|
|
1751
|
+
*
|
|
1752
|
+
* 为什么需要这一层:open_app 走的是「系统默认动作」,在 Windows 上 `cmd /c start` 一个
|
|
1753
|
+
* .bat/.ps1/.exe 就等于执行它。而它既没有 run_command 的危险命令检测,也不在平台的确认门里,
|
|
1754
|
+
* 于是形成一条绕过所有确认的代码执行路径:先 write_file 落一个脚本,再 open_app 打开它。
|
|
1755
|
+
* 打开文档/图片/网页是「看」,打开脚本/程序是「执行」—— 后者必须走需确认的命令通道。
|
|
1756
|
+
*/
|
|
1757
|
+
const DENIED_OPEN_EXT = new Set([
|
|
1758
|
+
'exe', 'com', 'scr', 'bat', 'cmd', 'ps1', 'psm1', 'vbs', 'vbe', 'js', 'jse', 'wsf', 'wsh',
|
|
1759
|
+
'msi', 'msp', 'lnk', 'url', 'hta', 'reg', 'jar', 'py', 'pyw', 'sh', 'bash', 'zsh', 'pl', 'rb',
|
|
1760
|
+
'app', 'dmg', 'pkg', 'deb', 'rpm', 'apk', 'gadget', 'cpl', 'scf', 'inf', 'chm',
|
|
1761
|
+
]);
|
|
1545
1762
|
const openApp = async (ctx, args, signal) => {
|
|
1546
1763
|
abortIfRequested(signal);
|
|
1547
1764
|
const target = requireString(args, 'target').trim();
|
|
@@ -1550,11 +1767,23 @@ const openApp = async (ctx, args, signal) => {
|
|
|
1550
1767
|
if (/^(https?:\/\/)/i.test(target)) {
|
|
1551
1768
|
// 允许:默认浏览器打开
|
|
1552
1769
|
}
|
|
1553
|
-
else if (
|
|
1770
|
+
else if (target.startsWith('\\\\') || target.startsWith('//')) {
|
|
1771
|
+
// UNC 路径:既是「拉起远端可执行文件」,光做存在性探测就会把 NTLM 哈希发给对方主机。
|
|
1772
|
+
throw new ToolError('DENIED', 'UNC/网络路径不允许通过 open_app 打开。');
|
|
1773
|
+
}
|
|
1774
|
+
else if (/^[a-zA-Z]:[\\/]/.test(target) || target.startsWith('/')) {
|
|
1554
1775
|
if (!fs.existsSync(target))
|
|
1555
1776
|
throw new ToolError('NOT_FOUND', `Path does not exist: ${target}`);
|
|
1556
1777
|
if ((0, security_1.isSensitivePath)(target))
|
|
1557
1778
|
throw new ToolError('SENSITIVE', 'Opening this path is blocked by safety policy.');
|
|
1779
|
+
const ext = path.extname(target).replace(/^\./, '').toLowerCase();
|
|
1780
|
+
if (DENIED_OPEN_EXT.has(ext)) {
|
|
1781
|
+
throw new ToolError('DENIED', `Refusing to launch "${path.basename(target)}": executable/script targets must go through a confirm-required command.`);
|
|
1782
|
+
}
|
|
1783
|
+
const base = path.basename(target).toLowerCase().replace(/\.exe$|\.bat$|\.cmd$|\.ps1$/, '');
|
|
1784
|
+
if (DENIED_APPS.has(base)) {
|
|
1785
|
+
throw new ToolError('DENIED', `"${target}" is blocked by safety policy.`);
|
|
1786
|
+
}
|
|
1558
1787
|
}
|
|
1559
1788
|
else {
|
|
1560
1789
|
const base = path.basename(target).toLowerCase().replace(/\.exe$|\.bat$|\.cmd$|\.ps1$/, '');
|
|
@@ -1665,6 +1894,86 @@ const installApp = async (ctx, args, signal, opts) => {
|
|
|
1665
1894
|
return { ok: false, error: `install error: ${msg}` };
|
|
1666
1895
|
}
|
|
1667
1896
|
};
|
|
1897
|
+
// ---------------------------------------------------------------------------
|
|
1898
|
+
// MCP:每个授权根一个管理器
|
|
1899
|
+
// ---------------------------------------------------------------------------
|
|
1900
|
+
/** MCP server 是按「工作区」配置拉起的,所以管理器也按授权根分桶 —— 切工作区互不干扰。 */
|
|
1901
|
+
const mcpManagers = new Map();
|
|
1902
|
+
function mcpManagerFor(ctx) {
|
|
1903
|
+
const key = (0, path_utils_1.canonicalize)(ctx.root);
|
|
1904
|
+
let m = mcpManagers.get(key);
|
|
1905
|
+
if (!m) {
|
|
1906
|
+
m = new mcp_1.McpManager(key, (msg) => ctx.logger.info(msg));
|
|
1907
|
+
mcpManagers.set(key, m);
|
|
1908
|
+
}
|
|
1909
|
+
return m;
|
|
1910
|
+
}
|
|
1911
|
+
/**
|
|
1912
|
+
* 供 WS 层调用:把当前工作区可用的 MCP 工具清单报给平台,让模型直接看到这些工具。
|
|
1913
|
+
* 失败/未配置都返回空数组(MCP 是可选能力,不该拖垮 Agent 上线)。
|
|
1914
|
+
*/
|
|
1915
|
+
async function listMcpTools(root, logger) {
|
|
1916
|
+
if ((0, mcp_1.mcpDisabled)())
|
|
1917
|
+
return [];
|
|
1918
|
+
try {
|
|
1919
|
+
const key = (0, path_utils_1.canonicalize)(root);
|
|
1920
|
+
let m = mcpManagers.get(key);
|
|
1921
|
+
if (!m) {
|
|
1922
|
+
m = new mcp_1.McpManager(key, (msg) => logger?.info(msg));
|
|
1923
|
+
mcpManagers.set(key, m);
|
|
1924
|
+
}
|
|
1925
|
+
return await m.listTools();
|
|
1926
|
+
}
|
|
1927
|
+
catch {
|
|
1928
|
+
return [];
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
/** 配置了但启动失败的 MCP server(用于向平台如实说明,而不是让工具静默消失) */
|
|
1932
|
+
function mcpFailuresFor(root) {
|
|
1933
|
+
try {
|
|
1934
|
+
return mcpManagers.get((0, path_utils_1.canonicalize)(root))?.failures() || [];
|
|
1935
|
+
}
|
|
1936
|
+
catch {
|
|
1937
|
+
return [];
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
/** 进程退出时收尾:把所有 MCP 子进程杀掉,避免留下孤儿进程 */
|
|
1941
|
+
function shutdownMcp() {
|
|
1942
|
+
for (const [, m] of mcpManagers) {
|
|
1943
|
+
try {
|
|
1944
|
+
m.shutdown();
|
|
1945
|
+
}
|
|
1946
|
+
catch { /* 忽略 */ }
|
|
1947
|
+
}
|
|
1948
|
+
mcpManagers.clear();
|
|
1949
|
+
}
|
|
1950
|
+
/**
|
|
1951
|
+
* mcp_list:列出当前工作区配置的 MCP server 与它们暴露的工具。
|
|
1952
|
+
* 平台会把工具清单合并进模型工具表(正常情况下模型不需要调这个);
|
|
1953
|
+
* 这里保留是为了「平台没同步到」或模型想确认参数时有个兜底。
|
|
1954
|
+
*/
|
|
1955
|
+
const mcpList = async (ctx) => {
|
|
1956
|
+
if ((0, mcp_1.mcpDisabled)()) {
|
|
1957
|
+
return { ok: true, result: { enabled: false, count: 0, tools: [], note: 'MCP 已通过环境变量 YSAGC_MCP=0 关闭' } };
|
|
1958
|
+
}
|
|
1959
|
+
const root = (0, path_utils_1.canonicalize)(ctx.root);
|
|
1960
|
+
const m = mcpManagerFor(ctx);
|
|
1961
|
+
const tools = await m.listTools();
|
|
1962
|
+
const failures = m.failures();
|
|
1963
|
+
return {
|
|
1964
|
+
ok: true,
|
|
1965
|
+
result: {
|
|
1966
|
+
enabled: true,
|
|
1967
|
+
config_path: (0, mcp_1.mcpConfigPath)(root),
|
|
1968
|
+
count: tools.length,
|
|
1969
|
+
tools: tools.map((t) => ({ name: t.name, description: t.description })),
|
|
1970
|
+
failures,
|
|
1971
|
+
note: tools.length
|
|
1972
|
+
? '调用方式:直接用上面列出的工具名(mcp__server__tool)调用即可,参数按各工具的 inputSchema 传。'
|
|
1973
|
+
: `当前工作区没有可用的 MCP 工具。如需接入,编辑 ${(0, mcp_1.mcpConfigPath)(root)} 后重新调用本工具。`,
|
|
1974
|
+
},
|
|
1975
|
+
};
|
|
1976
|
+
};
|
|
1668
1977
|
const TOOLS = {
|
|
1669
1978
|
list_dir: listDir,
|
|
1670
1979
|
fs_drives: fsDrives,
|
|
@@ -1689,6 +1998,10 @@ const TOOLS = {
|
|
|
1689
1998
|
write_binary: writeBinary,
|
|
1690
1999
|
open_app: openApp,
|
|
1691
2000
|
install_app: installApp,
|
|
2001
|
+
screenshot: screenshot_1.screenshotTool,
|
|
2002
|
+
browser: browser_1.browserTool,
|
|
2003
|
+
remember: memory_1.rememberTool,
|
|
2004
|
+
mcp_list: mcpList,
|
|
1692
2005
|
};
|
|
1693
2006
|
/** Build a tool registry bound to a single execution context. */
|
|
1694
2007
|
function createToolRegistry(ctx) {
|
|
@@ -1697,12 +2010,16 @@ function createToolRegistry(ctx) {
|
|
|
1697
2010
|
ctx,
|
|
1698
2011
|
tools: names,
|
|
1699
2012
|
has(name) {
|
|
1700
|
-
|
|
2013
|
+
// MCP 工具名是动态的,静态表里查不到;按前缀放行,真正的可用性由 Manager 判断。
|
|
2014
|
+
return Object.prototype.hasOwnProperty.call(TOOLS, name) || (0, mcp_1.parseMcpToolName)(name) !== null;
|
|
1701
2015
|
},
|
|
1702
|
-
async dispatch(name, args, signal, rootOverride, preApproved) {
|
|
2016
|
+
async dispatch(name, args, signal, rootOverride, preApproved, onProgress, readRootsOverride) {
|
|
1703
2017
|
const started = Date.now();
|
|
1704
2018
|
const executor = TOOLS[name];
|
|
1705
|
-
|
|
2019
|
+
// mcp__<server>__<tool>:MCP 工具是动态的(由工作区配置决定),没法写进静态 TOOLS 表,
|
|
2020
|
+
// 所以在这里按名字前缀分流。走的是同一套权限闸门 / hook / 密钥脱敏。
|
|
2021
|
+
const mcpTarget = (0, mcp_1.parseMcpToolName)(name);
|
|
2022
|
+
if (!executor && !mcpTarget) {
|
|
1706
2023
|
return { ok: false, error: `Unknown tool: ${name}`, durationMs: 0 };
|
|
1707
2024
|
}
|
|
1708
2025
|
// 平台可把“用户所选工作区”作为本次调用的授权根:工作区是用户的明确选择,允许任意绝对路径(不限于启动时的 --root);仍拦截系统/凭证敏感路径,执行边界就是该工作区(safeResolve/assertNotSensitive 保证)。
|
|
@@ -1721,6 +2038,21 @@ function createToolRegistry(ctx) {
|
|
|
1721
2038
|
/* 无效的 override:沿用原授权根 */
|
|
1722
2039
|
}
|
|
1723
2040
|
}
|
|
2041
|
+
// 只读参考根:逐个规范化,无效/敏感的直接丢弃(宁可少一个参考目录,也不要放开边界)
|
|
2042
|
+
if (readRootsOverride && readRootsOverride.length) {
|
|
2043
|
+
const cleaned = [];
|
|
2044
|
+
for (const r of readRootsOverride.slice(0, 10)) {
|
|
2045
|
+
try {
|
|
2046
|
+
const rc = (0, path_utils_1.canonicalize)(r);
|
|
2047
|
+
if (!(0, security_1.isSensitivePath)(rc))
|
|
2048
|
+
cleaned.push(rc);
|
|
2049
|
+
}
|
|
2050
|
+
catch {
|
|
2051
|
+
/* 忽略无效参考根 */
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
effCtx = { ...effCtx, readRoots: cleaned };
|
|
2055
|
+
}
|
|
1724
2056
|
try {
|
|
1725
2057
|
// Guarded gate: pre-execution check (permission preset, danger
|
|
1726
2058
|
// commands, canonicalise + sensitive-path). A platform-preApproved
|
|
@@ -1741,13 +2073,73 @@ function createToolRegistry(ctx) {
|
|
|
1741
2073
|
}
|
|
1742
2074
|
// A cancel message may arrive while pre-checking; honour it.
|
|
1743
2075
|
abortIfRequested(signal);
|
|
1744
|
-
|
|
2076
|
+
// ===== 前置 hook(工作区 .ysagc/hooks.json 里配的)=====
|
|
2077
|
+
// 放在权限检查之后:被安全策略拦下的调用不该触发用户的 hook(否则 hook 会对着
|
|
2078
|
+
// 一次根本没发生的操作跑)。hook 失败**不阻断**本次工具调用 —— 它是辅助动作,
|
|
2079
|
+
// 不能因为 prettier 没装就让「写文件」这件事失败;失败信息会带回给模型。
|
|
2080
|
+
let preHookNote = null;
|
|
2081
|
+
try {
|
|
2082
|
+
const ph = await (0, hooks_1.runPreHooks)(effCtx, name, (args ?? {}));
|
|
2083
|
+
if (ph)
|
|
2084
|
+
preHookNote = ph.ok
|
|
2085
|
+
? `前置 hook \`${ph.command}\` 执行成功${ph.output ? `:${ph.output}` : ''}`
|
|
2086
|
+
: `前置 hook \`${ph.command}\` 执行失败${ph.output ? `:${ph.output}` : ''}`;
|
|
2087
|
+
}
|
|
2088
|
+
catch { /* hook 机制自身出错不影响工具 */ }
|
|
2089
|
+
const outcome = mcpTarget
|
|
2090
|
+
? await (async () => {
|
|
2091
|
+
try {
|
|
2092
|
+
// 用 effCtx(平台下发的当前工作区;没有 override 时就是 Agent 授权根):
|
|
2093
|
+
// MCP 配置跟着**工作区**走 —— `.ysagc/mcp.json` 就放在工作区根目录,
|
|
2094
|
+
// mcp_list 与平台侧工具表也都按工作区算。
|
|
2095
|
+
// 这里以前写的是 ctx(Agent 授权根),于是「探测到的工具」和「实际能调到的 server」
|
|
2096
|
+
// 读的不是同一份配置,实测表现是:工具表里有 mcp__testbox__add,
|
|
2097
|
+
// 一调用却回「MCP server "testbox" 未配置或未启动」。
|
|
2098
|
+
const raw = await mcpManagerFor(effCtx).callByName(name, (args ?? {}));
|
|
2099
|
+
const r = (0, mcp_1.renderMcpResult)(raw);
|
|
2100
|
+
return r.ok
|
|
2101
|
+
? { ok: true, result: { mcp: name, content: r.text } }
|
|
2102
|
+
: { ok: false, error: `MCP 工具 ${name} 返回错误:${r.text}` };
|
|
2103
|
+
}
|
|
2104
|
+
catch (err) {
|
|
2105
|
+
return { ok: false, error: err?.message || String(err) };
|
|
2106
|
+
}
|
|
2107
|
+
})()
|
|
2108
|
+
: await executor(effCtx, args ?? {}, signal, {
|
|
2109
|
+
preApproved: preApproved === true,
|
|
2110
|
+
...(onProgress ? { onOutput: onProgress } : {}),
|
|
2111
|
+
});
|
|
2112
|
+
// ===== 后置 hook:只在工具**成功**后跑 =====
|
|
2113
|
+
// 工具失败说明工作区可能停在半成品状态,这时再跑格式化只会对着残缺内容动手,
|
|
2114
|
+
// 还可能把失败原因搅乱。所以失败的工具不触发后置 hook。
|
|
2115
|
+
let hookNote = preHookNote;
|
|
2116
|
+
if (outcome.ok) {
|
|
2117
|
+
try {
|
|
2118
|
+
const po = await (0, hooks_1.runPostHooks)(effCtx, name, (args ?? {}));
|
|
2119
|
+
if (po) {
|
|
2120
|
+
const note = po.ok
|
|
2121
|
+
? `后置 hook \`${po.command}\` 执行成功${po.output ? `:${po.output}` : ''}`
|
|
2122
|
+
: `后置 hook \`${po.command}\` 执行失败${po.output ? `:${po.output}` : ''}`;
|
|
2123
|
+
hookNote = hookNote ? `${hookNote}\n${note}` : note;
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
catch { /* 同上 */ }
|
|
2127
|
+
}
|
|
2128
|
+
// 把 hook 结果并进 result:模型和用户都能看到「自动跑了什么、成没成」
|
|
2129
|
+
const withHook = hookNote
|
|
2130
|
+
? {
|
|
2131
|
+
...outcome,
|
|
2132
|
+
result: (outcome.result && typeof outcome.result === 'object')
|
|
2133
|
+
? { ...outcome.result, hook: hookNote }
|
|
2134
|
+
: { hook: hookNote },
|
|
2135
|
+
}
|
|
2136
|
+
: outcome;
|
|
1745
2137
|
const result = {
|
|
1746
|
-
ok:
|
|
1747
|
-
result:
|
|
1748
|
-
error:
|
|
2138
|
+
ok: withHook.ok,
|
|
2139
|
+
result: withHook.result,
|
|
2140
|
+
error: withHook.error,
|
|
1749
2141
|
durationMs: Date.now() - started,
|
|
1750
|
-
...(
|
|
2142
|
+
...(withHook.needsApproval ? { needsApproval: true } : {}),
|
|
1751
2143
|
};
|
|
1752
2144
|
// Guarded gate: post-execution check (mask secrets + record reason).
|
|
1753
2145
|
const post = postExecute(ctx, name, result);
|