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/pricing.js CHANGED
@@ -7,6 +7,7 @@
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import { modelPreset } from './models.js';
10
+ import { atomicWriteFileSync } from './atomic-write.js';
10
11
  import { mingdaoHome } from './config.js';
11
12
 
12
13
  // 内置价格表的数据时点(定价可能调整,配置覆盖可随时更新)
@@ -63,7 +64,7 @@ export async function refreshPricingFromSource(cfg) {
63
64
  }
64
65
  const file = pricingFilePath();
65
66
  fs.mkdirSync(path.dirname(file), { recursive: true });
66
- fs.writeFileSync(file, JSON.stringify({ fetchedAt: Date.now(), source: src, models }, null, 2));
67
+ atomicWriteFileSync(file, JSON.stringify({ fetchedAt: Date.now(), source: src, models }, null, 2));
67
68
  extCache = { mtime: -1, ttlDays: 7, data: null, stale: false };
68
69
  const names = Object.keys(models).join('、');
69
70
  return { ok: true, lines: ['✓ 价格表已刷新(' + names + '),TTL 内费用估算/护栏/避峰自动跟随'] };
@@ -114,15 +115,37 @@ export function isPeakHour(date = new Date()) {
114
115
  }
115
116
  }
116
117
 
117
- // —— 北京时间墙钟工具(避峰调度/费用护栏按天统计用,零依赖 Intl) ——
118
- export function beijingParts(date = new Date()) {
119
- let tz = 'Asia/Shanghai';
118
+ // —— 计价时区墙钟工具(避峰调度/费用护栏按天统计用,零依赖 Intl) ——
119
+ // v0.4.6:抽出「生效时区」解析(坏配置回退 Asia/Shanghai,绝不让计费/护栏崩溃)。
120
+ function activeTimezone() {
120
121
  try {
121
- tz = peakCfg().timezone;
122
+ const tz = peakCfg().timezone;
122
123
  new Intl.DateTimeFormat('en-US', { timeZone: tz }).format(); // 非法时区在此抛错
124
+ return tz;
123
125
  } catch {
124
- tz = 'Asia/Shanghai'; // 审计 B1:坏时区配置回退北京时间,绝不让计费/护栏崩溃
126
+ return 'Asia/Shanghai';
125
127
  }
128
+ }
129
+
130
+ /** @param {Date} date @param {string} tz 该时刻在 tz 的墙钟与 UTC 的偏移(毫秒) */
131
+ function tzOffsetMs(date, tz) {
132
+ const parts = new Intl.DateTimeFormat('en-US', {
133
+ timeZone: tz,
134
+ year: 'numeric',
135
+ month: '2-digit',
136
+ day: '2-digit',
137
+ hour: '2-digit',
138
+ minute: '2-digit',
139
+ second: '2-digit',
140
+ hourCycle: 'h23',
141
+ }).formatToParts(date);
142
+ const g = (/** @type {any} */ t) => Number(parts.find((/** @type {any} */ p) => p.type === t)?.value);
143
+ const asUtc = Date.UTC(g('year'), g('month') - 1, g('day'), g('hour'), g('minute'), g('second'));
144
+ return asUtc - Math.floor(date.getTime() / 1000) * 1000;
145
+ }
146
+
147
+ export function beijingParts(date = new Date()) {
148
+ const tz = activeTimezone();
126
149
  const parts = new Intl.DateTimeFormat('en-US', {
127
150
  timeZone: tz,
128
151
  year: 'numeric',
@@ -137,12 +160,24 @@ export function beijingParts(date = new Date()) {
137
160
  return { year: g('year'), month: g('month'), day: g('day'), hour: g('hour'), minute: g('minute'), second: g('second') };
138
161
  }
139
162
 
140
- // 北京时间墙钟 → Date(UTC+8)
163
+ // 时区墙钟 → Date。
164
+ // v0.4.6 P3 修复:此前硬编码 `- 8h`(只管 Asia/Shanghai),而 beijingParts() 用的是可配置的
165
+ // `pricing.timezone`——用户按文档覆盖时区后,日界(费用护栏按自然日累计)与 --offpeak 顺延
166
+ // 会同时算错(美东实测错 12 小时:日界落在当地中午、避峰时刻整体偏移)。
167
+ // 现按目标时区真实偏移换算,用「两遍法」处理含夏令时的时区。
141
168
  /**
142
169
  * @param {any} parts
143
170
  */
144
171
  export function beijingToDate(parts) {
145
- return new Date(Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second) - 8 * 3600 * 1000);
172
+ const tz = activeTimezone();
173
+ const guessMs = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second);
174
+ let ts = guessMs;
175
+ for (let i = 0; i < 3; i++) {
176
+ const next = guessMs - tzOffsetMs(new Date(ts), tz);
177
+ if (next === ts) break;
178
+ ts = next;
179
+ }
180
+ return new Date(ts);
146
181
  }
147
182
 
148
183
  // 避峰顺延:当前处于高峰 → 顺延到该高峰段的结束整点(即最近一个闲时起点)
@@ -180,7 +215,10 @@ export const BATCH_DISCOUNT = 0.5;
180
215
  */
181
216
  export function estimateBatchCost(modelName, promptTokens, completionTokens) {
182
217
  const pricing = effectivePricing(modelName);
183
- if (!pricing) return 0;
218
+ // P0-4 同口径(v0.4.6):无价模型返 null 而非 0。Batch 路径此前恒返 0,导致
219
+ // ① `--max-cost` 预算拦截对「无内置/外部/覆盖价格」的模型静默失效(0 > maxCost 恒 false),
220
+ // ② /cost 把未知费用显示成 ≈¥0.00000 冒充免费。与 estimateCost 的语义保持一致。
221
+ if (!pricing) return null;
184
222
  return ((promptTokens * pricing.offpeak.input + completionTokens * pricing.offpeak.output) / 1e6) * BATCH_DISCOUNT;
185
223
  }
186
224
 
@@ -214,13 +252,32 @@ function pricingOverrides() {
214
252
  /**
215
253
  * @param {any} modelName
216
254
  */
255
+ // P0-4(v0.4.5):判断模型是否有价格数据(内置定价或外部定价或 overrides)——
256
+ // 无价模型 estimateCost 恒 0,费用护栏/仪表盘/分账静默失真,必须显式暴露给调用方告警。
257
+ export function hasPricing(/** @type {any} */ modelName) {
258
+ return Boolean(modelName && effectivePricing(modelName));
259
+ }
260
+
261
+ /** @param {any} modelName */
217
262
  function effectivePricing(modelName) {
218
263
  const preset = modelPreset(modelName);
219
264
  const ext = externalPricing().data?.models?.[modelName] || null;
220
- if (!preset?.pricing && !ext) return null;
221
- const base = ext || preset?.pricing || null;
222
- if (!base) return null;
223
265
  const over = pricingOverrides()[modelName] || {}; // 审计 Q3:mtime 缓存替代每轮读盘
266
+ const base = ext || preset?.pricing || null;
267
+ // P0 修复(v0.4.6):overrides 本身就是一条价格来源,必须与 ext/preset 等价对待。
268
+ // 此前「先判 base 是否存在」就 return null,导致用户按护栏提示给 gpt-5 / 本地模型配的
269
+ // config.pricing.overrides 完全不生效:hasPricing=false → estimateCost=null →
270
+ // cache-stats 记 cost=null(当 0 累计)→ todayCost 看不到这些消费 → 护栏永不拦截。
271
+ // 内置 12 个模型里只有 3 个 DeepSeek 带 pricing,即「每日上限防超支」对另外 9 个
272
+ // 以及全部动态发现/自定义模型形同虚设,而护栏给出的补救办法恰是这条不生效的路径。
273
+ const hasOver = Object.keys(over).length > 0;
274
+ if (!base && !hasOver) return null;
275
+ // 仅靠 overrides 供价时,要求 input 与 output 都是有限正数:半张价格表会把缺失的一侧
276
+ // 静默当成 0(输出免费 / 命中免费),比「未知(null)」更危险——宁可报无价并告警。
277
+ if (!base) {
278
+ const ok = (/** @type {any} */ v) => Number.isFinite(Number(v)) && Number(v) > 0;
279
+ if (!ok(over.input) || !ok(over.output)) return null;
280
+ }
224
281
  /** @type {(b: any, o?: any) => { input: number, output: number, cacheHit: number }} */
225
282
  const merge = (b, o = {}) => ({
226
283
  input: Number(o.input ?? b?.input ?? 0),
@@ -236,8 +293,8 @@ function effectivePricing(modelName) {
236
293
  cacheHit: over.peak?.cacheHit ?? over.cacheHit,
237
294
  };
238
295
  return {
239
- offpeak: merge(base.offpeak || base, over),
240
- peak: merge(base.peak || base.offpeak || base, peakOver),
296
+ offpeak: merge(base?.offpeak || base || {}, over),
297
+ peak: merge(base?.peak || base?.offpeak || base || {}, peakOver),
241
298
  };
242
299
  }
243
300
 
@@ -250,7 +307,9 @@ function effectivePricing(modelName) {
250
307
  */
251
308
  export function estimateCost(modelName, promptTokens, completionTokens, cache = null, date = new Date()) {
252
309
  const pricing = effectivePricing(modelName);
253
- if (!pricing) return 0;
310
+ // P0-4(v0.4.5):无价返 null 而非 0——0 与「未知」语义完全混淆(0 被当作「免费/没花钱」),
311
+ // 下游护栏/分账/仪表盘据此静默失真。返回 null 强制调用方显式区分「未知」与「免费(0)」(技术评估 P0-4)。
312
+ if (!pricing) return null;
254
313
  const price = isPeakHour(date) ? pricing.peak : pricing.offpeak;
255
314
  if (cache && Number.isFinite(cache.hit) && Number.isFinite(cache.miss)) {
256
315
  const hitPrice = price.cacheHit ?? 0;
@@ -268,10 +327,11 @@ export function estimateCost(modelName, promptTokens, completionTokens, cache =
268
327
  * @param {any} [usage]
269
328
  */
270
329
  export function estimateCostLabel(modelName, promptTokens, completionTokens, usage = null) {
271
- const pricing = effectivePricing(modelName);
272
- if (!pricing) return '';
273
330
  const cache = cacheSplit(usage);
274
- const yuan = estimateCost(modelName, promptTokens, completionTokens, cache).toFixed(5);
331
+ const c = estimateCost(modelName, promptTokens, completionTokens, cache);
332
+ // P0-4(v0.4.5):无价返 null → 标签置空(不显示「≈¥0.0000」冒充免费)
333
+ if (c == null) return '';
334
+ const yuan = c.toFixed(5);
275
335
  const hitPart =
276
336
  cache && cache.hit + cache.miss > 0 ? ` · 缓存命中 ${(cache.rate * 100).toFixed(0)}%` : ' · 未计缓存折扣';
277
337
  return ` ≈¥${yuan}(${isPeakHour() ? '高峰' : '闲时'}${hitPart})${pricingDataStale() ? ' · ⚠ 价格表过期,运行 mingdao update --pricing 刷新' : ''}`;
@@ -14,21 +14,42 @@ import { pathToFileURL } from 'node:url';
14
14
  import { chat as openaiChat } from './openai-compatible.js';
15
15
  import { modelPreset, providerPreset } from '../models.js';
16
16
  import { mingdaoHome } from '../config.js';
17
- import { resolveApiKey } from '../credentials.js';
17
+ import { resolveApiKey, getStoredKey } from '../credentials.js';
18
18
  import { isLocalBaseUrl } from '../model-caps.js';
19
19
 
20
20
  export function resolveProviderConfig(/** @type {any} */ cfg, /** @type {any} */ modelName) {
21
21
  // 自定义模型(config.customModels,WebUI 可增删改):优先于内置预设
22
22
  const cm = cfg?.customModels?.[modelName];
23
23
  if (cm) {
24
- const envKeys = [cm.envKey, 'MINGDAO_API_KEY'].filter(Boolean);
25
- const apiKey = resolveApiKey(cfg, `custom:${modelName}`, envKeys[0]) || resolveApiKey(cfg, 'custom', envKeys[0]);
24
+ // P1 修复(v0.4.6):自定义模型的密钥解析不得成为「任意环境变量外泄」通道。
25
+ // 此前 envKeys = [cm.envKey, 'MINGDAO_API_KEY'],而 envKey 与 baseUrl 都完全由调用方指定
26
+ // (WebUI 的 addCustom 接受任意输入)——于是任何能加一个自定义模型的人,都能把宿主环境里的
27
+ // 任意变量(AWS_SECRET_ACCESS_KEY / GITHUB_TOKEN / …)当作 Bearer 发到自己的端点;
28
+ // 即使不填 envKey 也会回落 MINGDAO_API_KEY,把用户的主密钥送出去。两条路一起封:
29
+ // ① envKey 必须是「像模型密钥」的名字(大写、以 _API_KEY/_KEY 结尾、不含 SECRET/TOKEN/…);
30
+ // ② 未声明(或被拒)时不读任何环境变量,只认凭证库——环境变量必须被显式点名才可用。
31
+ const rawEnvKey = String(cm.envKey || '').trim();
32
+ const safeEnvKey =
33
+ /^[A-Z][A-Z0-9_]{2,63}$/.test(rawEnvKey) &&
34
+ /(_API_KEY|_KEY)$/.test(rawEnvKey) &&
35
+ !/(SECRET|TOKEN|PASSWORD|CREDENTIAL|PRIVATE|PASSWD)/.test(rawEnvKey)
36
+ ? rawEnvKey
37
+ : '';
38
+ if (rawEnvKey && !safeEnvKey) {
39
+ console.warn(
40
+ `[MingDao] 自定义模型 ${modelName} 的 envKey「${rawEnvKey}」不是合法的模型密钥变量名,已忽略。` +
41
+ `如需用环境变量提供密钥,请用形如 MY_GATEWAY_API_KEY 的名字;否则请用 mingdao key set custom:${modelName} 存入凭证库。`
42
+ );
43
+ }
44
+ const envVal = safeEnvKey ? process.env[safeEnvKey] || '' : '';
45
+ const storedKey = getStoredKey(`custom:${modelName}`) || getStoredKey('custom') || '';
46
+ const apiKey = envVal || storedKey || '';
26
47
  return {
27
48
  name: `custom:${modelName}`,
28
49
  kind: 'openai-compatible',
29
50
  baseUrl: cm.baseUrl || cfg?.baseUrl || '',
30
51
  apiKey,
31
- envHint: envKeys[0] || 'MINGDAO_API_KEY',
52
+ envHint: safeEnvKey || `custom:${modelName}`,
32
53
  isCustom: true,
33
54
  modelName,
34
55
  };
@@ -92,14 +113,20 @@ export async function createProvider(/** @type {any} */ cfg, /** @type {any} */
92
113
  config: pc,
93
114
  async chat(/** @type {any} */ opts) {
94
115
  let attempt = 0;
116
+ // P1-7(v0.4.5):总量护栏提到重试循环外层——此前每 attempt 重建 totalTimer,重试序列最坏
117
+ // 放大 retries+1 倍(本地 3×30min=90min)。总时长覆盖整个重试序列,单次首 token/流式空闲仍按 attempt 计。
118
+ let totalExpired = false;
119
+ let currentAc = /** @type {AbortController | null} */ (null);
120
+ const totalTimer = setTimeout(() => {
121
+ totalExpired = true;
122
+ try {
123
+ currentAc?.abort(new Error(`请求总时长超限(${Math.round(totalMs / 1000)}s),已中断`));
124
+ } catch {}
125
+ }, totalMs);
95
126
  for (;;) {
96
127
  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);
128
+ currentAc = ac;
129
+ let timedOut = false; // 审计 P2-6:用标志而非 name/字符串匹配识别内部超时(首 token/流式空闲)
103
130
  // 首 token 等待:prefill 阶段无任何帧到达即断(覆盖长上下文慢 prefill)
104
131
  let firstTokenTimer = /** @type {ReturnType<typeof setTimeout> | null} */ (setTimeout(() => {
105
132
  timedOut = true;
@@ -125,6 +152,7 @@ export async function createProvider(/** @type {any} */ cfg, /** @type {any} */
125
152
  const onUserAbort = () => ac.abort(opts.signal?.reason);
126
153
  if (opts.signal?.aborted) onUserAbort();
127
154
  else opts.signal?.addEventListener('abort', onUserAbort, { once: true });
155
+ let leaving = true; // 本次 attempt 是否退出(成功/不再重试);将重试则置 false,总量计时器保留覆盖下一 attempt
128
156
  try {
129
157
  return await openaiChat({
130
158
  ...opts,
@@ -135,9 +163,11 @@ export async function createProvider(/** @type {any} */ cfg, /** @type {any} */
135
163
  onActivity,
136
164
  });
137
165
  } catch (err) {
138
- // 内部超时经 abort 抛出,用标志识别(审计 P2-6);用户 Ctrl+C 的中断不算超时、不重试
139
- const transient = (timedOut && !opts.signal?.aborted) || isTransient(err);
166
+ // 内部超时经 abort 抛出,用标志识别(审计 P2-6);用户 Ctrl+C 的中断不算超时、不重试。
167
+ // 总量超时(totalExpired)覆盖整个序列,超了直接抛不再重试。
168
+ const transient = !totalExpired && ((timedOut && !opts.signal?.aborted) || isTransient(err));
140
169
  if (!transient || attempt >= retries) throw err;
170
+ leaving = false; // 将重试:本次不退出,总量计时器继续覆盖下一 attempt
141
171
  attempt += 1;
142
172
  // 首 token 等待超时通常不是偶发网络抖动(是模型/上下文慢),重试价值低但保留一次机会;
143
173
  // 其余瞬态错误指数退避 + 尊重 Retry-After(评估 P3-1):基础 1s/2s,封顶 30s
@@ -147,7 +177,7 @@ export async function createProvider(/** @type {any} */ cfg, /** @type {any} */
147
177
  backoff = Math.min(backoff, 30000);
148
178
  await sleep(backoff);
149
179
  } finally {
150
- clearTimeout(totalTimer);
180
+ if (leaving) clearTimeout(totalTimer); // 成功/最终失败即清——避免悬挂 totalMs 计时器(每请求一个)
151
181
  if (firstTokenTimer) clearTimeout(firstTokenTimer);
152
182
  if (idleTimer) clearTimeout(idleTimer);
153
183
  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/redact.js CHANGED
@@ -13,6 +13,11 @@ export function redactSecrets(/** @type {any} */ text) {
13
13
  s = s.replace(/(Authorization\s*:\s*Bearer\s+)[^\s"',}]+/gi, '$1***');
14
14
  s = s.replace(/((?:api[_-]?key|token|secret|password|passwd|access_token)\s*[=:]\s*["']?)[^\s"',}]+/gi, '$1***');
15
15
  s = s.replace(/([?&](?:key|token|secret|api_key|access_token)=)[^&\s"']+/gi, '$1***');
16
+ // P2 修复(v0.4.6):URL 内嵌凭据(scheme://user:pass@host)——本文件头注释一直声称覆盖,
17
+ // 但规则里从来没有这一条:`git clone https://oauth2:glpat-xxx@host/repo`、
18
+ // `postgres://user:pw@host/db` 这类命令行会**原样**落入 ~/.mingdao/audit.jsonl
19
+ // (bash/fetch 的完整参数写审计)与 diagnose 诊断包。只掩码密码段,保留用户名与主机便于排查。
20
+ s = s.replace(/(:\/\/[^/\s:@]{1,64}:)[^/\s@]{1,256}@/g, '$1***@');
16
21
  return s;
17
22
  }
18
23
 
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,7 +195,10 @@ 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
  });
@@ -354,6 +360,15 @@ export async function runSleeper(/** @type {any} */ home, /** @type {any} */ id)
354
360
 
355
361
  const wait = (/** @type {any} */ ms) => new Promise((r) => setTimeout(r, ms));
356
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
+
357
372
  // 依赖检查:支持两种依赖——调度任务 id(chain 编排)或后台任务 id(mingdao run 输出)
358
373
  async function depsSatisfied(/** @type {any} */ deps) {
359
374
  for (const dep of deps) {
@@ -376,8 +391,11 @@ export async function runSleeper(/** @type {any} */ home, /** @type {any} */ id)
376
391
  // 顺延到最近闲时起点(12:00 / 18:00)执行,输入价省 50%
377
392
  if (job.offpeak && isPeakHour(new Date())) {
378
393
  const defer = deferToOffpeak(new Date());
379
- const curN = readSchedule(home, id);
380
- 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
+ });
381
399
  await wait(defer.getTime() - Date.now() + 2000);
382
400
  }
383
401
  if (job.after?.length) {
@@ -406,26 +424,32 @@ export async function runSleeper(/** @type {any} */ home, /** @type {any} */ id)
406
424
  killTask(home, task.id);
407
425
  t = { ...t, status: 'timedout' };
408
426
  }
409
- const cur0 = readSchedule(home, id);
410
- if (!cur0) return 'failed'; // 任务已被删除:停止后续写入
411
- const history = [...(cur0?.history || [])];
412
- history.push({
413
- taskId: task.id,
414
- status: t?.status || 'unknown',
415
- at: Date.now(),
416
- durationMs: t?.durationMs ?? null,
417
- text: (t?.text || t?.error || '').slice(0, 200),
418
- });
419
- if (history.length > 50) history.shift();
420
427
  const result = t?.status === 'done' ? 'done' : t?.status === 'timedout' ? 'timedout' : 'failed';
421
- writeSchedule(home, {
422
- ...cur0,
423
- lastRunAt: Date.now(),
424
- lastTaskId: task.id,
425
- runs: (cur0?.runs || 0) + 1,
426
- history,
427
- 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;
428
451
  });
452
+ if (!wrote) return 'failed'; // 任务已被删除
429
453
  return result;
430
454
  };
431
455
 
@@ -438,7 +462,7 @@ export async function runSleeper(/** @type {any} */ home, /** @type {any} */ id)
438
462
  await wait(Math.min(Math.max((cur.nextRunAt || now) - now, 1000), 60000));
439
463
  continue;
440
464
  }
441
- writeSchedule(home, { ...cur, status: 'running' });
465
+ if (!markRunning()) return; // P1-15:标记 running 前复查 paused(被暂停则直接退出)
442
466
  const result = await runOnce();
443
467
  // 质检 H3:状态读-改-写加锁(与 pause/remove 互斥,防丢更新)
444
468
  const nextState = withFileLockSync(path.join(scheduleDir(home), '.lock'), () => {
@@ -456,12 +480,14 @@ export async function runSleeper(/** @type {any} */ home, /** @type {any} */ id)
456
480
  await wait(Math.min(cur.nextRunAt - now, 60000));
457
481
  continue;
458
482
  }
459
- writeSchedule(home, { ...cur, status: 'running' });
483
+ if (!markRunning()) return; // P1-15:标记 running 前复查 paused
460
484
  const result = await runOnce();
461
- // 评估 6.4(v0.4.3):once 最终状态读-改-写加锁(与 pause/remove 互斥,防 pause 被 done 覆盖)
485
+ // 评估 6.4(v0.4.3)+ P2-4(v0.4.5):once 最终状态读-改-写加锁,且 paused 不覆盖——
486
+ // 用户执行期间 pause(cur2.status==='paused')绝不被 done/failed 覆盖(every 经 postRunStatus 有防护,此处补齐)
462
487
  withFileLockSync(path.join(scheduleDir(home), '.lock'), () => {
463
488
  const cur2 = readSchedule(home, id);
464
- if (cur2) writeSchedule(home, { ...cur2, status: result });
489
+ if (!cur2 || cur2.status === 'paused') return;
490
+ writeSchedule(home, { ...cur2, status: result });
465
491
  });
466
492
  return;
467
493
  } else {
@@ -472,15 +498,21 @@ export async function runSleeper(/** @type {any} */ home, /** @type {any} */ id)
472
498
  continue;
473
499
  }
474
500
  if (st === 'failed') {
475
- 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
+ });
476
507
  return;
477
508
  }
478
- writeSchedule(home, { ...cur, status: 'running' });
509
+ if (!markRunning()) return; // P1-15:标记 running 前复查 paused
479
510
  const result = await runOnce();
480
- // 评估 6.4(v0.4.3):after 最终状态读-改-写加锁(同上)
511
+ // 评估 6.4(v0.4.3)+ P2-4(v0.4.5):after 最终状态读-改-写加锁,且 paused 不覆盖
481
512
  withFileLockSync(path.join(scheduleDir(home), '.lock'), () => {
482
513
  const cur2 = readSchedule(home, id);
483
- if (cur2) writeSchedule(home, { ...cur2, status: result });
514
+ if (!cur2 || cur2.status === 'paused') return;
515
+ writeSchedule(home, { ...cur2, status: result });
484
516
  });
485
517
  return;
486
518
  }
package/src/session.js CHANGED
@@ -4,13 +4,26 @@
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
 
7
+ // —— 冲突备份名(单一事实来源,v0.4.6)——
8
+ // 同步冲突时写入的备份:<会话名>.server-<时间戳>[-<随机后缀>].jsonl(远端版本)与 .remote-(远端拉取版本)。
9
+ // 教训:producer(sync.js conflictCopyName)带随机后缀,而 consumer 的正则只认 <数字>.jsonl,
10
+ // 导致 listSyncConflicts/resolveSyncConflict 永远匹配不到自己的备份——「冲突三选一」100% 失效;
11
+ // 且这些备份被 listSessions 当成普通会话推出/拉取,在其他设备上变成无法解析的幽灵会话。
12
+ // 现由本模块统一给出「识别 + 排除」,写入 / 冲突面板 / 会话列表与同步三处共用同一判定。
13
+ export const CONFLICT_BACKUP_RE = /^(.+)\.(server|remote)-(\d+)(?:-[a-z0-9]+)?\.jsonl$/;
14
+ /** @param {any} n */
15
+ export function isConflictBackupName(n) {
16
+ return typeof n === 'string' && CONFLICT_BACKUP_RE.test(n);
17
+ }
18
+
7
19
  /** @param {any} home */
8
20
  export function listSessions(home) {
9
21
  const dir = path.join(home, 'sessions');
10
22
  try {
11
23
  return fs
12
24
  .readdirSync(dir)
13
- .filter((f) => f.endsWith('.jsonl'))
25
+ // 冲突备份不是会话:排除后不再出现在会话列表/最近会话/搜索/云同步推送集合中
26
+ .filter((f) => f.endsWith('.jsonl') && !isConflictBackupName(f))
14
27
  .map((f) => {
15
28
  const file = path.join(dir, f);
16
29
  return { file, name: f, mtime: fs.statSync(file).mtimeMs };
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
  }