mingdao-harness 0.2.4 → 0.2.6

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.
Files changed (66) hide show
  1. package/docs/EVALUATION-ROUND2.md +58 -0
  2. package/docs/ROADMAP-NEXT.md +5 -5
  3. package/package.json +2 -2
  4. package/src/agent.js +74 -36
  5. package/src/atomic-write.js +8 -2
  6. package/src/audit.js +2 -2
  7. package/src/batch.js +7 -7
  8. package/src/cachestats.js +9 -9
  9. package/src/cli.js +42 -688
  10. package/src/commands/desktop.js +1 -1
  11. package/src/commands/key.js +1 -1
  12. package/src/commands/repl.js +711 -0
  13. package/src/commands/schedule.js +7 -7
  14. package/src/commands/skill.js +18 -16
  15. package/src/commands/sync.js +5 -5
  16. package/src/commands/update.js +7 -6
  17. package/src/commands/workspace.js +2 -2
  18. package/src/compact.js +6 -6
  19. package/src/config.js +7 -4
  20. package/src/context.js +37 -21
  21. package/src/cost-guard.js +14 -6
  22. package/src/credentials.js +6 -0
  23. package/src/hooks.js +11 -11
  24. package/src/mcp-presets.js +5 -5
  25. package/src/mcp.js +19 -19
  26. package/src/memory.js +16 -16
  27. package/src/model-discovery.js +16 -15
  28. package/src/models.js +4 -4
  29. package/src/notify.js +2 -2
  30. package/src/permissions.js +7 -3
  31. package/src/pricing.js +35 -1
  32. package/src/prompts.js +1 -1
  33. package/src/providers/index.js +7 -7
  34. package/src/providers/openai-compatible.js +13 -11
  35. package/src/routing.js +8 -8
  36. package/src/schedule.js +30 -30
  37. package/src/session-index.js +5 -1
  38. package/src/session.js +11 -2
  39. package/src/skill-lib.js +62 -3
  40. package/src/skill-registry.js +10 -9
  41. package/src/skills.js +14 -12
  42. package/src/sync-server.js +86 -3
  43. package/src/sync.js +53 -20
  44. package/src/tasks/worker.js +126 -0
  45. package/src/tasks.js +14 -14
  46. package/src/titles.js +5 -5
  47. package/src/tokenizer.js +23 -5
  48. package/src/tools/bash.js +12 -12
  49. package/src/tools/fs-tools.js +66 -9
  50. package/src/tools/index.js +7 -7
  51. package/src/ui.js +83 -6
  52. package/src/update.js +8 -8
  53. package/src/web/app.js +16 -5
  54. package/src/web/attachments.js +1 -1
  55. package/src/web/index.html +21 -1
  56. package/src/web/routes/api.js +38 -786
  57. package/src/web/routes/domains/config.js +280 -0
  58. package/src/web/routes/domains/misc.js +129 -0
  59. package/src/web/routes/domains/schedule.js +127 -0
  60. package/src/web/routes/domains/sessions.js +105 -0
  61. package/src/web/routes/domains/skills.js +79 -0
  62. package/src/web/routes/domains/sync.js +101 -0
  63. package/src/web/routes/domains/workspace.js +119 -0
  64. package/src/web/server.js +73 -31
  65. package/src/web/web-io.js +54 -12
  66. package/src/workspace.js +14 -14
