mingdao-harness 0.4.4 → 0.4.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 (69) hide show
  1. package/docs/ARCHITECTURE.md +1 -1
  2. package/docs/AUDIT-v0.4.6.md +179 -0
  3. package/docs/PACK-API.md +250 -0
  4. package/docs/SAVINGS-BENCHMARK.md +12 -5
  5. package/docs/STRATEGY-0.5.md +262 -0
  6. package/docs/STRATEGY-NEXT.md +1 -1
  7. package/package.json +2 -1
  8. package/skills-lib/bash/SKILL.md +26 -0
  9. package/skills-lib/changelog/SKILL.md +24 -0
  10. package/skills-lib/ci-cd/SKILL.md +25 -0
  11. package/skills-lib/data-analysis/SKILL.md +25 -0
  12. package/skills-lib/database-design/SKILL.md +25 -0
  13. package/skills-lib/docs/SKILL.md +25 -0
  14. package/skills-lib/email/SKILL.md +25 -0
  15. package/skills-lib/file-organize/SKILL.md +27 -0
  16. package/skills-lib/git-workflow/SKILL.md +26 -0
  17. package/skills-lib/i18n/SKILL.md +24 -0
  18. package/skills-lib/markdown/SKILL.md +26 -0
  19. package/skills-lib/meeting-notes/SKILL.md +25 -0
  20. package/skills-lib/nodejs/SKILL.md +26 -0
  21. package/skills-lib/performance/SKILL.md +25 -0
  22. package/skills-lib/python/SKILL.md +26 -0
  23. package/skills-lib/readme/SKILL.md +25 -0
  24. package/skills-lib/regex/SKILL.md +25 -0
  25. package/skills-lib/report/SKILL.md +25 -0
  26. package/skills-lib/resume/SKILL.md +24 -0
  27. package/skills-lib/security-audit/SKILL.md +28 -0
  28. package/skills-lib/sql/SKILL.md +25 -0
  29. package/skills-lib/translation/SKILL.md +25 -0
  30. package/src/agent.js +95 -9
  31. package/src/atomic-write.js +9 -0
  32. package/src/autostart.js +37 -3
  33. package/src/batch.js +9 -1
  34. package/src/cachestats.js +32 -8
  35. package/src/cli.js +29 -14
  36. package/src/commands/repl.js +5 -3
  37. package/src/commands/update.js +1 -1
  38. package/src/compact.js +16 -1
  39. package/src/context.js +8 -0
  40. package/src/cost-guard.js +26 -6
  41. package/src/hooks.js +20 -4
  42. package/src/log-writer.js +19 -7
  43. package/src/mcp.js +4 -0
  44. package/src/memory.js +6 -5
  45. package/src/model-caps.js +48 -4
  46. package/src/permissions.js +10 -4
  47. package/src/presets.js +13 -3
  48. package/src/pricing.js +78 -18
  49. package/src/providers/index.js +43 -13
  50. package/src/providers/openai-compatible.js +42 -1
  51. package/src/redact.js +5 -0
  52. package/src/schedule.js +62 -30
  53. package/src/session.js +14 -1
  54. package/src/skill-lib.js +23 -4
  55. package/src/skills.js +2 -2
  56. package/src/sync-server.js +29 -1
  57. package/src/sync.js +6 -3
  58. package/src/tasks/worker.js +4 -1
  59. package/src/tasks.js +4 -1
  60. package/src/tokenizer.js +48 -11
  61. package/src/tools/bash.js +9 -2
  62. package/src/tools/fetch.js +97 -8
  63. package/src/tools/fs-tools.js +48 -3
  64. package/src/tools/git.js +17 -1
  65. package/src/tools/index.js +12 -5
  66. package/src/web/constants.js +10 -0
  67. package/src/web/routes/api.js +3 -1
  68. package/src/web/routes/domains/misc.js +6 -0
  69. package/src/web/server.js +41 -31
package/src/web/server.js CHANGED
@@ -25,9 +25,10 @@ import { createProvider, resolveProviderConfig, helperProvider } from '../provid
25
25
  import { MODELS, modelPreset, PROVIDERS } from '../models.js';
26
26
  import { routeTask, routingConfig } from '../routing.js';
27
27
  import { buildUserContent } from './attachments.js';
28
- import { MAX_CONCURRENT } from './constants.js';
28
+ import { MAX_CONCURRENT, SECURITY_HEADERS } from './constants.js';
29
29
  import { createAgent } from '../agent.js';
30
30
  import { createPermission } from '../permissions.js';
