mingdao-harness 0.3.2 → 0.4.1
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/docs/CONFIG.md +27 -2
- package/docs/DEVELOPER.md +126 -0
- package/docs/PLAN-v0.4.0.md +38 -0
- package/docs/PLAN-v0.4.1.md +65 -0
- package/docs/STRATEGY-NEXT.md +175 -0
- package/package.json +2 -1
- package/presets/local-audit.json +10 -0
- package/src/agent.js +60 -12
- package/src/batch.js +4 -3
- package/src/cli.js +45 -6
- package/src/commands/diagnose.js +15 -0
- package/src/commands/repl.js +50 -5
- package/src/context.js +3 -3
- package/src/cost-guard.js +3 -1
- package/src/index.js +30 -4
- package/src/mcp.js +7 -2
- package/src/memory.js +5 -1
- package/src/permissions.js +4 -2
- package/src/presets.js +175 -0
- package/src/prompts.js +5 -2
- package/src/routing.js +5 -1
- package/src/tasks/worker.js +3 -0
- package/src/tools/bash.js +10 -2
- package/src/tools/fetch.js +34 -2
- package/src/tools/fs-tools.js +66 -10
- package/src/tools/git.js +10 -2
- package/src/tools/index.js +145 -1
- package/src/web/app.js +10 -1
- package/src/web/index.html +4 -1
- package/src/web/routes/domains/misc.js +8 -0
- package/src/web/server.js +36 -4
package/src/tools/fetch.js
CHANGED
|
@@ -9,7 +9,7 @@ function isPrivateHost(/** @type {string} */ hostname) {
|
|
|
9
9
|
if (h === 'localhost' || h.endsWith('.localhost') || h === '::1') return true;
|
|
10
10
|
if (h.includes(':')) {
|
|
11
11
|
if (/^::ffff:/.test(h)) return isPrivateHost(h.slice(7));
|
|
12
|
-
return /^fe[89ab]/.test(h) || /^f[
|
|
12
|
+
return /^fe[89ab]/.test(h) || /^f[cd]/.test(h) || h === '::' || h === '::1';
|
|
13
13
|
}
|
|
14
14
|
const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
15
15
|
if (!m) return false;
|
|
@@ -39,10 +39,42 @@ export async function runFetch(/** @type {any} */ args, /** @type {any} */ _ctx)
|
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
if (blocked) return { ok: false, error: `拒绝访问内网/本机地址(${host})——SSRF 防护。` };
|
|
42
|
+
// 302 重定向复检(P0 安全,v0.4.1):redirect:'follow' 只检查初始 URL,攻击者可用公网 302 跳回内网。
|
|
43
|
+
// 改 redirect:'manual' 手动跟随,每一跳重新 isPrivateHost + DNS 复检,跳数上限 5。
|
|
42
44
|
const ac = new AbortController();
|
|
43
45
|
const timer = setTimeout(() => ac.abort(), 15000);
|
|
44
46
|
try {
|
|
45
|
-
|
|
47
|
+
let cur = u;
|
|
48
|
+
let res = /** @type {any} */ (null);
|
|
49
|
+
for (let hop = 0; hop <= 5; hop++) {
|
|
50
|
+
if (hop > 5) return { ok: false, error: '重定向次数超过上限(5 跳)。' };
|
|
51
|
+
const ch = String(cur.hostname || '').toLowerCase();
|
|
52
|
+
let hopBlocked = isPrivateHost(ch);
|
|
53
|
+
if (!hopBlocked && ch && ch !== 'localhost' && !/^\d{1,3}(\.\d{1,3}){3}$/.test(ch)) {
|
|
54
|
+
try {
|
|
55
|
+
const addrs = await lookup(ch, { all: true, verbatim: true });
|
|
56
|
+
hopBlocked = addrs.some((/** @type {any} */ a) => isPrivateHost(a.address));
|
|
57
|
+
} catch {
|
|
58
|
+
// DNS 解析失败:放行,连接阶段会报错
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (hopBlocked) return { ok: false, error: `拒绝访问内网/本机地址(${ch})——SSRF 重定向防护。` };
|
|
62
|
+
res = await fetch(cur, { signal: ac.signal, redirect: 'manual' });
|
|
63
|
+
if (res.status >= 300 && res.status < 400) {
|
|
64
|
+
const loc = res.headers.get('location');
|
|
65
|
+
if (!loc) break; // 无 Location:按最终响应处理
|
|
66
|
+
try {
|
|
67
|
+
cur = new URL(loc, cur);
|
|
68
|
+
} catch {
|
|
69
|
+
return { ok: false, error: `非法重定向地址:${loc}` };
|
|
70
|
+
}
|
|
71
|
+
if (cur.protocol !== 'http:' && cur.protocol !== 'https:') {
|
|
72
|
+
return { ok: false, error: '重定向到非 http(s) 地址,已拒绝。' };
|
|
73
|
+
}
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
46
78
|
const buf = await res.arrayBuffer();
|
|
47
79
|
clearTimeout(timer);
|
|
48
80
|
if (buf.byteLength > 512 * 1024) return { ok: false, error: `响应超过 512KB 上限(实际 ${buf.byteLength} 字节)。` };
|
package/src/tools/fs-tools.js
CHANGED
|
@@ -19,6 +19,53 @@ function resolvePath(cwd, p) {
|
|
|
19
19
|
return path.resolve(cwd, p);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
// —— 路径穿越防护(P0 安全,v0.4.1)——
|
|
23
|
+
// 文件工具限定在「工作目录 + config.fsAllowDirs 白名单」内;realpath 逐级校验防软链接逃逸。
|
|
24
|
+
// auto 模式下模型(或被提示注入诱导)也无法 read ~/.ssh、~/.mingdao/credentials.json 等越界文件。
|
|
25
|
+
// 白名单:config.json 的 fsAllowDirs 数组(绝对目录),需要访问工作目录外时显式添加。
|
|
26
|
+
function normCmp(/** @type {any} */ p) {
|
|
27
|
+
return process.platform === 'win32' ? String(p).toLowerCase() : String(p);
|
|
28
|
+
}
|
|
29
|
+
function realPathOrNull(/** @type {any} */ p) {
|
|
30
|
+
try {
|
|
31
|
+
return normCmp(fs.realpathSync(p));
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// 判断 target 是否在 root 内:对 target 逐级向上找最近的已存在祖先 realpath,再与 root realpath 比较。
|
|
37
|
+
// (write 新文件时父目录可能尚不存在,逐级向上即可正确处理;软链接已被 realpath 展开。)
|
|
38
|
+
function withinRoot(/** @type {any} */ root, /** @type {any} */ target) {
|
|
39
|
+
const rr = realPathOrNull(root);
|
|
40
|
+
if (!rr) return false;
|
|
41
|
+
let cur = target;
|
|
42
|
+
for (let i = 0; i < 64; i++) {
|
|
43
|
+
const rp = realPathOrNull(cur);
|
|
44
|
+
if (rp) return rp === rr || rp.startsWith(rr + path.sep);
|
|
45
|
+
const parent = path.dirname(cur);
|
|
46
|
+
if (parent === cur) return false;
|
|
47
|
+
cur = parent;
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* 解析路径并做工作目录边界检查。
|
|
53
|
+
* @param {any} ctx
|
|
54
|
+
* @param {any} p
|
|
55
|
+
* @returns {{ ok: true, path: string } | { ok: false, error: string }}
|
|
56
|
+
*/
|
|
57
|
+
function boundedPath(/** @type {any} */ ctx, /** @type {any} */ p) {
|
|
58
|
+
const cwd = ctx?.cwd || ctx?.workingDir || process.cwd();
|
|
59
|
+
const raw = resolvePath(cwd, p ?? '');
|
|
60
|
+
if (!raw) return { ok: false, error: '缺少 path 参数。' };
|
|
61
|
+
const allowDirs = Array.isArray(ctx?.cfg?.fsAllowDirs) ? ctx.cfg.fsAllowDirs : [];
|
|
62
|
+
const roots = [cwd, ...allowDirs.map((/** @type {any} */ d) => path.resolve(String(d)))];
|
|
63
|
+
for (const r of roots) {
|
|
64
|
+
if (withinRoot(r, raw)) return { ok: true, path: raw };
|
|
65
|
+
}
|
|
66
|
+
return { ok: false, error: `路径越界(工作目录外):${raw}。如需访问请将目录加入 config.fsAllowDirs 白名单。` };
|
|
67
|
+
}
|
|
68
|
+
|
|
22
69
|
/**
|
|
23
70
|
* @param {any} buf
|
|
24
71
|
*/
|
|
@@ -76,7 +123,7 @@ function backup(ctx, p) {
|
|
|
76
123
|
export function undo(args, ctx) {
|
|
77
124
|
const store = ctx?.undoStore?.backups;
|
|
78
125
|
if (!(store instanceof Map) || !store.size) return { ok: false, error: '没有可撤销的修改。' };
|
|
79
|
-
const p = args.path ?
|
|
126
|
+
const p = args.path ? (() => { const bb = boundedPath(ctx, args.path); return bb.ok ? bb.path : null; })() : null;
|
|
80
127
|
if (p) {
|
|
81
128
|
const list = store.get(p);
|
|
82
129
|
if (!list?.length) return { ok: false, error: `${p} 没有可撤销的修改记录。` };
|
|
@@ -124,8 +171,9 @@ export function invalidateReadCache(p) {
|
|
|
124
171
|
*/
|
|
125
172
|
export function read(args, ctx) {
|
|
126
173
|
try {
|
|
127
|
-
const
|
|
128
|
-
if (!
|
|
174
|
+
const b = boundedPath(ctx, args.path ?? '');
|
|
175
|
+
if (!b.ok) return b;
|
|
176
|
+
const p = b.path;
|
|
129
177
|
const st = fs.statSync(p);
|
|
130
178
|
if (st.isDirectory()) return { ok: false, error: `"${p}" 是目录,请使用 ls 查看。` };
|
|
131
179
|
if (st.size > MAX_FILE_BYTES) {
|
|
@@ -181,8 +229,9 @@ const MAX_WRITE_BYTES = 2 * 1024 * 1024; // write 单次内容上限(防模型
|
|
|
181
229
|
*/
|
|
182
230
|
export function write(args, ctx) {
|
|
183
231
|
try {
|
|
184
|
-
const
|
|
185
|
-
if (!
|
|
232
|
+
const b = boundedPath(ctx, args.path ?? '');
|
|
233
|
+
if (!b.ok) return b;
|
|
234
|
+
const p = b.path;
|
|
186
235
|
const content = String(args.content ?? '');
|
|
187
236
|
if (Buffer.byteLength(content) > MAX_WRITE_BYTES) {
|
|
188
237
|
return { ok: false, error: `内容超过 ${MAX_WRITE_BYTES / 1024 / 1024}MB 上限,请分多次写入。` };
|
|
@@ -222,11 +271,12 @@ function regionAround(text, lineStart, lineCount, context = 2) {
|
|
|
222
271
|
*/
|
|
223
272
|
export function edit(args, ctx) {
|
|
224
273
|
try {
|
|
225
|
-
const
|
|
274
|
+
const b = boundedPath(ctx, args.path ?? '');
|
|
275
|
+
if (!b.ok) return b;
|
|
276
|
+
const p = b.path;
|
|
226
277
|
const oldString = String(args.old_string ?? '');
|
|
227
278
|
const newString = String(args.new_string ?? '');
|
|
228
279
|
const replaceAll = Boolean(args.replace_all);
|
|
229
|
-
if (!p) return { ok: false, error: '缺少 path 参数。' };
|
|
230
280
|
if (!oldString) return { ok: false, error: '缺少 old_string 参数。' };
|
|
231
281
|
try {
|
|
232
282
|
if (fs.statSync(p).size > MAX_FILE_BYTES) {
|
|
@@ -265,7 +315,9 @@ export function edit(args, ctx) {
|
|
|
265
315
|
*/
|
|
266
316
|
export function ls(args, ctx) {
|
|
267
317
|
try {
|
|
268
|
-
const
|
|
318
|
+
const b = boundedPath(ctx, args.path || '.');
|
|
319
|
+
if (!b.ok) return b;
|
|
320
|
+
const p = b.path;
|
|
269
321
|
const entries = fs.readdirSync(p, { withFileTypes: true });
|
|
270
322
|
const rows = entries
|
|
271
323
|
.map((e) => {
|
|
@@ -354,7 +406,9 @@ function walkFiles(root, visitor) {
|
|
|
354
406
|
export function glob(args, ctx) {
|
|
355
407
|
try {
|
|
356
408
|
const pattern = String(args.pattern ?? '*');
|
|
357
|
-
const
|
|
409
|
+
const b = boundedPath(ctx, args.path || '.');
|
|
410
|
+
if (!b.ok) return b;
|
|
411
|
+
const root = b.path;
|
|
358
412
|
if (!fs.existsSync(root)) return { ok: false, error: `目录不存在:${root}` };
|
|
359
413
|
const re = globToRegExp(pattern);
|
|
360
414
|
const matchRel = pattern.includes('/');
|
|
@@ -401,7 +455,9 @@ export function grep(args, ctx) {
|
|
|
401
455
|
const MAX_LINE = 20 * 1024;
|
|
402
456
|
/** @type {(line: any) => any} */
|
|
403
457
|
const testLine = (line) => (line.length > MAX_LINE ? re.test(line.slice(0, MAX_LINE)) : re.test(line));
|
|
404
|
-
const
|
|
458
|
+
const root0 = boundedPath(ctx, args.path || '.');
|
|
459
|
+
if (!root0.ok) return root0;
|
|
460
|
+
const root = root0.path;
|
|
405
461
|
const include = args.include ? globToRegExp(String(args.include)) : null;
|
|
406
462
|
/** @type {any[]} */
|
|
407
463
|
const matches = [];
|
package/src/tools/git.js
CHANGED
|
@@ -14,10 +14,18 @@ export async function runGit(/** @type {any} */ args, /** @type {any} */ ctx) {
|
|
|
14
14
|
if (!GIT_READONLY.has(sub)) {
|
|
15
15
|
return { ok: false, error: `git ${sub} 不是只读子命令(仅支持 ${[...GIT_READONLY].join(' / ')})。写操作请用 bash 并注意授权。` };
|
|
16
16
|
}
|
|
17
|
-
// 追加默认防超大输出:log/diff 限量(除非模型显式给了 -n/--max-count
|
|
17
|
+
// 追加默认防超大输出:log/diff 限量(除非模型显式给了 -n/--max-count)——
|
|
18
|
+
// v0.4.1 P2 修复:此前注释声明限量但无实现,git log -p 在大型仓库会撞 maxBuffer 4MB 报 ENOBUFS。
|
|
19
|
+
let effectiveArgv = argv;
|
|
20
|
+
if ((sub === 'log' || sub === 'shortlog') && !argv.some((/** @type {string} */ a) => /^-(n|\d+)$/.test(a) || a === '--max-count')) {
|
|
21
|
+
effectiveArgv = [...argv, '-n', '50'];
|
|
22
|
+
} else if (sub === 'diff' && !argv.some((/** @type {string} */ a) => a === '--stat')) {
|
|
23
|
+
// diff 全量可能极巨:默认 --stat 概览,模型需要全文再显式加 --no-stat 或指定文件
|
|
24
|
+
effectiveArgv = [...argv, '--stat'];
|
|
25
|
+
}
|
|
18
26
|
const cwd = ctx.workingDir || process.cwd();
|
|
19
27
|
try {
|
|
20
|
-
const { stdout, stderr } = await execFile('git',
|
|
28
|
+
const { stdout, stderr } = await execFile('git', effectiveArgv, {
|
|
21
29
|
cwd,
|
|
22
30
|
timeout: 15000,
|
|
23
31
|
maxBuffer: 4 * 1024 * 1024,
|
package/src/tools/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import { runBash } from './bash.js';
|
|
|
6
6
|
import { runGit } from './git.js';
|
|
7
7
|
import { runFetch } from './fetch.js';
|
|
8
8
|
import { listSkills, loadSkill } from '../skills.js';
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
9
10
|
|
|
10
11
|
// 只读工具集合的单一来源:permissions.js 引用此导出,新增只读工具时只需改这里
|
|
11
12
|
export const READONLY_TOOLS = new Set(['read', 'glob', 'grep', 'ls', 'skill', 'git', 'fetch']);
|
|
@@ -245,6 +246,140 @@ export function toolSchemas() {
|
|
|
245
246
|
return TOOLS;
|
|
246
247
|
}
|
|
247
248
|
|
|
249
|
+
// —— 第三方工具注册表(v0.4.0 契约化):程序化 registerTool 注册的自定义工具 ——
|
|
250
|
+
// 与内置工具同一链路:schema 进 buildToolSchemas(省钱剥描述/只读档/预设白名单全适用)、
|
|
251
|
+
// dispatch 执行、审计/权限由 agent 外层统一处理。注册名不得与内置工具同名。
|
|
252
|
+
const customTools = new Map(); // name -> { schema, run }
|
|
253
|
+
const CUSTOM_TOOL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* 注册第三方工具(进程级,重复注册同名会报错——避免静默覆盖)。
|
|
257
|
+
* readOnly(v0.4.1 P2 新增):标注后进 READONLY_TOOLS 集合——只读档自动放行、ask 档不询问、
|
|
258
|
+
* 只读子代理可用。默认 false(写类/副作用工具仍走权限询问)。
|
|
259
|
+
* @param {{ name: string, description?: string, parameters?: any, readOnly?: boolean, run: (args: any, ctx: any) => any | Promise<any> }} tool
|
|
260
|
+
*/
|
|
261
|
+
export function registerTool(/** @type {any} */ tool) {
|
|
262
|
+
const name = String(tool?.name ?? '').trim();
|
|
263
|
+
if (!CUSTOM_TOOL_NAME_RE.test(name)) {
|
|
264
|
+
throw new Error(`自定义工具名非法(${CUSTOM_TOOL_NAME_RE}):${name}`);
|
|
265
|
+
}
|
|
266
|
+
// mcp__ 前缀保留给 MCP 工具(agent 按前缀路由到 mcp.call),自定义工具占用会静默错路由
|
|
267
|
+
if (name.startsWith('mcp__')) {
|
|
268
|
+
throw new Error(`自定义工具名不能以 mcp__ 开头(该前缀保留给 MCP 工具):${name}`);
|
|
269
|
+
}
|
|
270
|
+
if (TOOLS.some((/** @type {any} */ t) => t.function.name === name) || customTools.has(name)) {
|
|
271
|
+
throw new Error(`工具 ${name} 已存在(内置或已注册),不能重复注册`);
|
|
272
|
+
}
|
|
273
|
+
if (typeof tool?.run !== 'function') {
|
|
274
|
+
throw new Error(`自定义工具 ${name} 必须提供 run(args, ctx) 函数`);
|
|
275
|
+
}
|
|
276
|
+
const parameters =
|
|
277
|
+
tool.parameters && typeof tool.parameters === 'object' ? tool.parameters : { type: 'object', properties: {} };
|
|
278
|
+
const entry = {
|
|
279
|
+
schema: {
|
|
280
|
+
type: 'function',
|
|
281
|
+
function: { name, description: String(tool.description || ''), parameters },
|
|
282
|
+
},
|
|
283
|
+
run: tool.run,
|
|
284
|
+
readOnly: tool.readOnly === true,
|
|
285
|
+
};
|
|
286
|
+
customTools.set(name, entry);
|
|
287
|
+
if (tool.readOnly === true) READONLY_TOOLS.add(name);
|
|
288
|
+
return entry.schema;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** 判断第三方工具是否标注只读。 */
|
|
292
|
+
export function isRegisteredToolReadonly(/** @type {any} */ name) {
|
|
293
|
+
return customTools.get(name)?.readOnly === true;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** 已注册的第三方工具名列表。 */
|
|
297
|
+
export function listRegisteredTools() {
|
|
298
|
+
return [...customTools.keys()];
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** 第三方工具 schema 列表(与内置 TOOLS 合并进 buildToolSchemas)。 */
|
|
302
|
+
function customToolSchemas() {
|
|
303
|
+
return [...customTools.values()].map((/** @type {any} */ t) => t.schema);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// —— config.tools 声明式工具挂载(v0.4.0 契约化)——
|
|
307
|
+
// config.json: { "tools": [ { "name": "ping", "description": "...", "parameters": {...}, "command": "..." } ] }
|
|
308
|
+
// command 经 /bin/bash -lc 执行;参数以 MINGDAO_TOOL_ARGS(JSON)环境变量传入——参数不经字符串拼接进
|
|
309
|
+
// shell(防注入),由命令自行解析;执行受 agent 权限引擎门控(与 bash 同权重,权限提示含参数)。
|
|
310
|
+
// 进程级挂载(幂等:同名已挂载则跳过),改 config.tools 需重启生效(与 MCP 预设一致)。
|
|
311
|
+
const mountedConfigTools = new Set();
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* 把 config.tools 声明式工具挂载进注册表(幂等,可在启动路径重复调用)。
|
|
315
|
+
* 返回本次新挂载的工具名数组。非法条目(坏名/与内置冲突)跳过不挂载——
|
|
316
|
+
* 用户配置错误绝不能让 CLI/WebUI 启动崩溃,用 console.error 提示即可。
|
|
317
|
+
*/
|
|
318
|
+
export function mountConfigTools(/** @type {any} */ cfg) {
|
|
319
|
+
const entries = Array.isArray(cfg?.tools) ? cfg.tools : [];
|
|
320
|
+
const mounted = [];
|
|
321
|
+
for (const e of entries) {
|
|
322
|
+
const name = String(e?.name ?? '').trim();
|
|
323
|
+
if (!name || mountedConfigTools.has(name)) continue;
|
|
324
|
+
if (typeof e?.command !== 'string' || !e.command.trim()) continue; // 缺 command 的条目跳过
|
|
325
|
+
try {
|
|
326
|
+
registerTool({
|
|
327
|
+
name,
|
|
328
|
+
description: String(e.description || ''),
|
|
329
|
+
parameters: e.parameters,
|
|
330
|
+
// v0.4.1 P1 修复:异步 spawn 而非 spawnSync——spawnSync 最长阻塞 600s,WebUI 下单个请求
|
|
331
|
+
// 即冻结整个 Node 进程(所有并发会话/权限确认/SSE 流全无响应)。异步执行不阻塞事件循环。
|
|
332
|
+
run: (/** @type {any} */ args, /** @type {any} */ ctx) =>
|
|
333
|
+
new Promise((resolve) => {
|
|
334
|
+
const shell = process.platform === 'win32' ? 'cmd.exe' : '/bin/bash';
|
|
335
|
+
const shellArgs = process.platform === 'win32' ? ['/d', '/s', '/c', e.command] : ['-lc', e.command];
|
|
336
|
+
const timeoutMs = Math.min(Number(e.timeout) > 0 ? Number(e.timeout) : 120, 600) * 1000;
|
|
337
|
+
const child = spawn(shell, shellArgs, {
|
|
338
|
+
cwd: ctx.cwd,
|
|
339
|
+
env: { ...process.env, MINGDAO_TOOL_ARGS: JSON.stringify(args ?? {}) },
|
|
340
|
+
});
|
|
341
|
+
let out = '';
|
|
342
|
+
let err = '';
|
|
343
|
+
let done = false;
|
|
344
|
+
let timedOut = false;
|
|
345
|
+
const cap = (/** @type {any} */ s, /** @type {any} */ d) => {
|
|
346
|
+
const t = s + d;
|
|
347
|
+
return t.length > 20000 * 2 ? t.slice(-20000 * 2) : t;
|
|
348
|
+
};
|
|
349
|
+
const finish = (/** @type {any} */ result) => {
|
|
350
|
+
if (done) return;
|
|
351
|
+
done = true;
|
|
352
|
+
clearTimeout(timer);
|
|
353
|
+
resolve(result);
|
|
354
|
+
};
|
|
355
|
+
const timer = setTimeout(() => {
|
|
356
|
+
timedOut = true;
|
|
357
|
+
try { child.kill('SIGKILL'); } catch {}
|
|
358
|
+
}, timeoutMs);
|
|
359
|
+
child.stdout.on('data', (d) => { out = cap(out, d); });
|
|
360
|
+
child.stderr.on('data', (d) => { err = cap(err, d); });
|
|
361
|
+
child.on('error', (er) => finish({ ok: false, error: `命令执行失败:${String(er?.message || er)}` }));
|
|
362
|
+
child.on('close', (code) => {
|
|
363
|
+
const capped = out.length > 20000 ? out.slice(0, 20000) + `\n…[输出过长已截断,共 ${out.length} 字]` : out;
|
|
364
|
+
finish({
|
|
365
|
+
ok: timedOut ? false : (code ?? 0) === 0,
|
|
366
|
+
exitCode: timedOut ? null : (code ?? null),
|
|
367
|
+
output: (capped || '(无输出)').trim(),
|
|
368
|
+
...(err.trim() ? { stderr: err.trim().slice(0, 4000) } : {}),
|
|
369
|
+
...(timedOut ? { error: `命令执行超时(${Math.round(timeoutMs / 1000)}s),已终止` } : {}),
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
}),
|
|
373
|
+
});
|
|
374
|
+
mountedConfigTools.add(name);
|
|
375
|
+
mounted.push(name);
|
|
376
|
+
} catch (/** @type {any} */ err) {
|
|
377
|
+
console.error(`[MingDao] ⚠ config.tools 条目 "${name}" 挂载失败(已跳过):${String(err?.message || err)}`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return mounted;
|
|
381
|
+
}
|
|
382
|
+
|
|
248
383
|
/**
|
|
249
384
|
* 递归去除对象中的全部 description 键(保留类型/required/enum 等结构性字段)。
|
|
250
385
|
* @param {any} obj
|
|
@@ -283,7 +418,7 @@ export function buildToolSchemas(usedNames, extra = []) {
|
|
|
283
418
|
}
|
|
284
419
|
return t;
|
|
285
420
|
});
|
|
286
|
-
return [...strip(TOOLS), ...strip(extra)];
|
|
421
|
+
return [...strip(TOOLS), ...strip(customToolSchemas()), ...strip(extra)];
|
|
287
422
|
}
|
|
288
423
|
|
|
289
424
|
function runSkill(/** @type {any} */ args, /** @type {any} */ ctx) {
|
|
@@ -333,6 +468,15 @@ function runTodo(/** @type {any} */ args, /** @type {any} */ ctx) {
|
|
|
333
468
|
}
|
|
334
469
|
|
|
335
470
|
export async function dispatch(/** @type {any} */ name, /** @type {any} */ args, /** @type {any} */ ctx) {
|
|
471
|
+
const custom = customTools.get(name);
|
|
472
|
+
if (custom) {
|
|
473
|
+
try {
|
|
474
|
+
const r = await custom.run(args, ctx);
|
|
475
|
+
return r && typeof r === 'object' ? r : { ok: true, output: String(r ?? '') };
|
|
476
|
+
} catch (/** @type {any} */ err) {
|
|
477
|
+
return { ok: false, error: `自定义工具 ${name} 执行失败:${String(err?.message || err)}` };
|
|
478
|
+
}
|
|
479
|
+
}
|
|
336
480
|
switch (name) {
|
|
337
481
|
case 'read':
|
|
338
482
|
return read(args, ctx);
|
package/src/web/app.js
CHANGED
|
@@ -345,6 +345,9 @@ async function send(){
|
|
|
345
345
|
arm();
|
|
346
346
|
input.value=''; input.style.height='44px';
|
|
347
347
|
const payload={message:text,file:currentSession};
|
|
348
|
+
// v0.4.0 Agent Preset:选择器选中的预设随本轮发送(服务端按会话应用一次)
|
|
349
|
+
const presetVal=$('#presetSel')?.value;
|
|
350
|
+
if(presetVal) payload.preset=presetVal;
|
|
348
351
|
// 带上文开关:勾选后系统提示注入最近会话日志(默认不注入——新会话全新开始,避免串到历史会话上下文)
|
|
349
352
|
if($('#journalChk')?.checked) payload.withJournal=true;
|
|
350
353
|
if(attachments.length) payload.attachments=attachments;
|
|
@@ -647,6 +650,11 @@ async function init(){
|
|
|
647
650
|
}
|
|
648
651
|
refreshSessions();
|
|
649
652
|
refreshWsSel();
|
|
653
|
+
try{
|
|
654
|
+
// v0.4.0 Agent Preset:加载预设列表进下拉(项目 → 用户 → 内置)
|
|
655
|
+
const pr=await fetch('/api/presets',{cache:'no-store'}).catch(()=>null); const pj=pr?await pr.json():{presets:[]};
|
|
656
|
+
const psel=$('#presetSel'); if(psel&&pj.presets){ for(const p of pj.presets){ const o=document.createElement('option'); o.value=p.name; o.textContent=p.label+'('+(p.source==='project'?'项目':p.source==='user'?'用户':'内置')+')'; o.title=p.description||p.name; psel.appendChild(o); } }
|
|
657
|
+
}catch(e){}
|
|
650
658
|
try{
|
|
651
659
|
const dr=await fetch('/api/draft?file='+encodeURIComponent(currentSession||''),{cache:'no-store'}); const dj=await dr.json();
|
|
652
660
|
if(dj.text){ input.value=dj.text; input.style.height=Math.min(input.scrollHeight,200)+'px'; input.focus(); }
|
|
@@ -922,7 +930,8 @@ async function refreshModelsCfg(){
|
|
|
922
930
|
div.innerHTML='<span style="flex:1;min-width:0"><b>'+esc(c.name)+'</b> <span style="color:var(--faint)">'+esc(c.label)+' · '+esc(c.baseUrl)+'</span></span><span style="white-space:nowrap">'+kstate+'</span>';
|
|
923
931
|
const sk=document.createElement('button'); sk.textContent='设Key'; sk.style.cssText='padding:1px 8px;font-size:11px'; sk.onclick=async()=>{ const k=await uiPrompt('API Key('+c.name+'):', null, {hidden:true}); if(k===null) return; fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'setCustomKey',name:c.name,key:k})}).then(rr=>rr.json()).then(jj=>{ if(jj.ok) refreshModelsCfg(); else uiAlert(jj.error||'设置失败'); }); };
|
|
924
932
|
const test=document.createElement('button'); test.textContent='测试'; test.style.cssText='padding:1px 8px;font-size:11px'; test.onclick=async()=>{ test.disabled=true; test.textContent='测试中…'; try{ const rr=await fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'testCustom',name:c.name})}); const jj=await rr.json().catch(()=>({error:'请求失败'})); if(jj.ok){ await uiAlert('✅ 连接成功('+jj.latencyMs+'ms):'+esc(jj.reply||'空回复')); } else { await uiAlert('✖ 连接失败:'+esc(jj.error||'未知错误')); } } finally { test.disabled=false; test.textContent='测试'; } };
|
|
925
|
-
const ed=document.createElement('button'); ed.textContent='修改'; ed.style.cssText='padding:1px 8px;font-size:11px'; ed.onclick=async()=>{ const u=await uiPrompt('API 地址:', c.baseUrl); if(u===null) return; const l=await uiPrompt('标签:', c.label); if(l===null) return;
|
|
933
|
+
const ed=document.createElement('button'); ed.textContent='修改'; ed.style.cssText='padding:1px 8px;font-size:11px'; ed.onclick=async()=>{ const u=await uiPrompt('API 地址:', c.baseUrl); if(u===null) return; const l=await uiPrompt('标签:', c.label); if(l===null) return; // v0.4.1:本地模型上下文窗口/最大输出可在设置里直接改(此前只能改 baseUrl+label,本地模型补 contextWindow 需手改 config.json)
|
|
934
|
+
const cw=await uiPrompt('上下文窗口 tokens(本地模型必填,如 131072;留空=不变)', c.contextWindow||''); if(cw===null) return; const mo=await uiPrompt('最大输出 tokens(可选,如 8192;留空=不变)', c.maxOutputTokens||''); if(mo===null) return; fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'updateCustom',name:c.name,baseUrl:u,label:l,contextWindow:Number(cw)||undefined,maxOutputTokens:Number(mo)||undefined})}).then(rr=>rr.json()).then(jj=>{ if(jj.ok){ refreshModelsCfg(); reloadModels(); } else uiAlert(jj.error||'修改失败'); }); };
|
|
926
935
|
const rm=document.createElement('button'); rm.textContent='删除'; rm.className='danger'; rm.style.cssText='padding:1px 8px;font-size:11px'; rm.onclick=async()=>{ if(!await uiConfirm('删除自定义模型 '+c.name+'?')) return; const rr=await fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'removeCustom',name:c.name})}); const jj=await rr.json().catch(()=>({error:'失败'})); if(jj.ok){ refreshModelsCfg(); reloadModels(); } else uiAlert(jj.error||'删除失败'); };
|
|
927
936
|
div.appendChild(sk); div.appendChild(test); div.appendChild(ed); div.appendChild(rm); cl.appendChild(div);
|
|
928
937
|
}
|
package/src/web/index.html
CHANGED
|
@@ -57,6 +57,7 @@ header button{white-space:nowrap;flex:none}
|
|
|
57
57
|
header select{flex:none}
|
|
58
58
|
#sessionSearch{flex:1 1 auto;min-width:70px;max-width:220px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 12px;font-size:13px}
|
|
59
59
|
#modelSel{max-width:170px;min-width:0}
|
|
60
|
+
#presetSel{max-width:140px;min-width:0}
|
|
60
61
|
#permSel{max-width:150px;min-width:0}
|
|
61
62
|
#sessions{max-width:150px;min-width:0}
|
|
62
63
|
#wsSel{max-width:130px;min-width:64px}
|
|
@@ -197,11 +198,12 @@ footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);paddin
|
|
|
197
198
|
#composer #permSel{max-width:96px;min-width:64px;flex:none;padding:8px 8px}
|
|
198
199
|
#composer #reasoningSel{max-width:64px;min-width:48px;flex:none;padding:8px 6px;font-size:12px}
|
|
199
200
|
#composer #modelSel{max-width:150px;min-width:88px;flex:none;padding:8px 8px;font-size:12px}
|
|
201
|
+
#composer #presetSel{max-width:130px;min-width:76px;flex:none;padding:8px 8px;font-size:12px}
|
|
200
202
|
#input{flex:1;background:var(--bg3);border:1px solid var(--border);border-radius:12px;color:var(--text);padding:10px 14px;font:14px/1.6 inherit;resize:none;min-height:44px;max-height:200px;min-width:0}
|
|
201
203
|
#input:focus{outline:none;border-color:var(--accent2)}
|
|
202
204
|
#hint{font-size:11.5px;color:var(--faint);margin-top:6px;max-width:860px;margin-left:auto;margin-right:auto}
|
|
203
205
|
#statusBar{font-size:11.5px;color:var(--faint);margin-top:2px;max-width:860px;margin-left:auto;margin-right:auto;font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
204
|
-
@media (max-width:560px){footer{padding:10px 8px}#composer{gap:6px}#composer #permSel{min-width:52px;padding:8px 4px}#composer #reasoningSel{min-width:44px;padding:8px 4px}#composer #modelSel{min-width:72px;padding:8px 4px}#attachBtn{padding:10px 9px}}
|
|
206
|
+
@media (max-width:560px){footer{padding:10px 8px}#composer{gap:6px}#composer #permSel{min-width:52px;padding:8px 4px}#composer #reasoningSel{min-width:44px;padding:8px 4px}#composer #modelSel{min-width:72px;padding:8px 4px}#composer #presetSel{min-width:64px;max-width:88px;padding:8px 4px}#attachBtn{padding:10px 9px}}
|
|
205
207
|
.modal-mask{position:fixed;inset:0;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;z-index:50}
|
|
206
208
|
.modal{background:var(--panel);border:1px solid var(--border);border-radius:14px;width:min(520px,92vw);padding:18px;max-height:88vh;overflow-y:auto}
|
|
207
209
|
/* 设置多级菜单(审计:一拉到底改为左侧分组导航 + 右侧面板) */
|
|
@@ -330,6 +332,7 @@ footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);paddin
|
|
|
330
332
|
<option value="readonly">只读</option>
|
|
331
333
|
</select>
|
|
332
334
|
<select id="modelSel" title="切换模型"></select>
|
|
335
|
+
<select id="presetSel" title="智能体预设:一键切换工具白名单/权限/参数组合(v0.4.0 契约化)"><option value="">预设…</option></select>
|
|
333
336
|
<select id="reasoningSel" title="思考模式(推理等级):关=不推理省 token · 低 · 高(默认)· 最高=最强推理" style="display:none">
|
|
334
337
|
<option value="off">关</option>
|
|
335
338
|
<option value="low">低</option>
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { loadMemory, writeMemory, dedupeMemory } from '../../../memory.js';
|
|
4
4
|
import { recordUsage, listCacheStats, summarizeCacheStats, costBreakdown } from '../../../cachestats.js';
|
|
5
5
|
import { costGuardStatus } from '../../../cost-guard.js';
|
|
6
|
+
import { listPresets } from '../../../presets.js';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* 杂项域路由。命中返回 true,未命中返回 false。
|
|
@@ -100,6 +101,13 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
|
|
|
100
101
|
return true;
|
|
101
102
|
}
|
|
102
103
|
|
|
104
|
+
// v0.4.0 Agent Preset:列出可用预设(项目 → 用户 → 内置,同名遮蔽)
|
|
105
|
+
if (method === 'GET' && p === '/api/presets') {
|
|
106
|
+
const workingDir = deps.state?.workingDir || process.cwd();
|
|
107
|
+
json(res, 200, { ok: true, presets: listPresets(workingDir) });
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
103
111
|
if (method === 'POST' && p === '/api/memory') {
|
|
104
112
|
const body = await readBody(req, MAX_API_BODY);
|
|
105
113
|
if (body.action === 'dedupe') {
|
package/src/web/server.js
CHANGED
|
@@ -194,6 +194,13 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
194
194
|
const provider = await getProviderFor(modelName);
|
|
195
195
|
const undoStore = { backups: new Map() };
|
|
196
196
|
|
|
197
|
+
// v0.4.0 契约化:挂载 config.tools 声明式第三方工具(幂等,重启生效)
|
|
198
|
+
{
|
|
199
|
+
const { mountConfigTools } = await import('../tools/index.js');
|
|
200
|
+
const mounted = mountConfigTools(cfg);
|
|
201
|
+
if (mounted.length) console.error(`[MingDao] 🔧 已挂载声明式工具(config.tools):${mounted.join(', ')}`);
|
|
202
|
+
}
|
|
203
|
+
|
|
197
204
|
// MCP:A2 预热——await 连接(6s 超时);超时本会话冻结工具集(不再中途注入,保护前缀缓存)
|
|
198
205
|
/** @type {any} */
|
|
199
206
|
let mcpManager = null;
|
|
@@ -250,7 +257,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
250
257
|
if (h.includes(':')) {
|
|
251
258
|
// IPv6:回环 ::1、链路本地 fe80::/10、唯一本地 fc00::/7、IPv4 映射 ::ffff:私网
|
|
252
259
|
if (/^::ffff:/.test(h)) return isPrivateHost(h.slice(7));
|
|
253
|
-
return /^fe[89ab]/.test(h) || /^f[
|
|
260
|
+
return /^fe[89ab]/.test(h) || /^f[cd]/.test(h) || h === '::' || h === '::1';
|
|
254
261
|
}
|
|
255
262
|
const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
256
263
|
if (!m) return false; // 域名:由 validateRemoteUrl 的 DNS 解析复检
|
|
@@ -408,7 +415,32 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
408
415
|
sessionMemoryCache.delete(oldest);
|
|
409
416
|
}
|
|
410
417
|
}
|
|
411
|
-
|
|
418
|
+
// v0.4.0 Agent Preset:body.preset 按会话一次选定(新会话或显式传值);应用工具白名单/参数覆盖,
|
|
419
|
+
// 系统提示注入预设定制段。预设覆盖优先级:CLI/WebUI 显式 > 预设 > config.json。
|
|
420
|
+
let chatPreset = /** @type {any} */ (null);
|
|
421
|
+
let presetBlock = '';
|
|
422
|
+
let chatCfg = cfg;
|
|
423
|
+
if (typeof body.preset === 'string' && body.preset) {
|
|
424
|
+
const { loadPreset, presetConfigOverrides, presetSystemBlock, presetPermissionOverride } = await import('../presets.js');
|
|
425
|
+
chatPreset = loadPreset(taskDir, body.preset);
|
|
426
|
+
if (chatPreset) {
|
|
427
|
+
const over = presetConfigOverrides(chatPreset);
|
|
428
|
+
// P0(v0.4.1):预设 permission 提权防护——不得把 ask/readonly 静默改成 auto
|
|
429
|
+
const permOv = presetPermissionOverride(chatPreset, cfg.permission ?? 'ask');
|
|
430
|
+
if (permOv.escalated) {
|
|
431
|
+
delete over.permission;
|
|
432
|
+
send({ type: 'banner', text: `⚠ 预设 "${chatPreset.name}" 声明 permission=${chatPreset.permission} 属提权(当前 ${cfg.permission ?? 'ask'}),已忽略并保持 ${permOv.permission}。` });
|
|
433
|
+
} else if (chatPreset.permission !== undefined) {
|
|
434
|
+
over.permission = permOv.permission;
|
|
435
|
+
}
|
|
436
|
+
chatCfg = { ...cfg, ...over, presetName: chatPreset.name };
|
|
437
|
+
presetBlock = presetSystemBlock(chatPreset);
|
|
438
|
+
} else {
|
|
439
|
+
// 预设不存在:不静默——banner 告知(前端下拉与磁盘不同步/项目级预设未带入时可见)
|
|
440
|
+
send({ type: 'banner', text: `⚠ 预设 "${String(body.preset)}" 不存在,已按当前配置继续。` });
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
const systemPrompt = buildSystemPrompt({ workingDir: taskDir, withJournal: body.withJournal === true, projectMemory: projectMemorySnapshot, presetBlock });
|
|
412
444
|
let messages =
|
|
413
445
|
session.messages?.length && session.messages[0]?.role === 'system'
|
|
414
446
|
? session.messages
|
|
@@ -448,7 +480,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
448
480
|
entry.abortHandler = fn;
|
|
449
481
|
},
|
|
450
482
|
}));
|
|
451
|
-
const permission = createPermission(
|
|
483
|
+
const permission = createPermission(chatCfg.permission ?? 'ask', io);
|
|
452
484
|
let providerNow;
|
|
453
485
|
try {
|
|
454
486
|
// 首次使用引导:未配置密钥时给明确指引,而不是晦涩的 401 原始报错
|
|
@@ -471,7 +503,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
471
503
|
io,
|
|
472
504
|
modelName: runModel,
|
|
473
505
|
workingDir: taskDir,
|
|
474
|
-
cfg,
|
|
506
|
+
cfg: chatCfg, // v0.4.0:预设覆盖后的配置(工具白名单 presetTools/参数/权限)
|
|
475
507
|
undoStore,
|
|
476
508
|
mcp: mcpFacade,
|
|
477
509
|
// 自动压缩后重写会话文件(否则每次加载历史都会重新触发压缩),并同步落盘游标
|