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.
- package/package.json +1 -1
- package/presets/local-audit.json +2 -2
- package/src/agent.js +49 -7
- package/src/atomic-write.js +9 -0
- package/src/cachestats.js +5 -4
- package/src/cli.js +30 -15
- package/src/commands/repl.js +5 -3
- package/src/cost-guard.js +26 -6
- package/src/hooks.js +1 -0
- package/src/mcp.js +19 -4
- package/src/memory.js +6 -5
- package/src/model-caps.js +41 -2
- package/src/pricing.js +16 -5
- package/src/providers/index.js +18 -9
- package/src/providers/openai-compatible.js +42 -1
- package/src/schedule.js +84 -43
- package/src/skill-lib.js +23 -4
- package/src/skills.js +2 -2
- package/src/tasks/worker.js +5 -2
- package/src/tasks.js +4 -1
- package/src/tools/fetch.js +2 -1
- package/src/tools/git.js +8 -0
- package/src/web/app.js +18 -7
- package/src/web/attachments.js +2 -1
- package/src/web/routes/domains/workspace.js +3 -0
- package/src/web/server.js +65 -11
- package/src/workspace.js +5 -9
package/src/providers/index.js
CHANGED
|
@@ -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
|
-
|
|
98
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
next = job.
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
377
|
-
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
history
|
|
424
|
-
|
|
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
|
-
|
|
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
|
-
|
|
483
|
+
if (!markRunning()) return; // P1-15:标记 running 前复查 paused
|
|
457
484
|
const result = await runOnce();
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
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
|
-
|
|
509
|
+
if (!markRunning()) return; // P1-15:标记 running 前复查 paused
|
|
473
510
|
const result = await runOnce();
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
|
|
193
|
-
|
|
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.
|
|
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.
|
|
92
|
+
if (!fs.lstatSync(skillMd).isFile()) continue;
|
|
93
93
|
} catch {
|
|
94
94
|
continue;
|
|
95
95
|
}
|
package/src/tasks/worker.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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');
|
package/src/tools/fetch.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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
|
}
|
package/src/web/attachments.js
CHANGED
|
@@ -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
|
-
|
|
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:\\ 不再误拒)。
|