@@ -0,0 +1,79 @@
1
+ // 技能域(Phase C C1):/api/skills /api/skill-library /api/mcp-presets
2
+ // 技能列表/安装/卸载、内置技能库 + 线上注册表搜索、MCP 生态预设接入。
3
+ import { listSkills, tamperedSkillNames } from '../../../skills.js';
4
+ import { libraryList, installSkill, uninstallSkill, installedUserSkillNames } from '../../../skill-lib.js';
5
+ import { presetList, buildPreset } from '../../../mcp-presets.js';
6
+ import { saveConfig } from '../../../config.js';
7
+
8
+ /**
9
+ * 技能域路由。命中返回 true,未命中返回 false。
10
+ * @param {{req:any,res:any,method:any,p:any,url:any}} ctx
11
+ * @param {any} deps
12
+ * @param {{json:any,readBody:any,MAX_API_BODY:any}} shared
13
+ */
14
+ export async function handle({ req, res, method, p, url }, deps, shared) {
15
+ const { json, readBody, MAX_API_BODY } = shared;
16
+ const { cfg, state } = deps;
17
+
18
+ if (method === 'GET' && p === '/api/skills') {
19
+ json(res, 200, { ok: true, skills: listSkills(state.workingDir), tampered: tamperedSkillNames(state.workingDir) });
20
+ return true;
21
+ }
22
+
23
+ if (method === 'GET' && p === '/api/mcp-presets') {
24
+ json(res, 200, { ok: true, presets: presetList() });
25
+ return true;
26
+ }
27
+
28
+ if (method === 'POST' && p === '/api/mcp-presets') {
29
+ const body = await readBody(req, MAX_API_BODY);
30
+ const name = String(body.name || '').trim();
31
+ const r = buildPreset(name, body.arg, state.workingDir);
32
+ if (r.error) return json(res, 400, { error: r.error });
33
+ cfg.mcpServers = cfg.mcpServers || {};
34
+ cfg.mcpServers[name] = r.config;
35
+ saveConfig(cfg);
36
+ json(res, 200, { ok: true, name, note: '重启 mingdao web 后生效(/mcp 查看状态)' });
37
+ return true;
38
+ }
39
+
40
+ if (method === 'GET' && p === '/api/skill-library') {
41
+ const q = url.searchParams.get('q') || '';
42
+ const force = url.searchParams.get('refresh') === '1';
43
+ const local = q
44
+ ? libraryList().filter((s) => s.name.includes(q) || (s.description || '').includes(q))
45
+ : libraryList();
46
+ const { searchRegistry } = await import('../../../skill-registry.js');
47
+ const remote = await searchRegistry(q || '', { force, allowNetwork: force });
48
+ const localNames = new Set(local.map((s) => s.name));
49
+ const registryEntries = remote.skills
50
+ ? remote.skills.filter((/** @type {any} */ s) => !localNames.has(s.name)).map((/** @type {any} */ s) => ({ ...s, dir: null }))
51
+ : [];
52
+ json(res, 200, {
53
+ ok: true,
54
+ library: local.map((s) => ({ name: s.name, description: s.description, source: 'builtin-lib', installed: s.installed })).concat(
55
+ registryEntries.map((/** @type {any} */ s) => ({ name: s.name, description: s.description, source: 'registry', installed: s.installed }))
56
+ ),
57
+ installed: [...installedUserSkillNames()],
58
+ registry: remote.error ? { error: remote.error } : { host: remote.host, updatedAt: remote.updatedAt, stale: remote.stale || false },
59
+ });
60
+ return true;
61
+ }
62
+
63
+ if (method === 'POST' && p === '/api/skills') {
64
+ const body = await readBody(req, MAX_API_BODY);
65
+ if (body.action === 'install') {
66
+ const r = /** @type {any} */ (await installSkill(body.arg || body.name || ''));
67
+ if (r.error) return json(res, 400, { error: r.error });
68
+ return json(res, 200, { ok: true, name: r.name, names: r.names });
69
+ }
70
+ if (body.action === 'uninstall') {
71
+ const r = uninstallSkill(body.name);
72
+ if (r.error) return json(res, 400, { error: r.error });
73
+ return json(res, 200, { ok: true, name: r.name });
74
+ }
75
+ return json(res, 400, { error: '未知操作:install|uninstall' });
76
+ }
77
+
78
+ return false;
79
+ }
@@ -0,0 +1,101 @@
1
+ // 同步域(Phase C C1):/api/sync /api/sync-conflicts
2
+ // 云同步登录/登出/推拉/改密/分享与冲突处理。
3
+ import {
4
+ syncStatus,
5
+ syncLogin,
6
+ syncLogout,
7
+ syncPush,
8
+ syncPull,
9
+ syncRemoteList,
10
+ syncChangePassword,
11
+ syncShareCreate,
12
+ syncShareList,
13
+ syncShareAccept,
14
+ syncShareRevoke,
15
+ listSyncConflicts,
16
+ resolveSyncConflict,
17
+ } from '../../../sync.js';
18
+
19
+ /**
20
+ * 同步域路由。命中返回 true,未命中返回 false。
21
+ * @param {{req:any,res:any,method:any,p:any,url:any}} ctx
22
+ * @param {any} deps
23
+ * @param {{json:any,readBody:any,MAX_API_BODY:any,validateRemoteUrl:any}} shared
24
+ */
25
+ export async function handle({ req, res, method, p, url }, deps, shared) {
26
+ const { json, readBody, MAX_API_BODY, validateRemoteUrl } = shared;
27
+
28
+ if (method === 'GET' && p === '/api/sync') {
29
+ const st = syncStatus();
30
+ let remote = null;
31
+ if (st.loggedIn) {
32
+ const r = await syncRemoteList();
33
+ remote = r.ok ? { sessions: r.sessions.slice(0, 50) } : { error: r.error };
34
+ }
35
+ json(res, 200, { ok: true, ...st, remote });
36
+ return true;
37
+ }
38
+
39
+ if (method === 'POST' && p === '/api/sync') {
40
+ const body = await readBody(req, MAX_API_BODY);
41
+ if (body.action === 'login') {
42
+ const vurl = await validateRemoteUrl(body.url); // 质检 S1:SSRF 防护(同步端点同样只允许公网)
43
+ if (vurl.error) return json(res, 400, { error: vurl.error });
44
+ const r = await syncLogin({ url: body.url, username: body.username, password: body.password, deviceName: body.deviceName });
45
+ if (r.error) return json(res, 400, { error: r.error });
46
+ return json(res, 200, { ok: true, ...r });
47
+ }
48
+ if (body.action === 'logout') {
49
+ syncLogout();
50
+ return json(res, 200, { ok: true });
51
+ }
52
+ if (body.action === 'push') {
53
+ const r = /** @type {any} */ (await syncPush(body.name));
54
+ if (r.error) return json(res, 400, { error: r.error });
55
+ return json(res, 200, { ok: true, pushed: r.pushed.length, conflicts: r.conflicts });
56
+ }
57
+ if (body.action === 'pull') {
58
+ const r = /** @type {any} */ (await syncPull(body.name));
59
+ if (r.error) return json(res, 400, { error: r.error });
60
+ return json(res, 200, { ok: true, pulled: r.pulled.length, conflicts: r.conflicts });
61
+ }
62
+ if (body.action === 'passwd') {
63
+ const r = await syncChangePassword({ oldPassword: body.oldPassword, newPassword: body.newPassword });
64
+ if (r.error) return json(res, 400, { error: r.error });
65
+ return json(res, 200, { ok: true });
66
+ }
67
+ if (body.action === 'share') {
68
+ const r = await syncShareCreate(body.name);
69
+ if (r.error) return json(res, 400, { error: r.error });
70
+ return json(res, 200, { ok: true, shareId: r.shareId, name: r.name });
71
+ }
72
+ if (body.action === 'shares') {
73
+ const r = await syncShareList();
74
+ if (r.error) return json(res, 400, { error: r.error });
75
+ return json(res, 200, { ok: true, mine: r.mine, accepted: r.accepted });
76
+ }
77
+ if (body.action === 'accept') {
78
+ const r = await syncShareAccept(body.shareId);
79
+ if (r.error) return json(res, 400, { error: r.error });
80
+ return json(res, 200, { ok: true, ...r });
81
+ }
82
+ if (body.action === 'unshare') {
83
+ const r = await syncShareRevoke(body.shareId);
84
+ if (r.error) return json(res, 400, { error: r.error });
85
+ return json(res, 200, { ok: true });
86
+ }
87
+ if (body.action === 'resolveConflict') {
88
+ const r = resolveSyncConflict(body.base, body.choice);
89
+ if (r.error) return json(res, 400, { error: r.error });
90
+ return json(res, 200, { ok: true, ...r });
91
+ }
92
+ return json(res, 400, { error: '未知操作:login|logout|push|pull|passwd|share|shares|accept|unshare|resolveConflict' });
93
+ }
94
+
95
+ if (method === 'GET' && p === '/api/sync-conflicts') {
96
+ json(res, 200, { ok: true, conflicts: listSyncConflicts() });
97
+ return true;
98
+ }
99
+
100
+ return false;
101
+ }
@@ -0,0 +1,119 @@
1
+ // 工作空间域(Phase C C1):/api/workspaces /api/fs-browse
2
+ // 工作空间登记/切换/重命名/删除;受限目录浏览(基目录白名单)。
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import {
7
+ currentWorkspace,
8
+ listWorkspaces,
9
+ addWorkspace,
10
+ removeWorkspace,
11
+ renameWorkspace,
12
+ setWorkspaceDir,
13
+ workspacePath,
14
+ touchWorkspace,
15
+ setSessionWorkspace,
16
+ } from '../../../workspace.js';
17
+
18
+ /**
19
+ * 工作空间域路由。命中返回 true,未命中返回 false。
20
+ * @param {{req:any,res:any,method:any,p:any,url:any}} ctx
21
+ * @param {any} deps
22
+ * @param {{json:any,readBody:any,MAX_API_BODY:any}} shared
23
+ */
24
+ export async function handle({ req, res, method, p, url }, deps, shared) {
25
+ const { json, readBody, MAX_API_BODY } = shared;
26
+ const { cfg, state, startupCwd } = deps;
27
+
28
+ if (method === 'GET' && p === '/api/workspaces') {
29
+ json(res, 200, { ok: true, workspaces: listWorkspaces(), current: currentWorkspace(state.workingDir)?.name || null, cwd: state.workingDir });
30
+ return true;
31
+ }
32
+
33
+ if (method === 'POST' && p === '/api/workspaces') {
34
+ const body = await readBody(req, MAX_API_BODY);
35
+ const name = String(body.name || '').trim();
36
+ if (body.action === 'add') {
37
+ if (!name) return json(res, 400, { error: '名称不能为空' });
38
+ // 目录为空/不存在时自动新建(默认开,create:false 关闭)
39
+ const target = path.resolve(body.dir || state.workingDir);
40
+ if (body.create !== false) {
41
+ try {
42
+ fs.mkdirSync(target, { recursive: true });
43
+ } catch (/** @type {any} */ e) {
44
+ return json(res, 400, { error: `无法创建目录:${target}(${e.message})` });
45
+ }
46
+ }
47
+ const r = addWorkspace(name, target);
48
+ if (r.error) return json(res, 400, { error: r.error });
49
+ return json(res, 200, { ok: true, name: r.name, dir: r.dir, created: body.create !== false });
50
+ }
51
+ if (body.action === 'rename') {
52
+ const r = renameWorkspace(name, body.newName);
53
+ if (r.error) return json(res, 400, { error: r.error });
54
+ return json(res, 200, { ok: true, name: r.name });
55
+ }
56
+ if (body.action === 'set') {
57
+ // 切换全局工作空间(新会话默认目录;可带 dir 修改目录);目录缺失自动重建。
58
+ // 携带 file 时同时把当前会话的工作空间切过去(P3-4:会话跟随显式切换)。
59
+ if (body.dir) {
60
+ const r = setWorkspaceDir(name, body.dir);
61
+ if (r.error) return json(res, 400, { error: r.error });
62
+ }
63
+ const dir = workspacePath(name);
64
+ if (!dir) return json(res, 400, { error: `工作空间 ${name} 不存在` });
65
+ try {
66
+ fs.mkdirSync(dir, { recursive: true });
67
+ } catch (/** @type {any} */ e) {
68
+ return json(res, 400, { error: `无法创建目录:${dir}(${e.message})` });
69
+ }
70
+ touchWorkspace(name);
71
+ state.workingDir = dir;
72
+ // 不再 process.chdir:运行中任务的 cwd 在创建时已固定,全局切换只影响新会话
73
+ if (body.file) setSessionWorkspace(String(body.file), dir, /** @type {any} */ (name));
74
+ return json(res, 200, { ok: true, name, dir, current: name });
75
+ }
76
+ if (body.action === 'remove') {
77
+ if (!name) return json(res, 400, { error: '缺少名称' });
78
+ return json(res, 200, { ok: removeWorkspace(name) });
79
+ }
80
+ return json(res, 400, { error: '未知操作:add|rename|set|remove' });
81
+ }
82
+
83
+ // 目录浏览器(「新建工作空间」选择电脑磁盘目录用):本机运行时即用户电脑的目录树;
84
+ // 只列子目录(不含隐藏目录),供前端逐级导航选择
85
+ if (method === 'GET' && p === '/api/fs-browse') {
86
+ let dir = String(url.searchParams.get('dir') || '').trim();
87
+ if (!path.isAbsolute(dir)) return json(res, 400, { error: '需要绝对路径' });
88
+ // 质检 A3:目录浏览限定基目录,拒绝越界。Windows(CodeArts 报告):家目录覆盖整个用户配置树
89
+ // (AppData 等)——收紧为 桌面/文档/下载 三常用目录 + 启动目录 + 工作目录 + web.browseRoots 显式授权;
90
+ // 路径比较在 win32 下大小写归一(D:\\ vs d:\\ 不再误拒)。
91
+ const base = process.platform === 'win32' && process.env.USERPROFILE
92
+ ? [path.join(process.env.USERPROFILE, 'Desktop'), path.join(process.env.USERPROFILE, 'Documents'), path.join(process.env.USERPROFILE, 'Downloads')]
93
+ : [os.homedir()];
94
+ const norm = (/** @type {string} */ x) => (process.platform === 'win32' ? x.toLowerCase() : x);
95
+ const browseRoots = [...base, startupCwd, state.workingDir, ...(Array.isArray(cfg.web?.browseRoots) ? cfg.web.browseRoots : [])].map((r) => norm(path.resolve(String(r))));
96
+ const ndir = norm(dir);
97
+ const inRoot = browseRoots.some((r) => ndir === r || ndir.startsWith(r + path.sep));
98
+ if (!inRoot) return json(res, 403, { error: '目录不在可浏览范围内(授权目录或 web.browseRoots 显式添加)' });
99
+ try {
100
+ const st = fs.statSync(dir);
101
+ if (!st.isDirectory()) return json(res, 400, { error: '不是目录' });
102
+ const entries = fs
103
+ .readdirSync(dir, { withFileTypes: true })
104
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.'))
105
+ .map((e) => ({ name: e.name, path: path.join(dir, e.name) }))
106
+ .sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'))
107
+ .slice(0, 300);
108
+ let parent = path.dirname(dir);
109
+ // 父目录同样不得越出基目录
110
+ if (!browseRoots.some((r) => norm(parent) === r || norm(parent).startsWith(r + path.sep))) parent = /** @type {any} */ (null);
111
+ json(res, 200, { ok: true, path: dir, parent: parent === dir ? null : parent, entries });
112
+ } catch (/** @type {any} */ err) {
113
+ json(res, 400, { error: String(err?.message || err) });
114
+ }
115
+ return true;
116
+ }
117
+
118
+ return false;
119
+ }
package/src/web/server.js CHANGED
@@ -64,14 +64,22 @@ import { syncStatus, syncLogin, syncLogout, syncPush, syncPull, syncRemoteList,
64
64
 
65
65
  const INDEX_HTML = path.join(path.dirname(fileURLToPath(import.meta.url)), 'index.html');
66
66
 
67
+ /** @param {any} res @param {any} code @param {any} obj */
67
68
  function json(res, code, obj) {
68
69
  res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
69
70
  res.end(JSON.stringify(obj));
71
+ // 返回 truthy:域路由 `return json(...)` 必须被编排器视为「已处理」——
72
+ // 否则兜底 404 会对已结束的响应二次写入(Node 18 下 write-after-end 未捕获 → 整服务崩溃)
73
+ return true;
70
74
  }
71
75
 
72
- function readBody(req) {
73
- const MAX_BODY = 40 * 1024 * 1024; // 附件 base64 上限(4×5MB 图片 + 文本)留余量
76
+ /** 读取请求体。limit 默认 40MB(附件 base64 上限:4×5MB 图片 + 文本);
77
+ * 普通 JSON 接口必须显式传较小 limit(质检 A4;此前第二参数被忽略 → 1MB 限制形同虚设)。 */
78
+ /** @param {any} req @param {number} [limit] */
79
+ function readBody(req, limit = 40 * 1024 * 1024) {
80
+ const MAX_BODY = limit; // 附件 base64 上限(4×5MB 图片 + 文本)留余量
74
81
  return new Promise((resolve, reject) => {
82
+ /** @type {any[]} */
75
83
  const chunks = [];
76
84
  let size = 0;
77
85
  // 审计 P2-6:慢速连接防护——60s 未传完请求体即断开,防占满 socket
@@ -83,12 +91,14 @@ function readBody(req) {
83
91
  }, 60000);
84
92
  req.on('end', () => clearTimeout(slowTimer));
85
93
  req.on('close', () => clearTimeout(slowTimer));
86
- req.on('data', (d) => {
94
+ req.on('data', (/** @type {any} */ d) => {
87
95
  size += d.length;
88
96
  if (size > MAX_BODY) {
89
- const err = /** @type {Error & { status?: number }} */ (new Error('请求体过大(>40MB)'));
97
+ const err = /** @type {Error & { status?: number }} */ (new Error(`请求体过大(>${Math.round(MAX_BODY / 1048576)}MB)`));
90
98
  err.status = 413;
91
- req.destroy();
99
+ // 排空残余数据但保留连接:让上层 catch 返回真 413(此前 destroy 导致客户端只收到连接重置)
100
+ req.pause();
101
+ req.resume();
92
102
  reject(err);
93
103
  return;
94
104
  }
@@ -117,6 +127,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
117
127
  }
118
128
  // 访问令牌(P1-3):非回环绑定时强制——配置的 token / 启动参数 / 环境变量优先,
119
129
  // 都没有则本次随机生成并打印带 token 的访问地址。回环绑定且未配置时保持无认证(本机信任)。
130
+ /** @param {any} h */
120
131
  const isLoopbackHost = (h) => h === '127.0.0.1' || h === 'localhost' || h === '::1';
121
132
  let webToken = String(authToken || cfg?.web?.token || '').trim() || null;
122
133
  let tokenGenerated = false;
@@ -125,6 +136,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
125
136
  tokenGenerated = true;
126
137
  }
127
138
  const authEnabled = Boolean(webToken);
139
+ /** @param {any} got */
128
140
  const tokenMatches = (got) => {
129
141
  if (!webToken) return true;
130
142
  if (!got) return false;
@@ -132,6 +144,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
132
144
  const b = Buffer.from(webToken);
133
145
  return a.length === b.length && crypto.timingSafeEqual(a, b);
134
146
  };
147
+ /** @param {any} req @param {any} url */
135
148
  const requestToken = (req, url) => {
136
149
  const q = url.searchParams.get('token');
137
150
  if (q) return q;
@@ -143,6 +156,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
143
156
  // Host 白名单(P1-4,防 DNS rebinding):只接受回环名 + 绑定地址;
144
157
  // 绑定 0.0.0.0 且启用 token 时放行任意 Host(此时 token 才是访问边界,攻击者页面拿不到)。
145
158
  let boundPort = port;
159
+ /** @param {any} reqHost */
146
160
  const trustedHost = (reqHost) => {
147
161
  const h = String(reqHost || '').toLowerCase();
148
162
  const names = new Set(['127.0.0.1', 'localhost', '::1']);
@@ -167,6 +181,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
167
181
  const startupCwd = workingDir; // 质检 A3:启动工作目录恒为合法浏览根(工作空间切换后仍可浏览原位)
168
182
  // Provider 按模型懒加载缓存:界面切换模型后即时生效
169
183
  const providerCache = new Map();
184
+ /** @param {any} m */
170
185
  async function getProviderFor(m) {
171
186
  if (!providerCache.has(m)) {
172
187
  providerCache.set(m, await createProvider(cfg, m));
@@ -176,19 +191,20 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
176
191
  const provider = await getProviderFor(modelName);
177
192
  const undoStore = { backups: new Map() };
178
193
 
179
- // MCP:后台启动,就绪后工具并入后续轮次
194
+ // MCP:A2 预热——await 连接(6s 超时);超时本会话冻结工具集(不再中途注入,保护前缀缓存)
195
+ /** @type {any} */
180
196
  let mcpManager = null;
181
197
  if (cfg.mcpServers && Object.keys(cfg.mcpServers).length) {
182
- startMcpServers(cfg.mcpServers, workingDir)
183
- .then((m) => {
184
- mcpManager = m;
185
- })
186
- .catch(() => {});
198
+ mcpManager = await Promise.race([
199
+ startMcpServers(cfg.mcpServers, workingDir).catch(() => null),
200
+ new Promise((/** @type {any} */ r) => setTimeout(() => r(null), 6000)),
201
+ ]);
202
+ if (!mcpManager) console.error('[MingDao] ⚠ MCP 连接超时(6s):本会话不注入 MCP 工具(重启 mingdao web 可重试)');
187
203
  }
188
204
  const mcpFacade = {
189
205
  toolSchemas: () => (mcpManager ? mcpManager.toolSchemas() : []),
190
- call: (n, a) => (mcpManager ? mcpManager.call(n, a) : Promise.reject(new Error('MCP 未就绪'))),
191
- isReadonly: (n) => (mcpManager ? mcpManager.isReadonly(n) : false),
206
+ call: (/** @type {any} */ n, /** @type {any} */ a) => (mcpManager ? mcpManager.call(n, a) : Promise.reject(new Error('MCP 未就绪'))),
207
+ isReadonly: (/** @type {any} */ n) => (mcpManager ? mcpManager.isReadonly(n) : false),
192
208
  status: () => (mcpManager ? mcpManager.status() : []),
193
209
  stop: () => {
194
210
  if (mcpManager) mcpManager.stop();
@@ -202,6 +218,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
202
218
  let taskSeq = 0;
203
219
  // 会话文件互斥(质检 C1/M3):同一 session 文件的 append 与 compact 整文件重写必须串行
204
220
  const sessionLocks = new Map();
221
+ /** @param {any} file @param {any} fn */
205
222
  function withSessionLock(file, fn) {
206
223
  const prev = sessionLocks.get(file) || Promise.resolve();
207
224
  const next = prev.then(fn, fn);
@@ -216,17 +233,25 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
216
233
  const srvlog = createLogWriter(path.join(mingdaoHome(), 'logs', 'web-server.log'));
217
234
 
218
235
  // —— SSRF 防护(质检 S1):远端地址校验 ——
236
+ /** @param {any} hostname */
219
237
  function isPrivateHost(hostname) {
220
- const h = String(hostname || '').toLowerCase();
238
+ let h = String(hostname || '').toLowerCase();
221
239
  if (!h) return true;
222
- if (h === 'localhost' || h.endsWith('.localhost') || h === '::1' || h === '::ffff:127.0.0.1') return true;
240
+ h = h.replace(/^\[|\]$/g, ''); // IPv6 字面量去括号(URL('http://[::1]/').hostname 带方括号)
241
+ if (h === 'localhost' || h.endsWith('.localhost') || h === '::1') return true;
242
+ if (h.includes(':')) {
243
+ // IPv6:回环 ::1、链路本地 fe80::/10、唯一本地 fc00::/7、IPv4 映射 ::ffff:私网
244
+ if (/^::ffff:/.test(h)) return isPrivateHost(h.slice(7));
245
+ return /^fe[89ab]/.test(h) || /^f[c d]/.test(h) || h === '::' || h === '::1';
246
+ }
223
247
  const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
224
- if (!m) return false; // 域名:交给 DNS 解析(无法静态判定)
248
+ if (!m) return false; // 域名:由 validateRemoteUrl 的 DNS 解析复检
225
249
  const a = Number(m[1]);
226
250
  const b = Number(m[2]);
227
251
  return a === 10 || a === 127 || a === 0 || a >= 224 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127);
228
252
  }
229
- function validateRemoteUrl(raw) {
253
+ /** @param {any} raw */
254
+ async function validateRemoteUrl(raw) {
230
255
  let u;
231
256
  try {
232
257
  u = new URL(String(raw));
@@ -235,10 +260,24 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
235
260
  }
236
261
  if (u.protocol !== 'http:' && u.protocol !== 'https:') return { error: '仅支持 http/https 地址' };
237
262
  // 威胁模型:本机回环绑定时(默认/桌面版),本机模型服务(如 Ollama)属可信场景,放行;
238
- // 对外监听(0.0.0.0)时拒绝私网目标——除非显式 allowPrivateEndpoints
263
+ // 对外监听(0.0.0.0)时拒绝私网目标——除非显式 allowPrivateEndpoints。
264
+ // 域名目标:DNS 解析后逐条校验(2026-09-02 加固,防 DNS 重绑定把域名指向内网绕过字面量检查)。
239
265
  const serverBoundLocal = isLoopbackHost(host);
240
- if (!cfg.web?.allowPrivateEndpoints && !serverBoundLocal && isPrivateHost(u.hostname)) {
241
- return { error: `拒绝访问内网/本机地址(${u.hostname})——服务已对外监听,如确需连接可信内网服务,请在 config.json 设 web.allowPrivateEndpoints: true` };
266
+ if (!cfg.web?.allowPrivateEndpoints && !serverBoundLocal) {
267
+ const h = String(u.hostname || '').toLowerCase();
268
+ let blocked = isPrivateHost(h);
269
+ if (!blocked && h && h !== 'localhost' && !/^\d{1,3}(\.\d{1,3}){3}$/.test(h)) {
270
+ try {
271
+ const { lookup } = await import('node:dns/promises');
272
+ const addrs = await lookup(h, { all: true, verbatim: true });
273
+ blocked = addrs.some((/** @type {any} */ a) => isPrivateHost(a.address));
274
+ } catch {
275
+ // DNS 解析失败:保持放行(服务端会在连接时报错);解析成功且指向内网 → 拦截
276
+ }
277
+ }
278
+ if (blocked) {
279
+ return { error: `拒绝访问内网/本机地址(${u.hostname})——服务已对外监听,如确需连接可信内网服务,请在 config.json 设 web.allowPrivateEndpoints: true` };
280
+ }
242
281
  }
243
282
  return { ok: true };
244
283
  }
@@ -261,11 +300,13 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
261
300
  }
262
301
  }
263
302
 
303
+ /** @param {any} res @param {any} body */
264
304
  async function handleChat(res, body) {
265
305
  const taskId = `t${++taskSeq}`; // 服务端生成:客户端自选 taskId 可能覆盖他人任务
266
- const entry = { res, send: null, abortHandler: null, pendingAsk: null, session: null, startedAt: Date.now(), status: 'running', message: '', durationMs: 0 };
306
+ const entry = /** @type {any} */ ({ res, send: null, abortHandler: null, pendingAsk: null, session: null, startedAt: Date.now(), status: 'running', message: '', durationMs: 0 });
267
307
  srvlog('chat 开始 ' + taskId + ' session=' + (body.file || '新会话') + ' 消息长度=' + String(body.message || '').length);
268
308
  tasks.set(taskId, entry);
309
+ /** @param {any} obj */
269
310
  const send = (obj) => {
270
311
  try {
271
312
  res.write(`data: ${JSON.stringify({ ...obj, taskId })}\n\n`);
@@ -301,6 +342,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
301
342
  return;
302
343
  }
303
344
 
345
+ /** @type {any} */
304
346
  let session = null;
305
347
  const isNew = !body.file;
306
348
  if (body.file) {
@@ -355,7 +397,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
355
397
  let persistedBefore = messages.length;
356
398
 
357
399
  // 权限/选择类交互:发 ask 事件(带 taskId),等待 POST /api/permission 应答
358
- const askHandler = ({ question, hidden, options, label, confirm }) =>
400
+ const askHandler = (/** @type {any} */ { question, hidden, options, label, confirm }) =>
359
401
  new Promise((resolve) => {
360
402
  const id = Math.random().toString(36).slice(2);
361
403
  entry.pendingAsk = { id, resolve };
@@ -363,19 +405,19 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
363
405
  type: 'ask',
364
406
  id,
365
407
  question: question ?? label ?? '',
366
- options: options ? options.map((o) => ({ value: o.value, label: o.label })) : null,
408
+ options: options ? options.map((/** @type {any} */ o) => ({ value: o.value, label: o.label })) : null,
367
409
  confirm: Boolean(confirm ?? !options),
368
410
  hidden: Boolean(hidden),
369
411
  });
370
412
  });
371
413
 
372
- const io = createWebIO({
414
+ const io = /** @type {any} */ (createWebIO({
373
415
  send,
374
416
  askHandler,
375
- setAbortHandler: (fn) => {
417
+ setAbortHandler: (/** @type {any} */ fn) => {
376
418
  entry.abortHandler = fn;
377
419
  },
378
- });
420
+ }));
379
421
  const permission = createPermission(cfg.permission ?? 'ask', io);
380
422
  let providerNow;
381
423
  try {
@@ -384,7 +426,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
384
426
  throw new Error(`模型 ${runModel} 尚未配置 API Key:请点击右上角 ⚙ 设置 →「模型与 API Key」填入密钥。`);
385
427
  }
386
428
  providerNow = await getProviderFor(runModel); // 审计 P1-1:失败时清理任务占位,避免僵尸 running 耗尽并发
387
- } catch (err) {
429
+ } catch (/** @type {any} */ err) {
388
430
  entry.status = 'failed';
389
431
  entry.durationMs = Date.now() - entry.startedAt;
390
432
  send({ type: 'error', message: `模型 ${runModel} 不可用:${String(err?.message || err)}` });
@@ -403,7 +445,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
403
445
  undoStore,
404
446
  mcp: mcpFacade,
405
447
  // 自动压缩后重写会话文件(否则每次加载历史都会重新触发压缩),并同步落盘游标
406
- onCompact: (msgs) => {
448
+ onCompact: (/** @type {any} */ msgs) => {
407
449
  withSessionLock(session.file, () => rewriteSession(session.file, msgs)).catch(() => {}); // onCompact 为同步回调:入队串行即可
408
450
  persistedBefore = msgs.length;
409
451
  },
@@ -435,7 +477,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
435
477
  session.routeStats = { steps: st.steps + (io.stats().toolCount || 0), truncated: st.truncated + (r.truncated ? 1 : 0) };
436
478
  await withSessionLock(session.file, () => appendMessages(session.file, messages.slice(persistedBefore)));
437
479
  io.printUsageLine({ modelName: runModel, usage: r.usage, durationMs: r.durationMs });
438
- recordUsage(r.perf?.usedModel || runModel, r.usage, r.perf);
480
+ recordUsage(r.perf?.usedModel || runModel, r.usage, /** @type {any} */ (r.perf));
439
481
  maybeAutoSync().catch(() => {});
440
482
  // 新会话自动标题(可配置关闭)
441
483
  srvlog('chat 标题生成前 ' + taskId);
@@ -460,7 +502,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
460
502
  const { makeTokenCounter } = await import('../tokenizer.js');
461
503
  const { messageTokens } = await import('../context.js');
462
504
  const counter = makeTokenCounter(runModel);
463
- const used = messages.reduce((sum, m) => sum + messageTokens(m, counter), 0);
505
+ const used = messages.reduce((/** @type {any} */ sum, /** @type {any} */ m) => sum + messageTokens(m, counter), 0);
464
506
  budgetInfo = { used, total: cfg.contextBudget || modelPreset(runModel)?.budgetTokens || 128000 };
465
507
  } catch {}
466
508
  send({
@@ -476,7 +518,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
476
518
  stats: io.stats(),
477
519
  session: path.basename(session.file),
478
520
  });
479
- } catch (err) {
521
+ } catch (/** @type {any} */ err) {
480
522
  entry.status = 'failed';
481
523
  entry.durationMs = Date.now() - entry.startedAt;
482
524
  srvlog('chat 错误 ' + taskId + ' ' + String(err?.message || err));