mingdao-harness 0.4.3 → 0.4.5

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.
@@ -92,14 +92,20 @@ export async function createProvider(/** @type {any} */ cfg, /** @type {any} */
92
92
  config: pc,
93
93
  async chat(/** @type {any} */ opts) {
94
94
  let attempt = 0;
95
+ // P1-7(v0.4.5):总量护栏提到重试循环外层——此前每 attempt 重建 totalTimer,重试序列最坏
96
+ // 放大 retries+1 倍(本地 3×30min=90min)。总时长覆盖整个重试序列,单次首 token/流式空闲仍按 attempt 计。
97
+ let totalExpired = false;
98
+ let currentAc = /** @type {AbortController | null} */ (null);
99
+ const totalTimer = setTimeout(() => {
100
+ totalExpired = true;
101
+ try {
102
+ currentAc?.abort(new Error(`请求总时长超限(${Math.round(totalMs / 1000)}s),已中断`));
103
+ } catch {}
104
+ }, totalMs);
95
105
  for (;;) {
96
106
  const ac = new AbortController();
97
- let timedOut = false; // 审计 P2-6:用标志而非 name/字符串匹配识别内部超时
98
- // 总量护栏:整次请求(prefill+生成)的绝对上限
99
- const totalTimer = setTimeout(() => {
100
- timedOut = true;
101
- ac.abort(new Error(`请求总时长超限(${Math.round(totalMs / 1000)}s),已中断`));
102
- }, totalMs);
107
+ currentAc = ac;
108
+ let timedOut = false; // 审计 P2-6:用标志而非 name/字符串匹配识别内部超时(首 token/流式空闲)
103
109
  // 首 token 等待:prefill 阶段无任何帧到达即断(覆盖长上下文慢 prefill)
104
110
  let firstTokenTimer = /** @type {ReturnType<typeof setTimeout> | null} */ (setTimeout(() => {
105
111
  timedOut = true;
@@ -125,6 +131,7 @@ export async function createProvider(/** @type {any} */ cfg, /** @type {any} */
125
131
  const onUserAbort = () => ac.abort(opts.signal?.reason);
126
132
  if (opts.signal?.aborted) onUserAbort();
127
133
  else opts.signal?.addEventListener('abort', onUserAbort, { once: true });
134
+ let leaving = true; // 本次 attempt 是否退出(成功/不再重试);将重试则置 false,总量计时器保留覆盖下一 attempt
128
135
  try {
129
136
  return await openaiChat({
130
137
  ...opts,
@@ -135,9 +142,11 @@ export async function createProvider(/** @type {any} */ cfg, /** @type {any} */
135
142
  onActivity,
136
143
  });
137
144
  } catch (err) {
138
- // 内部超时经 abort 抛出,用标志识别(审计 P2-6);用户 Ctrl+C 的中断不算超时、不重试
139
- const transient = (timedOut && !opts.signal?.aborted) || isTransient(err);
145
+ // 内部超时经 abort 抛出,用标志识别(审计 P2-6);用户 Ctrl+C 的中断不算超时、不重试。
146
+ // 总量超时(totalExpired)覆盖整个序列,超了直接抛不再重试。
147
+ const transient = !totalExpired && ((timedOut && !opts.signal?.aborted) || isTransient(err));
140
148
  if (!transient || attempt >= retries) throw err;
149
+ leaving = false; // 将重试:本次不退出,总量计时器继续覆盖下一 attempt
141
150
  attempt += 1;
142
151
  // 首 token 等待超时通常不是偶发网络抖动(是模型/上下文慢),重试价值低但保留一次机会;
143
152
  // 其余瞬态错误指数退避 + 尊重 Retry-After(评估 P3-1):基础 1s/2s,封顶 30s
@@ -147,7 +156,7 @@ export async function createProvider(/** @type {any} */ cfg, /** @type {any} */
147
156
  backoff = Math.min(backoff, 30000);
148
157
  await sleep(backoff);
149
158
  } finally {
150
- clearTimeout(totalTimer);
159
+ if (leaving) clearTimeout(totalTimer); // 成功/最终失败即清——避免悬挂 totalMs 计时器(每请求一个)
151
160
  if (firstTokenTimer) clearTimeout(firstTokenTimer);
152
161
  if (idleTimer) clearTimeout(idleTimer);
153
162
  opts.signal?.removeEventListener('abort', onUserAbort);
@@ -6,6 +6,20 @@
6
6
  * @typedef {Error & { status?: number, headers?: Headers }} ApiError
7
7
  */
8
8
 
9
+ // 部分网关/本地推理框架在拒绝请求(如 507 memory_refusal)时不回非 2xx,而是回
10
+ // 200 + {"error":{"code":507,"message":"memory_refusal"}},或 SSE 首帧夹带 error 对象。
11
+ // 若不识别,会被当作「空输出」吞掉,误报成「模型本轮没有输出正文。」(MacBook 本地 mtplx 507 根因)。
12
+ function extractStreamError(/** @type {any} */ json) {
13
+ const err = json?.error;
14
+ if (!err) return null;
15
+ const code = Number(err?.code || err?.status_code || err?.status || 0);
16
+ const msg = String(err?.message || err?.type || '模型返回错误');
17
+ /** @type {ApiError} */
18
+ const e = new Error(`[流式响应错误${code ? ' ' + code : ''}] ${msg}`);
19
+ if (code) e.status = code;
20
+ return e;
21
+ }
22
+
9
23
  export async function chat(/** @type {any} */ { baseUrl, apiKey, model, messages, tools, temperature, maxTokens, signal, onDelta, onActivity, includeUsage = true, responseFormat, reasoningEffort }) {
10
24
  const url = String(baseUrl).replace(/\/+$/, '') + '/chat/completions';
11
25
  const payload = /** @type {Record<string, any>} */ ({ model, messages });
@@ -68,6 +82,8 @@ export async function chat(/** @type {any} */ { baseUrl, apiKey, model, messages
68
82
  }
69
83
 
70
84
  export function parseNonStream(/** @type {any} */ json, /** @type {any} */ onDelta) {
85
+ const se = extractStreamError(json);
86
+ if (se) throw se;
71
87
  const choice = json?.choices?.[0];
72
88
  const msg = choice?.message ?? {};
73
89
  if (msg.content) onDelta?.({ text: msg.content });
@@ -113,6 +129,9 @@ export async function parseStream(/** @type {any} */ body, /** @type {any} */ on
113
129
  } catch {
114
130
  return;
115
131
  }
132
+ // 200 流里夹带 error 帧(本地内存拒绝等):立即上抛,不当作空输出吞掉
133
+ const se = extractStreamError(json);
134
+ if (se) throw se;
116
135
  // 注意:DeepSeek/OpenAI 流式最后一块常是 usage-only(choices 为空),必须先取 usage 再判 choices
117
136
  if (json.usage) usage = json.usage;
118
137
  const choice = json?.choices?.[0];
@@ -147,9 +166,31 @@ export async function parseStream(/** @type {any} */ body, /** @type {any} */ on
147
166
  if (choice.finish_reason) finish = choice.finish_reason;
148
167
  };
149
168
 
169
+ // P1-6(v0.4.5):[DONE] 后不再无限等待网关关流——此前外层 for 继续 read 至流关闭,
170
+ // 部分网关/代理在 [DONE] 后不主动断开会挂到 streamIdleMs(120s)。但部分网关在 [DONE] 之后
171
+ // 才发 usage-only 终包(且末帧常不带换行),直接退出会丢 usage。因此进入「有界排空」:
172
+ // 继续读残余帧,每次 read 用短超时兜底,超时或流关闭即停止——usage 尾帧仍被捕获,
173
+ // 正常流在 [DONE] 后立即关流,Promise.race 随即以 done 返回,不增加延迟。
174
+ const DRAIN_TIMEOUT_MS = 500;
175
+ const readDrain = async () => {
176
+ let t;
177
+ const timer = new Promise((resolve) => {
178
+ t = setTimeout(() => resolve({ done: false, value: null, timedOut: true }), DRAIN_TIMEOUT_MS);
179
+ });
180
+ try {
181
+ return await Promise.race([reader.read(), timer]);
182
+ } finally {
183
+ clearTimeout(t);
184
+ }
185
+ };
150
186
  for (;;) {
151
- const { done, value } = await reader.read();
187
+ const { done, value } = await (doneFlag ? readDrain() : reader.read());
152
188
  if (done) break;
189
+ if (value == null) {
190
+ // 排空超时:不再等,取消 reader 释放连接(防火墙/网关持流不关的场景)
191
+ reader.cancel().catch(() => {});
192
+ break;
193
+ }
153
194
  buf += decoder.decode(value, { stream: true });
154
195
  let nl;
155
196
  while ((nl = buf.indexOf('\n')) >= 0) {
package/src/schedule.js CHANGED
@@ -171,7 +171,10 @@ export function removeSchedule(/** @type {any} */ home, /** @type {any} */ id) {
171
171
  }
172
172
  }
173
173
  // 正在跑的 worker 同步停止,避免成孤儿继续执行
174
- if (job.lastTaskId && isRunningTask(home, job.lastTaskId)) killTask(home, job.lastTaskId);
174
+ // P0-2(v0.4.5):killTask 失败不应阻断「删除」这一更强用户意图——包 try/catch 保证删除照常
175
+ try {
176
+ if (job.lastTaskId && isRunningTask(home, job.lastTaskId)) killTask(home, job.lastTaskId);
177
+ } catch {}
175
178
  try {
176
179
  fs.unlinkSync(path.join(scheduleDir(home), id + '.json'));
177
180
  } catch {}
@@ -192,26 +195,32 @@ export function pauseSchedule(/** @type {any} */ home, /** @type {any} */ id) {
192
195
  } catch {}
193
196
  }
194
197
  }
195
- if (job.lastTaskId && isRunningTask(home, job.lastTaskId)) killTask(home, job.lastTaskId);
198
+ // P0-2(v0.4.5):killTask 失败不应阻断「暂停」的状态写
199
+ try {
200
+ if (job.lastTaskId && isRunningTask(home, job.lastTaskId)) killTask(home, job.lastTaskId);
201
+ } catch {}
196
202
  writeSchedule(home, { ...job, status: 'paused', pid: null, lastTaskId: null });
197
203
  return true;
198
204
  });
199
205
  }
200
206
 
201
207
  export function resumeSchedule(/** @type {any} */ home, /** @type {any} */ id) {
202
- const job = readSchedule(home, id);
203
- if (!job || job.status !== 'paused') return false;
204
- let next = job.nextRunAt;
205
- if (job.kind === 'every') {
206
- next = job.anchor ? nextAnchorAfter(job.anchor, job.interval) || Date.now() + job.interval : Date.now() + job.interval;
207
- } else if (job.kind === 'once') {
208
- if (next && next <= Date.now()) next = Date.now() + 30000; // 已过期的一次性任务恢复后 30s 执行
209
- } else next = Date.now();
210
- const nextJob = { ...job, status: 'pending', nextRunAt: next };
211
- writeSchedule(home, nextJob);
212
- // 审计修复:守护进程在时只更新状态交给 daemon 接管;否则旧式 sleeper 兜底(避免双跑)
213
- if (process.env.MINGDAO_NO_DAEMON === '1' || !daemonAlive(home)) spawnSleeper(home, nextJob);
214
- return true;
208
+ // 评估 6.3(v0.4.3):读-改-写序列加锁,与 remove/pause 及 sleeper 状态写互斥(防丢更新)
209
+ return withFileLockSync(path.join(scheduleDir(home), '.lock'), () => {
210
+ const job = readSchedule(home, id);
211
+ if (!job || job.status !== 'paused') return false;
212
+ let next = job.nextRunAt;
213
+ if (job.kind === 'every') {
214
+ next = job.anchor ? nextAnchorAfter(job.anchor, job.interval) || Date.now() + job.interval : Date.now() + job.interval;
215
+ } else if (job.kind === 'once') {
216
+ if (next && next <= Date.now()) next = Date.now() + 30000; // 已过期的一次性任务恢复后 30s 执行
217
+ } else next = Date.now();
218
+ const nextJob = { ...job, status: 'pending', nextRunAt: next };
219
+ writeSchedule(home, nextJob);
220
+ // 审计修复:守护进程在时只更新状态交给 daemon 接管;否则旧式 sleeper 兜底(避免双跑)
221
+ if (process.env.MINGDAO_NO_DAEMON === '1' || !daemonAlive(home)) spawnSleeper(home, nextJob);
222
+ return true;
223
+ });
215
224
  }
216
225
 
217
226
  // 每日锚点:锚点时刻(HH:MM)对齐到 now 之后的最近一次
@@ -351,6 +360,15 @@ export async function runSleeper(/** @type {any} */ home, /** @type {any} */ id)
351
360
 
352
361
  const wait = (/** @type {any} */ ms) => new Promise((r) => setTimeout(r, ms));
353
362
 
363
+ // P1-15(v0.4.5):标记 running 前加锁并复查 paused——此前锁外 writeSchedule({...cur,'running'})
364
+ // 与 pauseSchedule 锁内写 paused 交错时,running 会覆盖 pause(任务继续触发,pause 语义失效)。
365
+ const markRunning = () => withFileLockSync(path.join(scheduleDir(home), '.lock'), () => {
366
+ const c = readSchedule(home, id);
367
+ if (!c || c.status === 'paused') return false; // 已删除或已暂停:不覆盖、不再触发
368
+ writeSchedule(home, { ...c, status: 'running' });
369
+ return true;
370
+ });
371
+
354
372
  // 依赖检查:支持两种依赖——调度任务 id(chain 编排)或后台任务 id(mingdao run 输出)
355
373
  async function depsSatisfied(/** @type {any} */ deps) {
356
374
  for (const dep of deps) {
@@ -373,8 +391,11 @@ export async function runSleeper(/** @type {any} */ home, /** @type {any} */ id)
373
391
  // 顺延到最近闲时起点(12:00 / 18:00)执行,输入价省 50%
374
392
  if (job.offpeak && isPeakHour(new Date())) {
375
393
  const defer = deferToOffpeak(new Date());
376
- const curN = readSchedule(home, id);
377
- if (curN) writeSchedule(home, { ...curN, note: `避峰等待至北京时间 ${defer.toISOString().slice(11, 16)}(闲时起执行)` });
394
+ // P2-4(v0.4.5):note 更新同样加锁 + 复查 paused(避免「读 curN 到写回之间 pause」被覆盖)
395
+ withFileLockSync(path.join(scheduleDir(home), '.lock'), () => {
396
+ const curN = readSchedule(home, id);
397
+ if (curN && curN.status !== 'paused') writeSchedule(home, { ...curN, note: `避峰等待至北京时间 ${defer.toISOString().slice(11, 16)}(闲时起执行)` });
398
+ });
378
399
  await wait(defer.getTime() - Date.now() + 2000);
379
400
  }
380
401
  if (job.after?.length) {
@@ -403,26 +424,32 @@ export async function runSleeper(/** @type {any} */ home, /** @type {any} */ id)
403
424
  killTask(home, task.id);
404
425
  t = { ...t, status: 'timedout' };
405
426
  }
406
- const cur0 = readSchedule(home, id);
407
- if (!cur0) return 'failed'; // 任务已被删除:停止后续写入
408
- const history = [...(cur0?.history || [])];
409
- history.push({
410
- taskId: task.id,
411
- status: t?.status || 'unknown',
412
- at: Date.now(),
413
- durationMs: t?.durationMs ?? null,
414
- text: (t?.text || t?.error || '').slice(0, 200),
415
- });
416
- if (history.length > 50) history.shift();
417
427
  const result = t?.status === 'done' ? 'done' : t?.status === 'timedout' ? 'timedout' : 'failed';
418
- writeSchedule(home, {
419
- ...cur0,
420
- lastRunAt: Date.now(),
421
- lastTaskId: task.id,
422
- runs: (cur0?.runs || 0) + 1,
423
- history,
424
- consecutiveFailures: result === 'done' ? 0 : prevFails + 1,
428
+ // 审计(H3 + 自检 P2):runOnce 收尾元数据读-改-写加锁——与 pause/remove(锁内写)跨进程互斥,
429
+ // 杜绝「读 cur0 到写回之间用户 pause」被覆盖(毫秒级窗口,彻底起见与终态写同锁、同复查)。
430
+ const wrote = withFileLockSync(path.join(scheduleDir(home), '.lock'), () => {
431
+ const curL = readSchedule(home, id);
432
+ if (!curL) return false; // 已删除:停止后续写入
433
+ const historyL = [...(curL?.history || [])];
434
+ historyL.push({
435
+ taskId: task.id,
436
+ status: t?.status || 'unknown',
437
+ at: Date.now(),
438
+ durationMs: t?.durationMs ?? null,
439
+ text: (t?.text || t?.error || '').slice(0, 200),
440
+ });
441
+ if (historyL.length > 50) historyL.shift();
442
+ writeSchedule(home, {
443
+ ...curL,
444
+ lastRunAt: Date.now(),
445
+ lastTaskId: task.id,
446
+ runs: (curL?.runs || 0) + 1,
447
+ history: historyL,
448
+ consecutiveFailures: result === 'done' ? 0 : prevFails + 1,
449
+ });
450
+ return true;
425
451
  });
452
+ if (!wrote) return 'failed'; // 任务已被删除
426
453
  return result;
427
454
  };
428
455
 
@@ -435,7 +462,7 @@ export async function runSleeper(/** @type {any} */ home, /** @type {any} */ id)
435
462
  await wait(Math.min(Math.max((cur.nextRunAt || now) - now, 1000), 60000));
436
463
  continue;
437
464
  }
438
- writeSchedule(home, { ...cur, status: 'running' });
465
+ if (!markRunning()) return; // P1-15:标记 running 前复查 paused(被暂停则直接退出)
439
466
  const result = await runOnce();
440
467
  // 质检 H3:状态读-改-写加锁(与 pause/remove 互斥,防丢更新)
441
468
  const nextState = withFileLockSync(path.join(scheduleDir(home), '.lock'), () => {
@@ -453,10 +480,15 @@ export async function runSleeper(/** @type {any} */ home, /** @type {any} */ id)
453
480
  await wait(Math.min(cur.nextRunAt - now, 60000));
454
481
  continue;
455
482
  }
456
- writeSchedule(home, { ...cur, status: 'running' });
483
+ if (!markRunning()) return; // P1-15:标记 running 前复查 paused
457
484
  const result = await runOnce();
458
- const cur2 = readSchedule(home, id);
459
- if (cur2) writeSchedule(home, { ...cur2, status: result });
485
+ // 评估 6.4(v0.4.3)+ P2-4(v0.4.5):once 最终状态读-改-写加锁,且 paused 不覆盖——
486
+ // 用户执行期间 pause(cur2.status==='paused')绝不被 done/failed 覆盖(every 经 postRunStatus 有防护,此处补齐)
487
+ withFileLockSync(path.join(scheduleDir(home), '.lock'), () => {
488
+ const cur2 = readSchedule(home, id);
489
+ if (!cur2 || cur2.status === 'paused') return;
490
+ writeSchedule(home, { ...cur2, status: result });
491
+ });
460
492
  return;
461
493
  } else {
462
494
  // after:轮询依赖,满足即执行一次后结束;任一依赖失败则跳过
@@ -466,13 +498,22 @@ export async function runSleeper(/** @type {any} */ home, /** @type {any} */ id)
466
498
  continue;
467
499
  }
468
500
  if (st === 'failed') {
469
- writeSchedule(home, { ...cur, status: 'skipped' });
501
+ // P2-4(v0.4.5):终态写加锁 + 复查 paused(依赖失败判 skipped 也不得覆盖 pause)
502
+ withFileLockSync(path.join(scheduleDir(home), '.lock'), () => {
503
+ const c2 = readSchedule(home, id);
504
+ if (!c2 || c2.status === 'paused') return;
505
+ writeSchedule(home, { ...c2, status: 'skipped' });
506
+ });
470
507
  return;
471
508
  }
472
- writeSchedule(home, { ...cur, status: 'running' });
509
+ if (!markRunning()) return; // P1-15:标记 running 前复查 paused
473
510
  const result = await runOnce();
474
- const cur2 = readSchedule(home, id);
475
- if (cur2) writeSchedule(home, { ...cur2, status: result });
511
+ // 评估 6.4(v0.4.3)+ P2-4(v0.4.5):after 最终状态读-改-写加锁,且 paused 不覆盖
512
+ withFileLockSync(path.join(scheduleDir(home), '.lock'), () => {
513
+ const cur2 = readSchedule(home, id);
514
+ if (!cur2 || cur2.status === 'paused') return;
515
+ writeSchedule(home, { ...cur2, status: result });
516
+ });
476
517
  return;
477
518
  }
478
519
  }
package/src/skill-lib.js CHANGED
@@ -188,10 +188,26 @@ export function validateSkillMarkdown(text, hint) {
188
188
  return { ok: true, name, description: desc };
189
189
  }
190
190
 
191
- /**
192
- * @param {any} dir
193
- * @param {any} hint
194
- */
191
+ // P1-5(v0.4.5):检测目录树内是否含符号链接——symlink 可越权读任意本机文件并架空 sha256 指纹检测。
192
+ function containsSymlink(/** @type {string} */ dir) {
193
+ const stack = [dir];
194
+ while (stack.length) {
195
+ const d = /** @type {string} */ (stack.pop());
196
+ let entries;
197
+ try {
198
+ entries = fs.readdirSync(d, { withFileTypes: true });
199
+ } catch {
200
+ continue;
201
+ }
202
+ for (const e of entries) {
203
+ if (e.isSymbolicLink()) return true;
204
+ if (e.isDirectory()) stack.push(path.join(d, e.name));
205
+ }
206
+ }
207
+ return false;
208
+ }
209
+
210
+ /** @param {any} dir @param {any} hint */
195
211
  export function validateSkillDir(dir, hint) {
196
212
  const skillMd = path.join(dir, 'SKILL.md');
197
213
  let text;
@@ -200,6 +216,9 @@ export function validateSkillDir(dir, hint) {
200
216
  } catch {
201
217
  return { error: `未找到 SKILL.md:${skillMd}` };
202
218
  }
219
+ if (containsSymlink(dir)) {
220
+ return { error: '技能目录含符号链接,已拒绝(防越权读取本机文件/绕过篡改检测)' };
221
+ }
203
222
  return validateSkillMarkdown(text, hint);
204
223
  }
205
224
 
package/src/skills.js CHANGED
@@ -63,7 +63,7 @@ export function tamperedSkillNames(/** @type {any} */ workingDir) {
63
63
  if (!e.isDirectory()) continue;
64
64
  const skillDir = path.join(dir, e.name);
65
65
  try {
66
- if (!fs.statSync(path.join(skillDir, 'SKILL.md')).isFile()) continue;
66
+ if (!fs.lstatSync(path.join(skillDir, 'SKILL.md')).isFile()) continue;
67
67
  } catch {
68
68
  continue;
69
69
  }
@@ -89,7 +89,7 @@ export function listSkills(/** @type {any} */ workingDir) {
89
89
  if (!e.isDirectory() || seen.has(e.name)) continue;
90
90
  const skillMd = path.join(dir, e.name, 'SKILL.md');
91
91
  try {
92
- if (!fs.statSync(skillMd).isFile()) continue;
92
+ if (!fs.lstatSync(skillMd).isFile()) continue;
93
93
  } catch {
94
94
  continue;
95
95
  }
@@ -68,7 +68,7 @@ export async function runWorkerTask(id, question, { permission, model, offpeak }
68
68
  if (mcpManager) mcpManager.stop();
69
69
  },
70
70
  };
71
- startMcpServers(cfg.mcpServers, workingDir).then((m) => (mcpManager = m)).catch(() => {});
71
+ startMcpServers(cfg.mcpServers, workingDir, cfg).then((m) => (mcpManager = m)).catch(() => {});
72
72
  }
73
73
  const sessionRef = /** @type {{name: any}} */ ({ name: null });
74
74
  let persistedCount = 0;
@@ -104,7 +104,10 @@ export async function runWorkerTask(id, question, { permission, model, offpeak }
104
104
  if (title) renameSessionFile(fs, path, home, session, title);
105
105
  } catch {}
106
106
  }
107
- const finalStatus = res.truncated ? 'failed' : res.aborted ? 'killed' : 'done';
107
+ // P1-3(v0.4.5):capHit(跑满步数上限后收尾)语义是「未真正完成、可续跑」——此前 worker 不判
108
+ // capHit 直接判 done,后台任务/链式编排把未完成任务当成功(假完成)。
109
+ const finalStatus = res.capHit ? 'failed' : res.truncated ? 'failed' : res.aborted ? 'killed' : 'done';
110
+ if (res.capHit && !note) note = '达到步数上限,任务未完成(可续跑)。';
108
111
  recordUsage(res.perf?.usedModel || modelName, res.usage, /** @type {any} */ (res.perf));
109
112
  finish({
110
113
  status: finalStatus,
package/src/tasks.js CHANGED
@@ -115,7 +115,10 @@ function killTaskInner(/** @type {any} */ home, /** @type {any} */ id) {
115
115
  try {
116
116
  owned = fs.readFileSync(`/proc/${t.pid}/cmdline`, 'utf8').includes(id);
117
117
  } catch {}
118
- if (owned === true) { // 非 Linux/无法校验(null)不盲杀(CodeBuddy 报告:PID 复用误杀风险)
118
+ // P0-3(v0.4.5):owned===null(非 Linux 无 /proc,或无法读取)时降级为「按 pid 存活即杀」——
119
+ // 此前 null 直接跳过,导致 macOS/Windows 上 kill 只改状态不杀进程、worker 继续跑完覆盖状态。
120
+ // 任务 id 含随机 + 启动时 pid,PID 复用误杀概率极低,且任务 id 本就是用户显式指定的目标。
121
+ if (owned === true || owned === null) {
119
122
  try {
120
123
  // worker 是 detached 进程(自成进程组):优先杀整组,避免工具子进程成孤儿
121
124
  process.kill(-t.pid, 'SIGTERM');
@@ -49,7 +49,6 @@ export async function runFetch(/** @type {any} */ args, /** @type {any} */ _ctx)
49
49
  let cur = u;
50
50
  let res = /** @type {any} */ (null);
51
51
  for (let hop = 0; hop <= 5; hop++) {
52
- if (hop > 5) return { ok: false, error: '重定向次数超过上限(5 跳)。' };
53
52
  const ch = String(cur.hostname || '').toLowerCase();
54
53
  let hopBlocked = isPrivateHost(ch);
55
54
  if (!hopBlocked && ch && ch !== 'localhost' && !/^\d{1,3}(\.\d{1,3}){3}$/.test(ch)) {
@@ -63,6 +62,8 @@ export async function runFetch(/** @type {any} */ args, /** @type {any} */ _ctx)
63
62
  if (hopBlocked) return { ok: false, error: `拒绝访问内网/本机地址(${ch})——SSRF 重定向防护。` };
64
63
  res = await fetch(cur, { signal: ac.signal, redirect: 'manual' });
65
64
  if (res.status >= 300 && res.status < 400) {
65
+ // P2-3(v0.4.5):原 `if(hop>5)` 死代码(hop 最大 5 永不触发)——超限应在 3xx 分支内判定
66
+ if (hop >= 5) return { ok: false, error: '重定向次数超过上限(5 跳)。' };
66
67
  const loc = res.headers.get('location');
67
68
  if (!loc) break; // 无 Location:按最终响应处理
68
69
  try {
package/src/tools/git.js CHANGED
@@ -3,6 +3,9 @@
3
3
  import { execFile } from 'node:child_process';
4
4
 
5
5
  const GIT_READONLY = new Set(['status', 'log', 'diff', 'show', 'blame', 'rev-parse', 'branch', 'tag', 'ls-files', 'shortlog']);
6
+ // P1-8(v0.4.5):参数级过滤——此前只校验子命令首词,`diff --no-index` 可越界读任意文件(git∈
7
+ // READONLY_TOOLS 免权限确认)、`branch -D`/`tag -f/-d` 破坏元数据、`--output` 写文件全部放行。
8
+ const BANNED_FLAGS = new Set(['--no-index', '--output', '-o', '--delete', '-D', '-d', '--force', '-f', '-m', '--move', '-M', '--rename']);
6
9
 
7
10
  export async function runGit(/** @type {any} */ args, /** @type {any} */ ctx) {
8
11
  const command = String(args.command ?? '').trim();
@@ -14,6 +17,11 @@ export async function runGit(/** @type {any} */ args, /** @type {any} */ ctx) {
14
17
  if (!GIT_READONLY.has(sub)) {
15
18
  return { ok: false, error: `git ${sub} 不是只读子命令(仅支持 ${[...GIT_READONLY].join(' / ')})。写操作请用 bash 并注意授权。` };
16
19
  }
20
+ // P1-8(v0.4.5):拒绝破坏性/越界 flag——`--no-index` 越界读、`--output` 写、`-D/-d/-f/-m` 破坏元数据
21
+ const banned = argv.find((/** @type {string} */ a) => BANNED_FLAGS.has(a) || a.startsWith('--output='));
22
+ if (banned) {
23
+ return { ok: false, error: `git ${sub} 含被禁止的参数 ${banned}——只读工具不允许写文件/越界读/破坏元数据(写操作请用 bash)。` };
24
+ }
17
25
  // 追加默认防超大输出:log/diff 限量(除非模型显式给了 -n/--max-count)——
18
26
  // v0.4.1 P2 修复:此前注释声明限量但无实现,git log -p 在大型仓库会撞 maxBuffer 4MB 报 ENOBUFS。
19
27
  let effectiveArgv = argv;
package/src/web/app.js CHANGED
@@ -399,10 +399,14 @@ async function send(){
399
399
  else if(ev.type==='done'){ console.log('[MingDao] done 事件:session=' + ev.session); onActivity(); if(ev.budget){ const b=ev.budget; if(hintTextEl) hintTextEl.textContent='预算 '+Math.round(b.used/1000)+'K/'+Math.round(b.total/1000)+'K('+Math.round(b.used/b.total*100)+'%)· 本轮完成 · 提示栏右侧为今日费用与命中率'; } refreshStatusBar(); if(ev.stats&&ev.stats.deliverables&&ev.stats.deliverables.length){ const card=document.createElement('div'); card.className='deliver'; card.innerHTML='<div class="t">📦 交付物('+ev.stats.deliverables.length+' 个文件)</div>'+ev.stats.deliverables.map(f=>'<div class="i">'+esc(f)+(f.toLowerCase().endsWith('.html')?' <span style="color:var(--accent2)">— 浏览器打开即可运行</span>':'')+'</div>').join(''); msg.appendChild(card); } if(ev.note){ const d=document.createElement('div'); d.className='errline'; d.style.color='var(--warn)'; d.textContent=ev.note; msg.appendChild(d); } currentSession=ev.session; update(); refreshSessions(); updateTasksPanel(); }
400
400
  });
401
401
  }catch(e){ onActivity(); // 诊断(v0.4.2 network error 排查):静默中断此前无任何日志,无法区分
402
- // 「手点停止 / 看门狗 / fetch 流静默断裂」。补记完整错误形态 + 回合状态,下次失败可定位。
403
- console.error('[MingDao] chat 流异常', { name: e && e.name, message: e && e.message, err: String(e), taskId, rawLen: raw.length, steps: stepsCount, elapsedS: Math.round((Date.now() - workT0) / 1000) });
402
+ // 「手点停止 / 看门狗 / fetch 流静默断裂」。v0.4.3 修:第二参改 JSON 字符串——Electron 日志
403
+ // 对对象只会落 [object Object],异常细节丢失。
404
+ const diag = { name: e && e.name, message: e && e.message, err: String(e), taskId, rawLen: raw.length, steps: stepsCount, elapsedS: Math.round((Date.now() - workT0) / 1000) };
405
+ console.error('[MingDao] chat 流异常 ' + JSON.stringify(diag));
404
406
  const d=document.createElement('div'); d.className='errline';
405
- d.textContent=(e&&e.name==='AbortError')?(killedByWatchdog?'响应超时已中断(120 秒无任何响应),请重试':'已中断'):((e&&e.message)||('连接中断,本轮未完成。已执行的工作已保存检查点——直接发送「继续」即可从断点续跑(' + Math.round((Date.now() - workT0) / 1000) + 's · ' + stepsCount + ' 步)。'));
407
+ // 统一 catch 文案(v0.4.3):非用户中断/看门狗时,无论有无 e.message 都优先给「可续跑」的友好提示,
408
+ // 具体错误形态已进日志(fetch failed 等不再绕开续跑提示)。
409
+ d.textContent=(e&&e.name==='AbortError')?(killedByWatchdog?'响应超时已中断(120 秒无任何响应),请重试':'已中断'):('连接中断,本轮未完成。已执行的工作已保存检查点——直接发送「继续」即可从断点续跑(' + Math.round((Date.now() - workT0) / 1000) + 's · ' + stepsCount + ' 步)。');
406
410
  msg.appendChild(d); scroll(); }
407
411
  finally{ clearInterval(hintTimer); disarm(); generating=false; setBtn(); curTaskId=null; curPhase='模型推理中'; console.log('[MingDao] 回合收尾:generating=false,按钮恢复发送'); hintEl.classList.remove('working'); if(hintTextEl) hintTextEl.textContent=defaultHint; pending=false; content.innerHTML=renderMarkdown(raw); attachTrajMeta(msg); activeAiMsg=null; curWorkT0=0; renderWorkStatus(); scroll(); updateTasksPanel(); }
408
412
  }
@@ -583,10 +587,17 @@ setInterval(updateTasksPanel, 2000);
583
587
  async function refreshCostBadge(){
584
588
  const r=await fetch('/api/cache-stats',{cache:'no-store'}).catch(()=>null); if(!r) return;
585
589
  const j=await r.json().catch(()=>null); if(!j) return;
586
- const bd=j.breakdown||{}; const gd=j.guard||null;
587
- let t='📊 今日 ≈¥'+(bd.today||0).toFixed(4);
588
- if(bd.rate!=null) t+=' · 命中 '+(bd.rate*100).toFixed(0)+'%';
589
- if(gd&&gd.limit>0&&gd.cost!=null) t+=' · 护栏 '+(gd.cost/gd.limit*100).toFixed(0)+'%';
590
+ const bd=j.breakdown||{}; const gd=j.guard||null; const sm=j.summary||{};
591
+ // v0.4.3:本地/自定义模型无定价时费用恒 0——此前显示「¥0.0000」被误读为「无统计」。
592
+ // 有用量但累计费用为 0 即判定为无计价模型,明确标注而非含糊 ¥0。
593
+ let t;
594
+ if ((sm.turns||0) > 0 && (bd.totalCost||0) === 0) {
595
+ t='📊 本地模型不计费 · 累计 ↑'+(sm.prompt||0)+' ↓'+(sm.completion||0)+' tokens';
596
+ } else {
597
+ t='📊 今日 ≈¥'+(bd.today||0).toFixed(4);
598
+ if(bd.rate!=null) t+=' · 命中 '+(bd.rate*100).toFixed(0)+'%';
599
+ if(gd&&gd.limit>0&&gd.cost!=null) t+=' · 护栏 '+(gd.cost/gd.limit*100).toFixed(0)+'%';
600
+ }
590
601
  $('#costBadge').textContent=t;
591
602
  if($('#dashPanel').style.display==='flex') renderDashboard(j);
592
603
  }
@@ -30,7 +30,8 @@ export function buildUserContent(/** @type {any} */ message, /** @type {any} */
30
30
  } else if (a.type === 'text') {
31
31
  const content = String(a.content ?? '');
32
32
  if (!content.trim()) continue;
33
- if (content.length > MAX_TEXT_BYTES) {
33
+ // 评估 6.7(v0.4.3):上限按字节数而非字符数(中文每字 3 字节,字符数口径会放大到声明值 3 倍)
34
+ if (Buffer.byteLength(content, 'utf8') > MAX_TEXT_BYTES) {
34
35
  return { error: `文本文件过大:${a.name || '未命名'}(≤200KB)` };
35
36
  }
36
37
  finalText += `${finalText ? '\n\n' : ''}[文件 ${a.name || '未命名'}]\n${content}`;
@@ -85,6 +85,9 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
85
85
  if (method === 'GET' && p === '/api/fs-browse') {
86
86
  let dir = String(url.searchParams.get('dir') || '').trim();
87
87
  if (!path.isAbsolute(dir)) return json(res, 400, { error: '需要绝对路径' });
88
+ // 评估 6.1(v0.4.3):先 path.resolve 消解 .. 段,再做前缀比较与 stat/readdir——此前字符串
89
+ // 前缀比较用未规范化的 dir,`/home/u/../../etc` 能通过 startsWith('/home/u/') 但 stat 解析到 /etc。
90
+ dir = path.resolve(dir);
88
91
  // 质检 A3:目录浏览限定基目录,拒绝越界。Windows(CodeArts 报告):家目录覆盖整个用户配置树
89
92
  // (AppData 等)——收紧为 桌面/文档/下载 三常用目录 + 启动目录 + 工作目录 + web.browseRoots 显式授权;
90
93
  // 路径比较在 win32 下大小写归一(D:\\ vs d:\\ 不再误拒)。