31
+ import { isPrivateHost as sharedIsPrivateHost } from '../tools/fetch.js';
31
32
  import { buildSystemPrompt } from '../prompts.js';
32
33
  import { loadProjectMemory, loadProjectMemoryEntries, retrieveRelevant, extractAndAppendProjectMemory } from '../memory.js';
33
34
  import { saveTaskStateMerge, clearTaskState, loadTaskState, resumePrompt } from '../task-state.js';
@@ -69,7 +70,7 @@ const INDEX_HTML = path.join(path.dirname(fileURLToPath(import.meta.url)), 'inde
69
70
 
70
71
  /** @param {any} res @param {any} code @param {any} obj */
71
72
  function json(res, code, obj) {
72
- res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
73
+ res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...SECURITY_HEADERS });
73
74
  res.end(JSON.stringify(obj));
74
75
  // 返回 truthy:域路由 `return json(...)` 必须被编排器视为「已处理」——
75
76
  // 否则兜底 404 会对已结束的响应二次写入(Node 18 下 write-after-end 未捕获 → 整服务崩溃)
@@ -85,15 +86,30 @@ function readBody(req, limit = 40 * 1024 * 1024) {
85
86
  /** @type {any[]} */
86
87
  const chunks = [];
87
88
  let size = 0;
89
+ let settled = false;
90
+ const settle = (/** @type {any} */ fn, /** @type {any} */ v) => {
91
+ if (settled) return;
92
+ settled = true;
93
+ clearTimeout(slowTimer);
94
+ fn(v);
95
+ };
88
96
  // 审计 P2-6:慢速连接防护——60s 未传完请求体即断开,防占满 socket
89
97
  const slowTimer = setTimeout(() => {
90
98
  const err = /** @type {Error & { status?: number }} */ (new Error('请求体上传超时(60s)'));
91
99
  err.status = 408;
92
100
  req.destroy();
93
- reject(err);
101
+ settle(reject, err);
94
102
  }, 60000);
95
- req.on('end', () => clearTimeout(slowTimer));
96
- req.on('close', () => clearTimeout(slowTimer));
103
+ // P1-6(v0.4.5):body 上传中断(客户端刷新/abort)时 Node 通常只触发 close 不发 error/end——
104
+ // 此前 close 仅 clearTimeout 不 reject → Promise 永久 pending → /api/chat 的 inflight 槽永久泄漏,
105
+ // 重复 N 次后服务对所有会话 429。close 且未 settle 时 reject(status 499)。
106
+ req.on('close', () => {
107
+ if (!settled) {
108
+ const err = /** @type {Error & { status?: number }} */ (new Error('请求体上传中断(客户端断开)'));
109
+ err.status = 499;
110
+ settle(reject, err);
111
+ }
112
+ });
97
113
  req.on('data', (/** @type {any} */ d) => {
98
114
  size += d.length;
99
115
  if (size > MAX_BODY) {
@@ -102,7 +118,7 @@ function readBody(req, limit = 40 * 1024 * 1024) {
102
118
  // 排空残余数据但保留连接:让上层 catch 返回真 413(此前 destroy 导致客户端只收到连接重置)
103
119
  req.pause();
104
120
  req.resume();
105
- reject(err);
121
+ settle(reject, err);
106
122
  return;
107
123
  }
108
124
  chunks.push(d);
@@ -110,12 +126,12 @@ function readBody(req, limit = 40 * 1024 * 1024) {
110
126
  req.on('end', () => {
111
127
  const raw = Buffer.concat(chunks).toString('utf8');
112
128
  try {
113
- resolve(raw ? JSON.parse(raw) : {});
129
+ settle(resolve, raw ? JSON.parse(raw) : {});
114
130
  } catch {
115
- resolve({});
131
+ settle(resolve, {});
116
132
  }
117
133
  });
118
- req.on('error', reject);
134
+ req.on('error', (/** @type {any} */ e) => settle(reject, e));
119
135
  });
120
136
  }
121
137
 
@@ -260,23 +276,12 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
260
276
  const srvlog = createLogWriter(path.join(mingdaoHome(), 'logs', 'web-server.log'));
261
277
 
262
278
  // —— SSRF 防护(质检 S1):远端地址校验 ——
263
- /** @param {any} hostname */
264
- function isPrivateHost(hostname) {
265
- let h = String(hostname || '').toLowerCase();
266
- if (!h) return true;
267
- h = h.replace(/^\[|\]$/g, ''); // IPv6 字面量去括号(URL('http://[::1]/').hostname 带方括号)
268
- if (h === 'localhost' || h.endsWith('.localhost') || h === '::1') return true;
269
- if (h.includes(':')) {
270
- // IPv6:回环 ::1、链路本地 fe80::/10、唯一本地 fc00::/7、IPv4 映射 ::ffff:私网
271
- if (/^::ffff:/.test(h)) return isPrivateHost(h.slice(7));
272
- return /^fe[89ab]/.test(h) || /^f[cd]/.test(h) || h === '::' || h === '::1';
273
- }
274
- const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
275
- if (!m) return false; // 域名:由 validateRemoteUrl 的 DNS 解析复检
276
- const a = Number(m[1]);
277
- const b = Number(m[2]);
278
- 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);
279
- }
279
+ // v0.4.6 P1:本文件的 isPrivateHost 副本已删除,改用 tools/fetch.js 的单一实现。
280
+ // 副本只识别 `::ffff:` + 点分四段,而 URL 解析器会把该形态规范化成十六进制
281
+ // ([::ffff:127.0.0.1] → [::ffff:7f00:1]),副本判为公网 → 私网目标放行。
282
+ // 单一来源同时覆盖 skill-lib.installFromUrl 与 fetch 工具,避免复现同款缺口。
283
+ const isPrivateHost = (/** @type {any} */ h) => sharedIsPrivateHost(h);
284
+ const isIpLiteral = (/** @type {any} */ h) => /^\d{1,3}(\.\d{1,3}){3}$/.test(String(h)) || String(h).includes(':');
280
285
  /** @param {any} raw */
281
286
  async function validateRemoteUrl(raw) {
282
287
  let u;
@@ -293,7 +298,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
293
298
  if (!cfg.web?.allowPrivateEndpoints && !serverBoundLocal) {
294
299
  const h = String(u.hostname || '').toLowerCase();
295
300
  let blocked = isPrivateHost(h);
296
- if (!blocked && h && h !== 'localhost' && !/^\d{1,3}(\.\d{1,3}){3}$/.test(h)) {
301
+ if (!blocked && h && h !== 'localhost' && !isIpLiteral(h)) {
297
302
  try {
298
303
  const { lookup } = await import('node:dns/promises');
299
304
  const addrs = await lookup(h, { all: true, verbatim: true });
@@ -475,8 +480,12 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
475
480
  // 权限/选择类交互:发 ask 事件(带 taskId),等待 POST /api/permission 应答
476
481
  const askHandler = (/** @type {any} */ { question, hidden, options, label, confirm }) =>
477
482
  new Promise((resolve) => {
478
- const id = Math.random().toString(36).slice(2);
479
- entry.pendingAsk = { id, resolve };
483
+ // P1 修复(v0.4.6):ask id 必须是不可猜测的随机值,且服务端在应答时校验它——
484
+ // 此前用 Math.random().toString(36) 且 /api/permission 从不核对 id,导致任何能访问
485
+ // API 的一方(局域网共享 token、或回环下的本机进程)只要知道可枚举的 taskId,
486
+ // 就能替他人的挂起确认直接答「允许」,把默认 ask 档这道唯一的人工闸门整个绕过。
487
+ const id = crypto.randomBytes(16).toString('hex');
488
+ entry.pendingAsk = { id, options: options || null, resolve };
480
489
  send({
481
490
  type: 'ask',
482
491
  id,
@@ -513,14 +522,15 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
513
522
  return;
514
523
  }
515
524
  // v0.4.4:长任务费用逐轮入账——记录已按轮入账的累计 usage,最终补记最后一轮 + 兜底总结的剩余。
525
+ // v0.4.5:onUsage 收 (modelName, delta)——主/子代理各自按实际模型归属(子代理经 spawnTask 透传,费用不漏计)。
516
526
  const recorded = { prompt_tokens: 0, completion_tokens: 0, prompt_cache_hit_tokens: 0, prompt_cache_miss_tokens: 0 };
517
- const onUsage = (/** @type {any} */ delta) => {
527
+ const onUsage = (/** @type {string} */ modelName, /** @type {any} */ delta) => {
518
528
  recorded.prompt_tokens += delta?.prompt_tokens || 0;
519
529
  recorded.completion_tokens += delta?.completion_tokens || 0;
520
530
  recorded.prompt_cache_hit_tokens += delta?.prompt_cache_hit_tokens || 0;
521
531
  recorded.prompt_cache_miss_tokens += delta?.prompt_cache_miss_tokens || 0;
522
532
  try {
523
- recordUsage(runModel, delta, null);
533
+ recordUsage(modelName, delta, null);
524
534
  } catch {}
525
535
  };
526
536
  const agent = createAgent({