mingdao-harness 0.2.5 → 0.2.7
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/docs/EVALUATION-ROUND2.md +58 -0
- package/package.json +2 -2
- package/skills/release-checklist/SKILL.md +5 -4
- package/src/agent.js +55 -17
- package/src/atomic-write.js +8 -2
- package/src/cli.js +29 -10
- package/src/commands/repl.js +2 -2
- package/src/context.js +26 -10
- package/src/cost-guard.js +14 -6
- package/src/log-writer.js +5 -2
- package/src/pricing.js +10 -1
- package/src/prompts.js +1 -1
- package/src/providers/openai-compatible.js +5 -3
- package/src/routing.js +3 -3
- package/src/schedule.js +5 -3
- package/src/skills.js +4 -2
- package/src/tasks.js +1 -1
- package/src/tokenizer.js +1 -1
- package/src/tools/index.js +4 -4
- package/src/web/app.js +25 -8
- package/src/web/index.html +23 -2
- package/src/web/routes/domains/config.js +2 -2
- package/src/web/routes/domains/misc.js +2 -1
- package/src/web/routes/domains/sync.js +1 -1
- package/src/web/routes/domains/workspace.js +12 -6
- package/src/web/server.js +50 -18
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# 六方技术评估 · 第二轮甄别与迭代方案(v0.2.5 → v0.2.6)
|
|
2
|
+
|
|
3
|
+
> 2026-09-02。对 CodeBuddy / MiniMax / Kimi / OfficeACE / workbuddy / CodeArts Agent 六家对
|
|
4
|
+
> MingDao Harness v0.2.5 的评估报告逐条对照真实代码甄别(每份报告均由独立子代理核实:
|
|
5
|
+
> 文件+行号证据、区分「真实缺陷」与「过时/误判/空泛套话」)。已修复项随本轮提交 `20693ee`。
|
|
6
|
+
|
|
7
|
+
## 一、本轮已修复(按严重度)
|
|
8
|
+
|
|
9
|
+
### 崩溃类(0.2.2 教训:打包期无声、运行期爆炸)
|
|
10
|
+
1. **域路由 400/403 双写崩溃【最高】**:C1 拆分后 `json()` 无返回值,`return json(...)` 被编排器视为「未处理」→ 兜底 404 对已结束响应二次写入 → Node 18 未捕获 write-after-end 整服务崩溃(CI ubuntu-18 腿连续红灯的真因,报告们误归因于 Windows)。修复:`json()` 返回 truthy。
|
|
11
|
+
2. **reasoning_content 工具轮未完整回传**:DeepSeek thinking_mode 官方要求带 tool_calls 的 assistant 消息在后续请求中原样回传 reasoning_content,否则 400——多轮工具会话真实 API 必崩(mock 测试看不见)。修复:回填完整 reasoning;裁剪逻辑只对纯文本回复生效。
|
|
12
|
+
3. **降级模型零校验**:`downgradeModel` 填错/跨服务商 → provider.chat 必然 400 终止回合。修复:校验同服务商,失败按 block 处理并给修复指引。
|
|
13
|
+
4. **reasoning_effort 无门控**:全局配置发给不支持 reasoning 的模型 → 400。修复:仅 `supportsReasoning` 模型发送;`/think off` 改为显式 `thinking:{type:'disabled'}`(此前 off 无效,pro 默认思考开启)。
|
|
14
|
+
|
|
15
|
+
### 安全类
|
|
16
|
+
5. **SSRF 域名 DNS 重绑定绕过**:校验不解析域名。修复:`validateRemoteUrl` 对域名做 `dns.lookup` 全量 A/AAAA 复检;IPv6 字面量去括号归一(`[::1]`/`[::ffff:127.0.0.1]` 此前直接放行)。
|
|
17
|
+
6. **readBody limit 形同虚设**:1MB 注释 vs 实际 40MB 全接口。修复:limit 参数生效 + 超限返回真 413(排水保留连接)+ 契约断言;chat 保留 40MB(附件 base64)。
|
|
18
|
+
7. **project 级技能无指纹校验**:仓库投毒可直通提示词注入。修复:project 级同样校验 `.source.json` 指纹(无指纹的旧仓库不受影响)。
|
|
19
|
+
8. **Windows fs-browse 白名单过宽**:家目录覆盖整个用户配置树(AppData)。修复:win32 收紧为 桌面/文档/下载 + 启动/工作目录 + 显式授权;路径比较大小写归一。
|
|
20
|
+
|
|
21
|
+
### 省钱与正确性类
|
|
22
|
+
9. **护栏在途费用失明**:长回合内统计文件不变,可烧穿日限。修复:回合内累计 usage 保守估算并入检查/前置拦截。
|
|
23
|
+
10. **语义回收缺陷**:>80% 即回收(能塞下也截断丢内容);回收窗口与被丢弃前缀不相交(白做)。修复:仅真超预算触发 + 按丢弃边界回收 + 只接受变短替换。
|
|
24
|
+
11. **工具 Schema 会话内多次变化破坏前缀缓存**(三家共识):每轮剥描述 + 只读阶段翻转 + MCP 晚到 → 前缀反复失配。**列入 v0.2.6 计划**(需要在线基准裁决收益,见下)。
|
|
25
|
+
12. **英文任务只读死锁/误路由**:写意图与生成意图正则纯中文,英文会话整回合只读、生成任务落 8192 输出。修复:双语正则 + 路由标注集英文子集(113 条)。
|
|
26
|
+
13. 杂项:PID 归属 `null`(无法校验)不再盲杀;SSE 尾字节并入 buf 统一行解析;原子写陈旧锁回收 TOCTOU 比对;tokenCache 整体清空改 LRU;`todayCost` 读失败返回 null + 一次性告警(不再静默归零)。
|
|
27
|
+
|
|
28
|
+
### 质量基础设施
|
|
29
|
+
14. CI 补跑 `api-contracts`(此前漏掉);`run-all` spawn 加 error 监听 + Windows `npm.cmd`;e2e-web 越界用例改用系统目录(Windows %TEMP% 在家目录内的假阴性)。
|
|
30
|
+
|
|
31
|
+
## 二、下一迭代(v0.2.6)方案
|
|
32
|
+
|
|
33
|
+
**A. 前缀缓存稳定性(省钱核心,唯一需要「先测量再动手」的项)**
|
|
34
|
+
- A1:会话内工具 Schema 两态冻结——回合首轮定档后恒定(不再逐轮剥描述/MCP 晚到突变),回合间才更新;
|
|
35
|
+
- A2:MCP 预热:启动时 await 连接(带超时,超时本会话冻结工具集);
|
|
36
|
+
- A3:新建 `test/bench/bench-cost.mjs`——mock 模型费用对照(schema 收益、前缀命中、护栏拦截),把「48%/1/30/半价」营销数字变成可回归断言;
|
|
37
|
+
- A4:`trimMessages` 前沿对齐(P1-1:轮重写头部消息导致前缀整体失配)——在 A3 基准下裁决是否采纳。
|
|
38
|
+
|
|
39
|
+
**B. 会话与索引扩展**
|
|
40
|
+
- B1:session-index 按会话名分片(>1000 会话时避免全量 JSON.parse);
|
|
41
|
+
- B2:常量单源化(MAX_CONCURRENT=8 实测值、200KB/5MB 附件上限去重,防 app.js/attachments.js/fs-tools.js 漂移)。
|
|
42
|
+
|
|
43
|
+
**C. 桌面与 WebUI**
|
|
44
|
+
- C1:面板推开布局(已随本次提交:轨迹/子代理/任务面板以 body class 推开内容区,≤560px 退化为全宽面板;附件行不再被遮挡);
|
|
45
|
+
- C2:app.js(88KB)原生 ES Modules 零构建拆分(可选,收益=可维护性)。
|
|
46
|
+
|
|
47
|
+
**D. 文档与门禁**
|
|
48
|
+
- D1:docs/QA-REPORT.md 断言数(14→60+)、CONFIG.md 补 `costGuard.action:'downgrade'`;
|
|
49
|
+
- D2:CI 类型门禁统一为 tsconfig.full.json(现仍跑弱版 tsconfig.json 双轨)。
|
|
50
|
+
|
|
51
|
+
**E. 缓做/不采纳(已甄别)**
|
|
52
|
+
- sync-server 非 loopback 无 TLS 拒启(需 `--insecure` 逃生门,改行为需用户确认);87 处空 catch 加 debug 开关(收益低,优先关键路径);WebUI SPA 拆分;i18n;SQLite(需 Node≥22.5,破坏 18/20 兼容矩阵);报告中的 roadmap 型空泛项(收益数字无测量依据)。
|
|
53
|
+
|
|
54
|
+
## 三、验收门槛(防 0.2.2 式回归)
|
|
55
|
+
1. 6 套测试全绿 + api-contracts 每域契约 + 新增 bench-cost 断言;
|
|
56
|
+
2. strict 棘轮 0/0 不回升;覆盖率 ≥60% 门禁;
|
|
57
|
+
3. **每次发版前 CI 打包冒烟(xvfb 真实运行打包产物主进程)**——已固化,不可省略;
|
|
58
|
+
4. 新增「Node 18 兼容」意识:CI 已有 ubuntu-18 腿(本次双写崩溃即由它抓住),任何路由改动必须过该腿。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mingdao-harness",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
4
4
|
"description": "MingDao Harness —— 开源智能体框架(Agent Harness)。零依赖、开箱即用,针对 DeepSeek-V4 系列优化,开放主流模型接入。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"desktop": "npm --prefix desktop start",
|
|
55
55
|
"desktop:dist": "node scripts/sync-versions.mjs && npm --prefix desktop run dist:dir",
|
|
56
56
|
"desktop:sync": "node scripts/sync-versions.mjs",
|
|
57
|
-
"bench": "node test/bench/bench-tokenizer.mjs && node test/bench/bench-routing.mjs && node test/bench/bench-compaction.mjs",
|
|
57
|
+
"bench": "node test/bench/bench-tokenizer.mjs && node test/bench/bench-routing.mjs && node test/bench/bench-compaction.mjs && node test/bench/bench-cost.mjs",
|
|
58
58
|
"coverage": "node test/run-all.mjs --coverage && node scripts/coverage-report.mjs",
|
|
59
59
|
"typecheck:strict": "node scripts/strict-ratchet.mjs",
|
|
60
60
|
"preversion": "node test/smoke.js && node test/e2e-local.js && npm run bench",
|
|
@@ -11,13 +11,14 @@ description: 准备发布新版本、打包交付前使用
|
|
|
11
11
|
2. **版本**:版本号统一(package.json / 配置 / 文档互相一致),遵循语义化版本。
|
|
12
12
|
3. **变更说明**:changelog 用用户语言描述新增/修复/破坏性变更。
|
|
13
13
|
4. **构建验证**:实际构建/打包一次,并在干净环境验证产物可安装可运行。
|
|
14
|
-
5.
|
|
14
|
+
5. **发布前自检(常设步骤,2026-09-02 起)**:不依赖外部评估报告,纯代码审查主动找 bug——重点覆盖本次改动的调用链、锁/并发、权限/安全、跨平台与「打包期无声、运行期爆炸」类边界;每一条发现必须给出**文件+行号证据并按严重度排序**(高危优先修,不可带着已知高危发布)。历史战例:v0.2.6 自检一次捞出 6 条(含外部六份评估全漏的 `schedule stop` 双守护进程 bug)与 2 条自改回归。
|
|
15
|
+
6. **桌面版打包冒烟(0.2.2 事故护栏,发版必经)**:主进程模块求值必须真实跑通打包产物——
|
|
15
16
|
- CI 已内置:desktop.yml 在 Linux 腿用 xvfb 运行 `dist/linux-unpacked/mingdao --no-sandbox`(`MINGDAO_DESKTOP_SMOKE=1`),模块求值通过输出 `MINGDAO_DESKTOP_SMOKE_OK` 并退出 0,否则 workflow 失败;
|
|
16
17
|
- 本地有显示器时可加验:`cd desktop && MINGDAO_DESKTOP_SMOKE=1 dist/linux-unpacked/mingdao --no-sandbox`(AppImage 加 `--appimage-extract-and-run`);
|
|
17
18
|
- 教训:0.2.2 三平台启动即崩(main.js 顶层调用 createLogWriter 漏导入),打包期不报错、运行期才炸——静态护栏(test/smoke.js「桌面主进程静态护栏」组)与打包冒烟双保险。
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
7. **敏感信息**:密钥、token、内网地址、个人信息不得进入仓库与产物(`grep -rE 'sk-[A-Za-z0-9]{20,}' .`)。
|
|
20
|
+
8. **回滚方案**:明确如何回退到上一版本(旧包/旧 tag 可恢复)。
|
|
21
|
+
9. **发布后冒烟**:发布完成后立即做一次端到端验证。
|
|
21
22
|
|
|
22
23
|
## 桌面版发布流水线(官网分发 + 镜像 + 转运附件回收)
|
|
23
24
|
|
package/src/agent.js
CHANGED
|
@@ -13,6 +13,7 @@ import { subagentModel } from './routing.js';
|
|
|
13
13
|
import { writeAudit, redactSecrets } from './audit.js';
|
|
14
14
|
import { checkCostGuard, costGuardConfig, todayCost } from './cost-guard.js';
|
|
15
15
|
import { estimateCost } from './pricing.js';
|
|
16
|
+
import { resolveProviderConfig } from './providers/index.js';
|
|
16
17
|
|
|
17
18
|
const MAX_STEPS = 24;
|
|
18
19
|
const SUBAGENT_MAX_STEPS = 12;
|
|
@@ -45,15 +46,18 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
45
46
|
// 省钱 B1(按需挂载):回合起始为「只读阶段」时只发只读工具(read/ls/glob/grep/skill/todo)
|
|
46
47
|
// + 已用过的工具;检测到写意图(用户消息或模型明说需要写/改/建)后注入全量工具。
|
|
47
48
|
const READONLY_TIER_SET = new Set(['read', 'ls', 'glob', 'grep', 'skill', 'todo']);
|
|
48
|
-
|
|
49
|
+
// 中英双语写意图(CodeArts 报告:纯中文正则让英文会话整回合只读死锁)
|
|
50
|
+
const WRITE_INTENT_RE = /写|建|创|改|修|删|装|加|添|增|补|换|移|部署|执行|运行|实现|重构|生成|迁移|安装|更新|升级|发布|调整|优化|修复|提交|推送|打包|编译|测试|implement|fix|create|modify|update|delete|deploy|build|make|generate|install|write|refactor|migrate|test|run|commit|push|remove|add|change|patch/i;
|
|
49
51
|
const hasWriteIntent = (/** @type {any} */ text) => WRITE_INTENT_RE.test(String(text || ''));
|
|
50
|
-
|
|
51
|
-
|
|
52
|
+
// A1(前缀稳定):剥描述集合按「回合冻结快照」——回合内恒定(至多两态:只读档/全量档),
|
|
53
|
+
// 新使用的工具只在下一回合才进入剥描述集合;回合边界本身就有新 user 消息,schema 变化免费。
|
|
54
|
+
const toolsFor = (/** @type {boolean} */ readOnlyPhase, /** @type {Set<string>} */ strippedSet) => {
|
|
55
|
+
const schemas = buildToolSchemas(strippedSet, mcpSchemas());
|
|
52
56
|
if (!readOnlyPhase) return schemas;
|
|
53
57
|
return schemas.filter((/** @type {any} */ t) => {
|
|
54
58
|
const n = t?.function?.name;
|
|
55
59
|
if (!n) return true;
|
|
56
|
-
if (READONLY_TIER_SET.has(n) ||
|
|
60
|
+
if (READONLY_TIER_SET.has(n) || strippedSet.has(n)) return true;
|
|
57
61
|
if (n.startsWith('mcp__')) return mcp ? mcp.isReadonly(n) : false;
|
|
58
62
|
return false;
|
|
59
63
|
});
|
|
@@ -142,6 +146,13 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
142
146
|
// activeModel 是本回合实际使用的模型(分账/记录归属它),downgraded 保证只提示一次。
|
|
143
147
|
let activeModel = modelName;
|
|
144
148
|
let downgraded = false;
|
|
149
|
+
// 护栏在途费用(MiniMax P0):本回合已累计 usage 的保守估算(无缓存折扣),
|
|
150
|
+
// 护栏检查/前置拦截时并入今日已用——否则长回合内统计文件不变,可烧穿日限。
|
|
151
|
+
const inFlightCost = () => estimateCost(activeModel, usage.prompt_tokens, usage.completion_tokens, null, new Date());
|
|
152
|
+
const usedTodayWithInflight = () => {
|
|
153
|
+
const today = todayCost();
|
|
154
|
+
return today == null ? null : today + inFlightCost();
|
|
155
|
+
};
|
|
145
156
|
// 省钱 B1:本回合只读阶段判定——最新用户消息无写意图则先只发只读工具,
|
|
146
157
|
// 模型明确表达写意图后(下一轮)注入全量。cfg.schemaTier=false 可关。
|
|
147
158
|
let readOnlyPhase = true;
|
|
@@ -151,6 +162,8 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
151
162
|
} else {
|
|
152
163
|
readOnlyPhase = false;
|
|
153
164
|
}
|
|
165
|
+
// A1:本回合的剥描述冻结快照(会话级 usedToolNames 的副本)——回合内 schema 字节不变
|
|
166
|
+
const turnStrippedSet = new Set(usedToolNames);
|
|
154
167
|
// 整个回合注册一次 SIGINT:思考、工具执行、权限询问期间都能中断
|
|
155
168
|
const offSigint = io.onSigint ? io.onSigint(() => { aborted = true; currentAc?.abort(); }) : () => {};
|
|
156
169
|
const ctx = makeCtx();
|
|
@@ -197,14 +210,16 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
197
210
|
}
|
|
198
211
|
const trimmed = trimMessages(messages, budget, count);
|
|
199
212
|
|
|
200
|
-
// reasoning 回填防护(
|
|
201
|
-
//
|
|
213
|
+
// reasoning 回填防护(Kimi P0 修正):带 tool_calls 的 assistant 消息必须「完整」回传
|
|
214
|
+
// reasoning_content(DeepSeek thinking_mode 官方要求:后续请求含 tools 时缺/截断即 400,
|
|
215
|
+
// 多轮工具会话必崩);仅纯文本回复的 reasoning 可裁剪(不回传也不影响)。
|
|
202
216
|
let sanitized = trimmed;
|
|
203
217
|
for (const m of sanitized) {
|
|
204
218
|
const rc = m.reasoning_content;
|
|
205
|
-
if (typeof rc
|
|
219
|
+
if (typeof rc !== 'string' || (Array.isArray(m.tool_calls) && m.tool_calls.length)) continue;
|
|
220
|
+
if (rc.length > 4000) {
|
|
206
221
|
sanitized = sanitized.map((/** @type {any} */ x) => (x === m ? { ...x, reasoning_content: `[思考过程已省略(原 ${rc.length} 字)]` } : x));
|
|
207
|
-
} else if (
|
|
222
|
+
} else if (rc.length > 1000) {
|
|
208
223
|
sanitized = sanitized.map((/** @type {any} */ x) => (x === m ? { ...x, reasoning_content: rc.slice(-500) + ' …[思考过程已截断]' } : x));
|
|
209
224
|
}
|
|
210
225
|
}
|
|
@@ -219,8 +234,10 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
219
234
|
let promptTokens = 0;
|
|
220
235
|
for (const m of sanitized) promptTokens += messageTokens(m, count);
|
|
221
236
|
const worst = estimateCost(activeModel, promptTokens, maxOutput, null, new Date());
|
|
222
|
-
const used =
|
|
223
|
-
if (used
|
|
237
|
+
const used = usedTodayWithInflight(); // 含本回合在途费用(防长回合烧穿)
|
|
238
|
+
if (used == null) {
|
|
239
|
+
// todayCost 读取失败:无法判断,跳过前置拦截(护栏主检查同样跳过并告警)
|
|
240
|
+
} else if (used + worst >= Number(g.dailyLimitYuan)) {
|
|
224
241
|
stripOrphanCalls();
|
|
225
242
|
return {
|
|
226
243
|
text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: false,
|
|
@@ -253,15 +270,29 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
253
270
|
}
|
|
254
271
|
if (guard.downgrade && !downgraded) {
|
|
255
272
|
if (guard.downgradeModel !== activeModel) {
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
273
|
+
// MiniMax P0:降级目标零校验会崩溃——必须与当前模型同服务商且已有 Key,
|
|
274
|
+
// 否则 provider.chat 必然 400;校验失败按 block 处理并给修复指引。
|
|
275
|
+
const curPc = resolveProviderConfig(cfg, activeModel);
|
|
276
|
+
const dgPc = resolveProviderConfig(cfg, guard.downgradeModel);
|
|
277
|
+
// Key 归属服务商(provider 级),同服务商即天然共享同一 Key,无需再查 apiKey
|
|
278
|
+
if (dgPc && dgPc.name === curPc.name) {
|
|
279
|
+
activeModel = guard.downgradeModel;
|
|
280
|
+
downgraded = true;
|
|
281
|
+
io.print(style(guard.message, C.yellow));
|
|
282
|
+
} else {
|
|
283
|
+
stripOrphanCalls();
|
|
284
|
+
return {
|
|
285
|
+
text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: false,
|
|
286
|
+
note: `费用护栏想降级到 ${guard.downgradeModel},但它与当前服务商不一致或缺少 API Key——已暂停执行。请把 config.costGuard.downgradeModel 改为与当前模型同服务商(当前:${curPc.name})的模型名,或调高 dailyLimitYuan。`,
|
|
287
|
+
durationMs: Date.now() - startedAt, perf: perf(),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
259
290
|
} else {
|
|
260
291
|
// 已经是降级目标模型:无法再降,按 block 处理
|
|
261
292
|
stripOrphanCalls();
|
|
262
293
|
return {
|
|
263
294
|
text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: false,
|
|
264
|
-
note: `今日费用已达上限(实际 ¥${String(todayCost().toFixed(4))}),且已在最便宜模型上执行,已暂停——调整 config.costGuard 或明天自动恢复。`,
|
|
295
|
+
note: `今日费用已达上限(实际 ¥${String((todayCost() ?? 0).toFixed(4))}),且已在最便宜模型上执行,已暂停——调整 config.costGuard 或明天自动恢复。`,
|
|
265
296
|
durationMs: Date.now() - startedAt, perf: perf(),
|
|
266
297
|
};
|
|
267
298
|
}
|
|
@@ -284,10 +315,11 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
284
315
|
res = await provider.chat({
|
|
285
316
|
model: activeModel,
|
|
286
317
|
messages: sanitized,
|
|
287
|
-
tools: toolsFor(readOnlyPhase),
|
|
318
|
+
tools: toolsFor(readOnlyPhase, turnStrippedSet),
|
|
288
319
|
temperature,
|
|
289
320
|
maxTokens: maxOutput,
|
|
290
|
-
|
|
321
|
+
// MiniMax P0:仅当 activeModel 声明支持 reasoning 才发送(否则 400 终止回合);'off' 显式禁用例外
|
|
322
|
+
reasoningEffort: modelPreset(activeModel)?.supportsReasoning || reasoningEffort === 'off' ? reasoningEffort : undefined,
|
|
291
323
|
signal: ac.signal,
|
|
292
324
|
onDelta(/** @type {any} */ d) {
|
|
293
325
|
io.stopSpinner();
|
|
@@ -328,7 +360,13 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
328
360
|
|
|
329
361
|
if (res.toolCalls?.length) {
|
|
330
362
|
io.endTurn();
|
|
331
|
-
const assistantMsg = {
|
|
363
|
+
const assistantMsg = {
|
|
364
|
+
role: 'assistant',
|
|
365
|
+
content: res.text || null,
|
|
366
|
+
tool_calls: res.toolCalls,
|
|
367
|
+
// Kimi P0:带工具调用的消息必须携带完整 reasoning_content(否则下一轮 400)
|
|
368
|
+
...(res.toolCalls?.length && res.reasoning ? { reasoning_content: res.reasoning } : {}),
|
|
369
|
+
};
|
|
332
370
|
messages.push(assistantMsg);
|
|
333
371
|
|
|
334
372
|
// 只读工具并行(P2-8):同一 response 里的连续 read/ls/glob/grep 无相互依赖,
|
package/src/atomic-write.js
CHANGED
|
@@ -57,10 +57,16 @@ export function withFileLockSync(/** @type {string} */ lockPath, /** @type {() =
|
|
|
57
57
|
try {
|
|
58
58
|
const st = fs.statSync(lockPath);
|
|
59
59
|
if (Date.now() - st.mtimeMs > staleMs) {
|
|
60
|
+
// TOCTOU 防护(OfficeACE 报告):unlink 前读锁内容并二次 stat 比对,
|
|
61
|
+
// 防两个进程同时判定陈旧、后者误删前者刚创建的新锁(互斥失效)
|
|
60
62
|
try {
|
|
61
|
-
fs.
|
|
63
|
+
const before = fs.readFileSync(lockPath, 'utf8');
|
|
64
|
+
const st2 = fs.statSync(lockPath);
|
|
65
|
+
if (st2.mtimeMs === st.mtimeMs && before === fs.readFileSync(lockPath, 'utf8')) {
|
|
66
|
+
fs.unlinkSync(lockPath);
|
|
67
|
+
}
|
|
62
68
|
} catch {}
|
|
63
|
-
continue; //
|
|
69
|
+
continue; // 无论是否回收成功都重试一次(若锁刚被他人更新则继续等待)
|
|
64
70
|
}
|
|
65
71
|
} catch {
|
|
66
72
|
continue; // 锁文件刚被释放
|
package/src/cli.js
CHANGED
|
@@ -257,8 +257,16 @@ async function main() {
|
|
|
257
257
|
const { readTask } = await import('./tasks.js');
|
|
258
258
|
const handled = new Set();
|
|
259
259
|
const supervising = new Set(); // 本 daemon 正在监督的任务(防崩溃恢复误判正在执行的任务)
|
|
260
|
+
const nonce = String(opts.prompt[1] || '');
|
|
260
261
|
try {
|
|
261
262
|
for (;;) {
|
|
263
|
+
// 守护进程租约自检(2026-09-03,防双 daemon):pidfile 不再等于「我的 pid nonce」
|
|
264
|
+
// 即视为已被 stop 或已被新 daemon 取代——立即退出,绝不与新 daemon 并跑重复执行任务
|
|
265
|
+
try {
|
|
266
|
+
if (fs.readFileSync(daemonPidFile(home0), 'utf8').trim() !== `${process.pid} ${nonce}`) break;
|
|
267
|
+
} catch {
|
|
268
|
+
break; // pidfile 被删 = 已被 stop
|
|
269
|
+
}
|
|
262
270
|
const jobs = listSchedules(home0);
|
|
263
271
|
if (!jobs.some((j) => j.status === 'pending' || j.status === 'running')) break;
|
|
264
272
|
for (const j of jobs) {
|
|
@@ -295,8 +303,12 @@ async function main() {
|
|
|
295
303
|
await new Promise((r) => setTimeout(r, 2000));
|
|
296
304
|
}
|
|
297
305
|
} finally {
|
|
306
|
+
// 只删除「仍指向自己」的 pidfile:租约被改写/被新 daemon 取代而退出时,
|
|
307
|
+
// 绝不误删新 daemon 的 pidfile(否则新 daemon 下一次租约检查会连锁退出,双守护全灭)
|
|
298
308
|
try {
|
|
299
|
-
fs.
|
|
309
|
+
if (fs.readFileSync(daemonPidFile(home0), 'utf8').trim() === `${process.pid} ${nonce}`) {
|
|
310
|
+
fs.rmSync(daemonPidFile(home0), { force: true });
|
|
311
|
+
}
|
|
300
312
|
} catch {}
|
|
301
313
|
}
|
|
302
314
|
return;
|
|
@@ -368,15 +380,22 @@ async function main() {
|
|
|
368
380
|
},
|
|
369
381
|
};
|
|
370
382
|
if (cfg.mcpServers && Object.keys(cfg.mcpServers).length) {
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
383
|
+
// A2:预热——await 连接(6s 超时);超时本会话冻结工具集(不再中途注入,保护前缀缓存)。
|
|
384
|
+
// 超时后输家 promise 仍在跑:迟到就绪的 manager 立即 stop,防 detached 子进程成孤儿(自查 #2)
|
|
385
|
+
const mcpStartP = startMcpServers(cfg.mcpServers, workingDir).catch(() => null);
|
|
386
|
+
mcpManager = await Promise.race([
|
|
387
|
+
mcpStartP,
|
|
388
|
+
new Promise((/** @type {any} */ r) => setTimeout(() => r(null), 6000)),
|
|
389
|
+
]);
|
|
390
|
+
if (!mcpManager) mcpStartP.then((/** @type {any} */ m) => { if (m) m.stop(); });
|
|
391
|
+
if (mcpManager) {
|
|
392
|
+
const ready = mcpManager.status().filter((/** @type {any} */ s) => s.ok).length;
|
|
393
|
+
if (io && !opts.prompt.length) {
|
|
394
|
+
io.print(style(`✓ MCP 就绪:${ready}/${mcpManager.status().length} 个服务器,共 ${mcpManager.toolSchemas().length} 个工具`, C.dim));
|
|
395
|
+
}
|
|
396
|
+
} else if (io && !opts.prompt.length) {
|
|
397
|
+
io.print(style('⚠ MCP 连接超时(6s):本会话不注入 MCP 工具(重启 mingdao 可重试)', C.dim));
|
|
398
|
+
}
|
|
380
399
|
}
|
|
381
400
|
const sessionRef = { name: null }; // 会话名在下方 REPL 初始化中回填(审计归因用)
|
|
382
401
|
// 会话文件与落盘游标共享槽:REPL(commands/repl.js)创建会话/更新游标后回填,
|
package/src/commands/repl.js
CHANGED
|
@@ -322,7 +322,7 @@ export async function runRepl(ctx) {
|
|
|
322
322
|
io.print(`用法:/think low|high|max|off(当前:${cfg.reasoningEffort || '跟随模型默认 high'})`);
|
|
323
323
|
continue;
|
|
324
324
|
}
|
|
325
|
-
if (arg === 'off') cfg.reasoningEffort =
|
|
325
|
+
if (arg === 'off') cfg.reasoningEffort = 'off'; // 显式禁用(provider 转 thinking:{type:'disabled'})
|
|
326
326
|
else if (vals.includes(arg)) cfg.reasoningEffort = arg;
|
|
327
327
|
else { io.print(style('无效取值:low|high|max|off', C.red)); continue; }
|
|
328
328
|
agent = createAgent({
|
|
@@ -579,7 +579,7 @@ export async function runRepl(ctx) {
|
|
|
579
579
|
if (guard) {
|
|
580
580
|
io.print(
|
|
581
581
|
style(
|
|
582
|
-
`费用护栏:今日 ¥${guard.cost.toFixed(4)} / 上限 ¥${guard.limit.toFixed(2)}${guard.overLimit ? '(已达上限' + (guard.action === 'block' ? ',执行已暂停' : ',仅提醒') + ')' : ''}`,
|
|
582
|
+
`费用护栏:今日 ¥${(guard.cost ?? 0).toFixed(4)} / 上限 ¥${guard.limit.toFixed(2)}${guard.overLimit ? '(已达上限' + (guard.action === 'block' ? ',执行已暂停' : ',仅提醒') + ')' : ''}`,
|
|
583
583
|
guard.overLimit ? C.yellow : C.dim
|
|
584
584
|
)
|
|
585
585
|
);
|
package/src/context.js
CHANGED
|
@@ -66,19 +66,31 @@ export function trimMessages(/** @type {any} */ messages, /** @type {any} */ bud
|
|
|
66
66
|
const system = hasSystem ? [messages[0]] : [];
|
|
67
67
|
const rest = hasSystem ? messages.slice(1) : messages;
|
|
68
68
|
const totalOf = (/** @type {any} */ arr) => arr.reduce((/** @type {any} */ s, /** @type {any} */ m) => s + messageTokens(m, count), 0);
|
|
69
|
-
// —— 轻量语义回收(Hermes B1
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
// 质检 M10:维护增量 total(替换消息后按新旧 token 差调整),不再每步全量重算(原 ≤500×n)。
|
|
69
|
+
// —— 轻量语义回收(Hermes B1 + workbuddy P0-1 修正)——
|
|
70
|
+
// 只在「真的超预算」时回收(此前 >80% 即动手,能塞下的会话也被白白截断丢内容);
|
|
71
|
+
// 回收边界 = 按尾部保留规则确定将被丢弃的前缀(此前固定扫前 500 条,超长会话时回收全白做);
|
|
72
|
+
// 替换仅当摘要确实更短才接受(防短消息被摘要撑长)。
|
|
74
73
|
const sysTokens = totalOf(system);
|
|
75
74
|
let total = sysTokens + totalOf(rest);
|
|
76
|
-
if (total > budget
|
|
75
|
+
if (total > budget) {
|
|
77
76
|
const out = [...rest];
|
|
78
|
-
|
|
79
|
-
|
|
77
|
+
// 丢弃边界:从后往前贪心,确定尾部保留起点 s(s 之前的消息将被丢弃,只对它们回收)
|
|
78
|
+
let s = out.length;
|
|
79
|
+
{
|
|
80
|
+
let acc = sysTokens;
|
|
81
|
+
for (let i = out.length - 1; i >= 0; i--) {
|
|
82
|
+
const t = messageTokens(out[i], count);
|
|
83
|
+
if (acc + t > budget) { s = i + 1; break; }
|
|
84
|
+
acc += t;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
for (let i = 0; i < s && i < 500; i++) {
|
|
88
|
+
if (total <= budget) break;
|
|
80
89
|
const m = out[i];
|
|
81
90
|
const c = typeof m.content === 'string' ? m.content : JSON.stringify(m?.content ?? '');
|
|
91
|
+
// 幂等守卫(A4):已回收过的内容跳过——否则「截断标记」把长度再次顶过阈值,逐轮嵌套重截,
|
|
92
|
+
// 被保留消息字节逐轮漂移(前缀缓存连尾部都失配)
|
|
93
|
+
if (c.includes('已回收')) continue;
|
|
82
94
|
let next = null;
|
|
83
95
|
if (m.role === 'tool' && c.length > 40) {
|
|
84
96
|
next = { ...m, content: `[工具 ${m.tool_call_id || ''} 结果摘要:${c.slice(0, 40).replace(/\n/g, ' ')}…(已回收,原 ${c.length} 字)]` };
|
|
@@ -86,8 +98,12 @@ export function trimMessages(/** @type {any} */ messages, /** @type {any} */ bud
|
|
|
86
98
|
next = { ...m, content: c.slice(0, 200) + ` …[已回收,原 ${c.length} 字]` };
|
|
87
99
|
}
|
|
88
100
|
if (next) {
|
|
89
|
-
|
|
90
|
-
|
|
101
|
+
const oldT = messageTokens(m, count);
|
|
102
|
+
const newT = messageTokens(next, count);
|
|
103
|
+
if (newT < oldT) {
|
|
104
|
+
total += newT - oldT;
|
|
105
|
+
out[i] = next;
|
|
106
|
+
}
|
|
91
107
|
}
|
|
92
108
|
}
|
|
93
109
|
// 回收后仍超预算 → 从尾部保留(必要时继续丢弃最老,清洗 tool 配对)
|
package/src/cost-guard.js
CHANGED
|
@@ -32,9 +32,15 @@ export function todayCost() {
|
|
|
32
32
|
todayCache = { mtime: st.mtimeMs, size: st.size, start, sum };
|
|
33
33
|
return sum;
|
|
34
34
|
} catch {
|
|
35
|
-
|
|
35
|
+
// MiniMax P0:统计文件损坏/不可读时返回 null(护栏全部失效)而不是静默 0——并一次性告警
|
|
36
|
+
if (!todayCostWarned) {
|
|
37
|
+
todayCostWarned = true;
|
|
38
|
+
console.warn('[MingDao] 费用统计读取失败:今日费用护栏暂时无法判断(修复 cache-stats.jsonl 或重启后恢复)。');
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
36
41
|
}
|
|
37
42
|
}
|
|
43
|
+
let todayCostWarned = false;
|
|
38
44
|
|
|
39
45
|
export function costGuardStatus() {
|
|
40
46
|
const g = costGuardConfig();
|
|
@@ -49,8 +55,9 @@ export function costGuardStatus() {
|
|
|
49
55
|
warnAt,
|
|
50
56
|
action,
|
|
51
57
|
downgradeModel: String(g.downgradeModel || 'deepseek-v4-flash'),
|
|
52
|
-
|
|
53
|
-
|
|
58
|
+
degraded: cost === null, // 统计不可读:护栏降级为「无法判断」
|
|
59
|
+
overWarn: cost !== null && limit > 0 && cost >= warnAt,
|
|
60
|
+
overLimit: cost !== null && limit > 0 && cost >= limit,
|
|
54
61
|
};
|
|
55
62
|
}
|
|
56
63
|
|
|
@@ -58,10 +65,11 @@ export function costGuardStatus() {
|
|
|
58
65
|
export function checkCostGuard() {
|
|
59
66
|
const st = costGuardStatus();
|
|
60
67
|
if (!st) return null;
|
|
68
|
+
if (st.degraded) return null; // 统计不可读:不误拦也不放水(已告警),按无法判断处理
|
|
61
69
|
if (st.overLimit && st.action === 'block') {
|
|
62
70
|
return {
|
|
63
71
|
blocked: true,
|
|
64
|
-
message: `今日费用已达上限 ¥${st.limit.toFixed(2)}(实际 ¥${st.cost.toFixed(4)}),已暂停执行——调整 config.costGuard 或明天自动恢复。`,
|
|
72
|
+
message: `今日费用已达上限 ¥${st.limit.toFixed(2)}(实际 ¥${(st.cost ?? 0).toFixed(4)}),已暂停执行——调整 config.costGuard 或明天自动恢复。`,
|
|
65
73
|
};
|
|
66
74
|
}
|
|
67
75
|
if (st.overLimit && st.action === 'downgrade') {
|
|
@@ -69,13 +77,13 @@ export function checkCostGuard() {
|
|
|
69
77
|
blocked: false,
|
|
70
78
|
downgrade: true,
|
|
71
79
|
downgradeModel: st.downgradeModel,
|
|
72
|
-
message: `⚠ 费用护栏:今日已用 ¥${st.cost.toFixed(4)} 超上限 ¥${st.limit.toFixed(2)}——已自动降级到便宜模型 ${st.downgradeModel} 继续执行(config.costGuard.action 可改回 warn/block)。`,
|
|
80
|
+
message: `⚠ 费用护栏:今日已用 ¥${(st.cost ?? 0).toFixed(4)} 超上限 ¥${st.limit.toFixed(2)}——已自动降级到便宜模型 ${st.downgradeModel} 继续执行(config.costGuard.action 可改回 warn/block)。`,
|
|
73
81
|
};
|
|
74
82
|
}
|
|
75
83
|
if (st.overWarn) {
|
|
76
84
|
return {
|
|
77
85
|
blocked: false,
|
|
78
|
-
message: `⚠ 费用护栏:今日已用 ¥${st.cost.toFixed(4)} / 上限 ¥${st.limit.toFixed(2)}${st.action !== 'warn' ? `(${st.action === 'block' ? '到达即暂停' : '到达自动降级 ' + st.downgradeModel})` : '(仅提醒)'}。`,
|
|
86
|
+
message: `⚠ 费用护栏:今日已用 ¥${(st.cost ?? 0).toFixed(4)} / 上限 ¥${st.limit.toFixed(2)}${st.action !== 'warn' ? `(${st.action === 'block' ? '到达即暂停' : '到达自动降级 ' + st.downgradeModel})` : '(仅提醒)'}。`,
|
|
79
87
|
};
|
|
80
88
|
}
|
|
81
89
|
return null;
|
package/src/log-writer.js
CHANGED
|
@@ -9,7 +9,9 @@ export function createLogWriter(/** @type {string} */ file, { maxBytes = 512 * 1
|
|
|
9
9
|
try {
|
|
10
10
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
11
11
|
const line = new Date().toISOString() + ' ' + String(msg) + '\n';
|
|
12
|
-
|
|
12
|
+
// 自查 #5:日志可能含工具参数等敏感信息——创建与轮转一律 600(多用户机器防同机读取)
|
|
13
|
+
if (!fs.existsSync(file)) fs.appendFileSync(file, line, { mode: 0o600 });
|
|
14
|
+
else fs.appendFileSync(file, line);
|
|
13
15
|
const st = fs.statSync(file);
|
|
14
16
|
if (st.size > maxBytes) {
|
|
15
17
|
const raw = fs.readFileSync(file, 'utf8');
|
|
@@ -17,8 +19,9 @@ export function createLogWriter(/** @type {string} */ file, { maxBytes = 512 * 1
|
|
|
17
19
|
const nl = raw.indexOf('\n', cut);
|
|
18
20
|
const keep = nl === -1 ? raw.slice(cut) : raw.slice(nl + 1); // 行对齐保留尾部
|
|
19
21
|
const tmp = file + '.' + process.pid + '.' + crypto.randomBytes(3).toString('hex') + '.tmp';
|
|
20
|
-
fs.writeFileSync(tmp, keep);
|
|
22
|
+
fs.writeFileSync(tmp, keep, { mode: 0o600 }); // 轮转重建保持 600
|
|
21
23
|
fs.renameSync(tmp, file);
|
|
24
|
+
try { fs.chmodSync(file, 0o600); } catch {} // rename 后兜底收权(历史 644 日志迁移)
|
|
22
25
|
}
|
|
23
26
|
} catch {
|
|
24
27
|
// 日志失败绝不抛错(best-effort)
|
package/src/pricing.js
CHANGED
|
@@ -42,9 +42,18 @@ export function pricingDataStale() { return externalPricing().stale; }
|
|
|
42
42
|
export async function refreshPricingFromSource(cfg) {
|
|
43
43
|
const src = String(cfg?.pricing?.source || '').trim();
|
|
44
44
|
if (!src) return { ok: false, lines: ['未配置 pricing.source(config.json 的 pricing.source 填官方价格 JSON 地址后重试)'] };
|
|
45
|
+
// 自查 #4:与 sync/自定义模型同类的第三外联入口——协议白名单 + 响应大小上限(防误配/恶意源灌内存)
|
|
46
|
+
try {
|
|
47
|
+
const u = new URL(src);
|
|
48
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return { ok: false, lines: ['价格源仅支持 http/https 地址'] };
|
|
49
|
+
} catch {
|
|
50
|
+
return { ok: false, lines: ['价格源必须是合法的 http(s) URL'] };
|
|
51
|
+
}
|
|
45
52
|
const res = await fetch(src, { signal: AbortSignal.timeout(15000) });
|
|
46
53
|
if (!res.ok) return { ok: false, lines: ['价格源请求失败:HTTP ' + res.status] };
|
|
47
|
-
const
|
|
54
|
+
const buf = await res.arrayBuffer();
|
|
55
|
+
if (buf.byteLength > 512 * 1024) return { ok: false, lines: ['价格源响应超过 512KB 上限,已拒绝'] };
|
|
56
|
+
const d = JSON.parse(Buffer.from(buf).toString('utf8'));
|
|
48
57
|
const models = d && typeof d === 'object' && !Array.isArray(d) ? /** @type {any} */ (d).models : null;
|
|
49
58
|
if (!models || typeof models !== 'object' || Array.isArray(models)) {
|
|
50
59
|
return { ok: false, lines: ['价格源格式不符:应为 {"models": {"模型名": {"input":…,"output":…,"cacheHit":…,"peak":{…}}}}'] };
|
package/src/prompts.js
CHANGED
|
@@ -14,7 +14,7 @@ const BASE = `你是 MingDao Harness,一个由 MingDao Harness 驱动的 AI
|
|
|
14
14
|
2. 精准修改:优先用 edit 做小步精确替换;新建文件用 write;保持改动最小化;改错可用 undo 撤销。
|
|
15
15
|
3. 大内容分批写:单个 write 的参数总长控制在 6000 字符以内;大文件先写核心骨架,再分多次 write/edit 逐步补充——单次输出超限会被截断并导致参数解析失败。
|
|
16
16
|
4. 任务管理:多步骤任务先建 todo 清单并逐项更新;与技能(skill)相关的任务先加载对应 SKILL.md。
|
|
17
|
-
5.
|
|
17
|
+
5. 委托与专注:把「互不依赖、可并行」的子问题(同时调研多个方向、复核多份文件、独立实现多模块)用 task 委托子代理;多个只读调研子代理应在同一轮一起派发(readOnly=true,自动并行,效率最高);依赖当前对话细节的小改动自己做,不要委托。子代理返回后拿结果继续主线。
|
|
18
18
|
6. 命令谨慎:执行 bash 前想清楚影响;命令失败时阅读错误输出并修复,同一问题最多重试 3 次。
|
|
19
19
|
7. 回答风格:使用用户的语言(默认中文),简洁直接,先结论后说明。
|
|
20
20
|
8. 诚实可靠:不编造不存在的文件、行号、API 或执行结果。
|
|
@@ -16,7 +16,8 @@ export async function chat(/** @type {any} */ { baseUrl, apiKey, model, messages
|
|
|
16
16
|
}
|
|
17
17
|
if (maxTokens) payload.max_tokens = maxTokens;
|
|
18
18
|
if (responseFormat) payload.response_format = responseFormat;
|
|
19
|
-
if (reasoningEffort) payload.
|
|
19
|
+
if (reasoningEffort === 'off') payload.thinking = { type: 'disabled' }; // /think off:显式关闭思考(官方 thinking_mode)
|
|
20
|
+
else if (reasoningEffort) payload.reasoning_effort = reasoningEffort; // 思考强度(官方 thinking_mode)
|
|
20
21
|
payload.stream = true;
|
|
21
22
|
// 流式响应默认不带 usage,显式请求以便展示 token/费用统计
|
|
22
23
|
// (部分网关不支持该字段,可在 config.json 设 "includeUsage": false 关闭)
|
|
@@ -153,8 +154,9 @@ export async function parseStream(/** @type {any} */ body, /** @type {any} */ on
|
|
|
153
154
|
if (handleLine(line)) break;
|
|
154
155
|
}
|
|
155
156
|
}
|
|
156
|
-
//
|
|
157
|
-
content
|
|
157
|
+
// 收尾:残行(网关最后一帧不带换行)与多字节字符冲刷——decode 残余并入 buf 统一走行解析,
|
|
158
|
+
// 不再直接拼进 content(此前尾字节绕过 doneFlag/行解析,流中途截断时正文混入杂质)
|
|
159
|
+
buf += decoder.decode();
|
|
158
160
|
if (buf.trim()) handleLine(buf); // [DONE] 后残留 usage 尾帧仍吸收(正文/推理已被 doneFlag 拦截)
|
|
159
161
|
|
|
160
162
|
const toolCalls = [...calls.entries()]
|
package/src/routing.js
CHANGED
|
@@ -19,11 +19,11 @@ export function routingConfig(/** @type {any} */ cfg) {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
const PLAN_HINTS =
|
|
22
|
-
/设计|架构|重构|审查|规划|分析|方案|评估|优化|排查|疑难|报错|怎么修|修复|设计模式|选型|技术债|roadmap|review|design|refactor|plan|architecture
|
|
22
|
+
/设计|架构|重构|审查|规划|分析|方案|评估|优化|排查|疑难|报错|怎么修|修复|设计模式|选型|技术债|roadmap|review|design|refactor|plan|architecture|方案设计|评审|fix|analyze|analyse|evaluate|audit|optimize|bug|issue|error|performance/i;
|
|
23
23
|
|
|
24
24
|
// 生成类任务(游戏/网页/文档等)需要大输出(planner 32K vs executor 8K),即使短句也路由 planner
|
|
25
25
|
const GENERATION_HINTS =
|
|
26
|
-
/(
|
|
26
|
+
/(生成|制作|编写|创建|写|开发|实现|做|make|build|create|generate|write|implement|develop).{0,30}(游戏|网页|页面|网站|应用|程序|文档|报告|简历|PPT|演示|完整|详细|小工具|game|website|page|site|app|program|document|report|resume|ppt|tool)|网页版|游戏|webpage|website/i;
|
|
27
27
|
|
|
28
28
|
export function heuristicRoute(/** @type {any} */ text, /** @type {any} */ rc) {
|
|
29
29
|
const s = String(text ?? '');
|
|
@@ -31,7 +31,7 @@ export function heuristicRoute(/** @type {any} */ text, /** @type {any} */ rc) {
|
|
|
31
31
|
if (PLAN_HINTS.test(s)) {
|
|
32
32
|
// 规划类关键词优先(审计:此前要求 length>=40,导致「设计一个缓存方案」这类
|
|
33
33
|
// 13 字短句被误路由到 flash 硬扛——关键词强度优先于文本长度)
|
|
34
|
-
return s.length >= 40 ||
|
|
34
|
+
return s.length >= 40 || /设计|规划|分析|评估|审查|架构|重构|方案|优化|报错|修复|fix|analy|review|design|plan|optimiz|refactor|evaluat|audit|error|bug/i.test(s) ? rc.planner : null;
|
|
35
35
|
}
|
|
36
36
|
if (s.length <= 60) return rc.executor;
|
|
37
37
|
return null; // 需要分类器
|
package/src/schedule.js
CHANGED
|
@@ -164,7 +164,7 @@ export function removeSchedule(/** @type {any} */ home, /** @type {any} */ id) {
|
|
|
164
164
|
if (!job) return false;
|
|
165
165
|
if (job.pid) {
|
|
166
166
|
const owned = pidOwnedBy(job.pid, id); // 质检 M11:cmdline 含调度 id 才 kill
|
|
167
|
-
if (owned
|
|
167
|
+
if (owned === true) { // 非 Linux/无法校验(null)不盲杀(CodeBuddy 报告:PID 复用误杀风险)
|
|
168
168
|
try {
|
|
169
169
|
process.kill(job.pid, 'SIGTERM');
|
|
170
170
|
} catch {}
|
|
@@ -186,7 +186,7 @@ export function pauseSchedule(/** @type {any} */ home, /** @type {any} */ id) {
|
|
|
186
186
|
if (!job) return false;
|
|
187
187
|
if (job.pid) {
|
|
188
188
|
const owned = pidOwnedBy(job.pid, id); // 质检 M11:cmdline 含调度 id 才 kill
|
|
189
|
-
if (owned
|
|
189
|
+
if (owned === true) { // 非 Linux/无法校验(null)不盲杀(CodeBuddy 报告:PID 复用误杀风险)
|
|
190
190
|
try {
|
|
191
191
|
process.kill(job.pid, 'SIGTERM');
|
|
192
192
|
} catch {}
|
|
@@ -293,7 +293,9 @@ export function daemonAlive(/** @type {any} */ home) {
|
|
|
293
293
|
}
|
|
294
294
|
export function stopDaemon(/** @type {any} */ home) {
|
|
295
295
|
try {
|
|
296
|
-
|
|
296
|
+
// pidfile 格式 "<pid> <nonce>"——必须取首段(此前整串 Number()=NaN,SIGTERM 永远不发,
|
|
297
|
+
// 只删 pidfile → 孤儿 daemon 继续跑,新 daemon 再被拉起 → 双守护重复执行任务)
|
|
298
|
+
const pid = Number(String(fs.readFileSync(daemonPidFile(home), 'utf8')).trim().split(/\s+/)[0]);
|
|
297
299
|
if (pid) {
|
|
298
300
|
try {
|
|
299
301
|
process.kill(pid, 'SIGTERM');
|
package/src/skills.js
CHANGED
|
@@ -94,8 +94,10 @@ export function listSkills(/** @type {any} */ workingDir) {
|
|
|
94
94
|
continue;
|
|
95
95
|
}
|
|
96
96
|
seen.add(e.name); // 优先级:user > project > builtin,先出现的生效
|
|
97
|
-
// P3-3
|
|
98
|
-
|
|
97
|
+
// P3-3:技能指纹不符 → 拒绝加载(绝不静默执行被篡改的提示词)。
|
|
98
|
+
// project 级(.mingdao/skills/)同样校验(CodeBuddy 报告:仓库投毒可直通提示词注入);
|
|
99
|
+
// 无 .source.json 指纹的旧仓库/手写技能不受影响(isTampered 返回 false)。
|
|
100
|
+
if ((source === 'user' || source === 'project') && isTampered(path.join(dir, e.name))) continue;
|
|
99
101
|
out.push({
|
|
100
102
|
name: e.name,
|
|
101
103
|
dir: path.join(dir, e.name),
|
package/src/tasks.js
CHANGED
|
@@ -115,7 +115,7 @@ 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
|
|
118
|
+
if (owned === true) { // 非 Linux/无法校验(null)不盲杀(CodeBuddy 报告:PID 复用误杀风险)
|
|
119
119
|
try {
|
|
120
120
|
// worker 是 detached 进程(自成进程组):优先杀整组,避免工具子进程成孤儿
|
|
121
121
|
process.kill(-t.pid, 'SIGTERM');
|
package/src/tokenizer.js
CHANGED
|
@@ -290,7 +290,7 @@ export function countTokens(text, modelName) {
|
|
|
290
290
|
const hit = byModel.get(s);
|
|
291
291
|
if (hit !== undefined) return hit;
|
|
292
292
|
const n = countDeepseek(s);
|
|
293
|
-
if (byModel.size >= TOKEN_CACHE_MAX_ENTRIES) byModel.
|
|
293
|
+
if (byModel.size >= TOKEN_CACHE_MAX_ENTRIES) byModel.delete(byModel.keys().next().value); // LRU:删最旧(Map 迭代序)
|
|
294
294
|
byModel.set(s, n);
|
|
295
295
|
return n;
|
|
296
296
|
}
|
package/src/tools/index.js
CHANGED
|
@@ -83,9 +83,9 @@ const SKILL_SCHEMA = {
|
|
|
83
83
|
const TASK_SCHEMA = {
|
|
84
84
|
type: 'object',
|
|
85
85
|
properties: {
|
|
86
|
-
description: { type: 'string', description: '
|
|
87
|
-
prompt: { type: 'string', description: '
|
|
88
|
-
readOnly: { type: 'boolean', description: '
|
|
86
|
+
description: { type: 'string', description: '子任务一句话描述(用于进度展示)。' },
|
|
87
|
+
prompt: { type: 'string', description: '子代理的完整任务说明(全新上下文,需自带全部必要背景)。' },
|
|
88
|
+
readOnly: { type: 'boolean', description: '只读调研任务(不写文件不执行命令)。多个 readOnly 子任务同一轮派发会并行执行,效率最高。' },
|
|
89
89
|
},
|
|
90
90
|
required: ['prompt'],
|
|
91
91
|
};
|
|
@@ -185,7 +185,7 @@ const TOOLS = [
|
|
|
185
185
|
type: 'function',
|
|
186
186
|
function: {
|
|
187
187
|
name: 'task',
|
|
188
|
-
description: '
|
|
188
|
+
description: '把独立、可并行的子任务委托给全新上下文子代理并返回汇报。适合:多方向调研/复核/独立实现等互不依赖的任务(多个只读调研子任务应同一轮一起派发,自动并行,效率最高);不适合:依赖当前对话细节的小改动。',
|
|
189
189
|
parameters: TASK_SCHEMA,
|
|
190
190
|
},
|
|
191
191
|
},
|
package/src/web/app.js
CHANGED
|
@@ -101,6 +101,7 @@ $('#dirPickNone').onclick = () => { const cb = pickerCb; pickerCb = null; $('#di
|
|
|
101
101
|
$('#dirPickCancel').onclick = () => { pickerCb = null; $('#dirModal').style.display = 'none'; };
|
|
102
102
|
const chatEl = $('#chat'), input = $('#input'), sendBtn = $('#sendBtn');
|
|
103
103
|
let activeAiMsg=null, bgRunning=0, curSteps=0, curWorkT0=0, curTools=0, curTasks=0; // 本轮进度(活动条/状态条/轨迹共用)
|
|
104
|
+
let bgTasks=[]; // 后台任务列表快照(chip tooltip 详情用,updateTasksPanel 每 2s 刷新)
|
|
104
105
|
let curPhase='模型推理中'; // 阶段语义(服务端 progress 事件下发)
|
|
105
106
|
const sessionSubs=[]; // 本会话全部子代理(task 工具)条目:{seq, question, result, msg}
|
|
106
107
|
// 回到底部悬浮按钮:滚动容器为 main;上滚超过 300px 时出现,点击平滑回底
|
|
@@ -421,13 +422,17 @@ function renderWorkStatus(){
|
|
|
421
422
|
let html='';
|
|
422
423
|
if(generating){
|
|
423
424
|
const secs=curWorkT0?Math.round((Date.now()-curWorkT0)/1000):0;
|
|
424
|
-
html='<span class="ws-busy"><span class="spinner"></span>⏳ '+curSteps+' 步 · '+Math.floor(secs/60)+' 分 '+Math.round(secs%60)+' 秒 · '+curTools+'
|
|
425
|
+
html='<span class="ws-busy"><span class="spinner"></span><span class="ws-phase">'+esc(curPhase)+'</span><span>⏳ 第 '+curSteps+' 步 · '+Math.floor(secs/60)+' 分 '+Math.round(secs%60)+' 秒 · '+curTools+' 工具步'+(curTasks>0?' · '+curTasks+' 个子代理':'')+'</span></span>';
|
|
425
426
|
} else if(bgRunning>0){
|
|
426
|
-
|
|
427
|
+
// 后台任务 chip(非顶部,位于输入框上方):计数 + 最新任务 + 悬浮详情 tooltip,点击打开详情面板
|
|
428
|
+
const running=bgTasks.filter((/** @type {any} */ t)=>t.status==='running');
|
|
429
|
+
const latest=running.length?running[running.length-1]:null;
|
|
430
|
+
const tip=bgTasks.map((/** @type {any} */ t)=>(t.kind==='schedule'?'⏰':'🛠')+' '+String(t.message||t.id||'').slice(0,40)+' · '+t.status).join('\n');
|
|
431
|
+
html='<span class="ws-bg" title="'+esc(String(tip)).replace(/"/g,'"')+'">🛠 '+bgRunning+' 个后台任务运行中'+(latest?' · '+esc(String(latest.message||'').slice(0,22)):'')+' — 点击查看详情</span>';
|
|
427
432
|
}
|
|
428
433
|
el.style.display=html?'flex':'none';
|
|
429
434
|
el.innerHTML=html;
|
|
430
|
-
const bg=el.querySelector('.ws-bg'); if(bg) bg.onclick=()=>{ const tp=$('#tasksPanel'); tp.style.display = tp.style.display==='none'?'flex':'none'; if(tp.style.display==='flex'){ $('#trajPanel').style.display='none'; $('#subPanel').style.display='none'; updateTasksPanel(); } };
|
|
435
|
+
const bg=el.querySelector('.ws-bg'); if(bg) bg.onclick=()=>{ const tp=$('#tasksPanel'); tp.style.display = tp.style.display==='none'?'flex':'none'; if(tp.style.display==='flex'){ $('#trajPanel').style.display='none'; $('#subPanel').style.display='none'; updateTasksPanel(); } syncPanelLayout(); };
|
|
431
436
|
}
|
|
432
437
|
// 顶部常驻活动条(静默完美解决层):生成期间始终可见,滚动不影响
|
|
433
438
|
function renderLiveBar(){
|
|
@@ -483,6 +488,15 @@ function openTraj(msg){
|
|
|
483
488
|
$('#trajPanel').style.display='flex';
|
|
484
489
|
$('#tasksPanel').style.display='none'; // 仅任务面板(右侧)与轨迹互斥;子代理面板可与轨迹同显
|
|
485
490
|
$('#trajRailBtn').classList.add('on');
|
|
491
|
+
syncPanelLayout();
|
|
492
|
+
}
|
|
493
|
+
// 面板布局同步(2026-09-02):轨迹/子代理/任务面板打开时以 body class 推开内容区,
|
|
494
|
+
// 聊天文本与附件行不再被固定面板遮挡;面板宽度随屏宽自动收窄(CSS 媒体查询)。
|
|
495
|
+
function syncPanelLayout(){
|
|
496
|
+
const open=(id)=>document.getElementById(id)?.style.display==='flex';
|
|
497
|
+
document.body.classList.toggle('traj-open', open('trajPanel'));
|
|
498
|
+
document.body.classList.toggle('sub-open', open('subPanel'));
|
|
499
|
+
document.body.classList.toggle('tasks-open', open('tasksPanel'));
|
|
486
500
|
}
|
|
487
501
|
function resultText(r){
|
|
488
502
|
if(r==null||r===undefined) return '';
|
|
@@ -492,13 +506,14 @@ function resultText(r){
|
|
|
492
506
|
return JSON.stringify(r);
|
|
493
507
|
}
|
|
494
508
|
function truncText(t,n){ t=String(t||''); return t.length>n?t.slice(0,n)+'\n…(截断,共 '+t.length+' 字)':t; }
|
|
495
|
-
$('#tjClose').onclick=()=>{ $('#trajPanel').style.display='none'; $('#trajRailBtn').classList.remove('on'); };
|
|
509
|
+
$('#tjClose').onclick=()=>{ $('#trajPanel').style.display='none'; $('#trajRailBtn').classList.remove('on'); syncPanelLayout(); };
|
|
496
510
|
$('#trajRailBtn').onclick=()=>{
|
|
497
511
|
const p=$('#trajPanel');
|
|
498
512
|
const willShow=p.style.display==='none';
|
|
499
513
|
if(willShow){ const m=lastMsgWithTraj(); if(!m){ uiAlert('本轮还没有可展示的轨迹(先发起一个任务)'); return; } openTraj(m); }
|
|
500
514
|
else p.style.display='none';
|
|
501
515
|
$('#trajRailBtn').classList.toggle('on', willShow);
|
|
516
|
+
syncPanelLayout();
|
|
502
517
|
};
|
|
503
518
|
function lastMsgWithTraj(){
|
|
504
519
|
const msgs=[...document.querySelectorAll('#chat .msg-ai')].reverse();
|
|
@@ -528,9 +543,10 @@ const toggleSubPanel=()=>{
|
|
|
528
543
|
p.style.display=willShow?'flex':'none';
|
|
529
544
|
$('#subRailBtn').classList.toggle('on', willShow);
|
|
530
545
|
if(willShow){ $('#tasksPanel').style.display='none'; renderSubPanel(); } // 轨迹(左侧)可与子代理(右侧)同显
|
|
546
|
+
syncPanelLayout();
|
|
531
547
|
};
|
|
532
548
|
$('#subRailBtn').onclick=toggleSubPanel;
|
|
533
|
-
$('#sbClose').onclick=()=>{ $('#subPanel').style.display='none'; $('#subRailBtn').classList.remove('on'); };
|
|
549
|
+
$('#sbClose').onclick=()=>{ $('#subPanel').style.display='none'; $('#subRailBtn').classList.remove('on'); syncPanelLayout(); };
|
|
534
550
|
// —— 任务面板(多会话并行) ——
|
|
535
551
|
function setBtn(){
|
|
536
552
|
sendBtn.textContent = generating ? '■ 停止' : '发送';
|
|
@@ -542,6 +558,7 @@ async function updateTasksPanel(){
|
|
|
542
558
|
const j=await r.json(); const list=$('#tpList'); list.innerHTML='';
|
|
543
559
|
// 质检(等待状态静默):后台任务(worker/调度)并入计数与面板;状态转换时在聊天区弹可见横幅
|
|
544
560
|
bgRunning=(Number(j.running)||0)+(Number(j.bgRunning)||0);
|
|
561
|
+
bgTasks=j.background||[];
|
|
545
562
|
for(const t of (j.background||[])){
|
|
546
563
|
const prev=lastBgStatus.get(t.id);
|
|
547
564
|
if(prev && prev.status==='running' && t.status!=='running'){
|
|
@@ -580,7 +597,7 @@ async function refreshCostBadge(){
|
|
|
580
597
|
const bd=j.breakdown||{}; const gd=j.guard||null;
|
|
581
598
|
let t='今日 ≈¥'+(bd.today||0).toFixed(4);
|
|
582
599
|
if(bd.rate!=null) t+=' · 命中 '+(bd.rate*100).toFixed(0)+'%';
|
|
583
|
-
if(gd&&gd.limit>0) t+=' · 护栏 '+(gd.cost/gd.limit*100).toFixed(0)+'%';
|
|
600
|
+
if(gd&&gd.limit>0&&gd.cost!=null) t+=' · 护栏 '+(gd.cost/gd.limit*100).toFixed(0)+'%';
|
|
584
601
|
$('#costBadge').textContent=t;
|
|
585
602
|
}
|
|
586
603
|
refreshCostBadge(); setInterval(refreshCostBadge, 15000);
|
|
@@ -597,8 +614,8 @@ async function refreshStatusBar(){
|
|
|
597
614
|
$('#statusBar').textContent = s.turns+' 轮 · '+s.steps+' 步 | LLM '+fmtDur(s.llmMs)+' · 工具调用 '+fmtDur(s.toolMs)+' | 首 token 平均 '+ft+' · '+tps+' | 缓存命中 '+rate+' | 输入 '+fmtTok(s.prompt)+' tok · 输出 '+fmtTok(s.completion)+' tok';
|
|
598
615
|
}
|
|
599
616
|
refreshStatusBar(); setInterval(refreshStatusBar, 15000);
|
|
600
|
-
$('#tasksBtn').onclick=()=>{ const p=$('#tasksPanel'); p.style.display = p.style.display==='none'?'flex':'none'; if(p.style.display==='flex'){ $('#subPanel').style.display='none'; $('#trajPanel').style.display='none'; $('#trajRailBtn').classList.remove('on'); $('#subRailBtn').classList.remove('on'); updateTasksPanel(); } };
|
|
601
|
-
$('#tpClose').onclick=()=>{ $('#tasksPanel').style.display='none'; };
|
|
617
|
+
$('#tasksBtn').onclick=()=>{ const p=$('#tasksPanel'); p.style.display = p.style.display==='none'?'flex':'none'; if(p.style.display==='flex'){ $('#subPanel').style.display='none'; $('#trajPanel').style.display='none'; $('#trajRailBtn').classList.remove('on'); $('#subRailBtn').classList.remove('on'); updateTasksPanel(); } syncPanelLayout(); };
|
|
618
|
+
$('#tpClose').onclick=()=>{ $('#tasksPanel').style.display='none'; syncPanelLayout(); };
|
|
602
619
|
|
|
603
620
|
async function refreshSessions(q){ const u=q?'/api/sessions?q='+encodeURIComponent(q):'/api/sessions'; const r=await fetch(u).catch(()=>null); if(!r)return; const j=await r.json(); const sel=$('#sessions'); sel.innerHTML='<option value="">历史会话</option>'; for(const s of j.sessions){ const o=document.createElement('option'); o.value=s.file; o.textContent=s.label; sel.appendChild(o); } if(currentSession&&!q) sel.value=currentSession; }
|
|
604
621
|
let searchTimer=null;
|
package/src/web/index.html
CHANGED
|
@@ -25,7 +25,27 @@
|
|
|
25
25
|
*::-webkit-scrollbar-thumb:hover{background:#545557;border:2px solid transparent;background-clip:content-box}
|
|
26
26
|
*::-webkit-scrollbar-track{background:transparent}
|
|
27
27
|
html,body{height:100%}
|
|
28
|
-
body{margin:0;background:var(--bg);color:var(--text);font:15px/1.65 -apple-system,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;display:flex;flex-direction:column;overflow-x:hidden}
|
|
28
|
+
body{margin:0;background:var(--bg);color:var(--text);font:15px/1.65 -apple-system,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;display:flex;flex-direction:column;overflow-x:hidden;transition:padding .16s ease}
|
|
29
|
+
/* 面板推开布局(2026-09-02):轨迹/子代理/任务面板打开时推开内容区,聊天文本与附件行不被遮挡;
|
|
30
|
+
面板宽度随屏宽自动收窄,保证聊天区始终可读(≤560px 退化为全宽面板) */
|
|
31
|
+
body.traj-open{padding-left:294px}
|
|
32
|
+
body.sub-open{padding-right:320px}
|
|
33
|
+
body.tasks-open{padding-right:280px}
|
|
34
|
+
@media (max-width:1200px){
|
|
35
|
+
#trajPanel{width:210px}body.traj-open{padding-left:252px}
|
|
36
|
+
#subPanel{width:270px}body.sub-open{padding-right:270px}
|
|
37
|
+
#tasksPanel{width:230px}body.tasks-open{padding-right:230px}
|
|
38
|
+
}
|
|
39
|
+
@media (max-width:860px){
|
|
40
|
+
#trajPanel{width:160px}body.traj-open{padding-left:202px}
|
|
41
|
+
#subPanel{width:190px}body.sub-open{padding-right:190px}
|
|
42
|
+
#tasksPanel{width:190px}body.tasks-open{padding-right:190px}
|
|
43
|
+
}
|
|
44
|
+
@media (max-width:560px){
|
|
45
|
+
body.traj-open,body.sub-open,body.tasks-open{padding:0}
|
|
46
|
+
#trajPanel{width:calc(100vw - 42px);left:42px}
|
|
47
|
+
#subPanel,#tasksPanel{width:100vw}
|
|
48
|
+
}
|
|
29
49
|
header{min-height:52px;display:flex;align-items:center;gap:10px;padding:8px 12px;border-bottom:1px solid var(--border);background:var(--bg2);flex:none;flex-wrap:nowrap;white-space:nowrap;overflow-x:auto}
|
|
30
50
|
header .logo{font-weight:700;font-size:16px;color:var(--accent);letter-spacing:.5px;flex:none}
|
|
31
51
|
header .logo span{color:var(--text)}
|
|
@@ -42,7 +62,8 @@ header select{flex:none}
|
|
|
42
62
|
#tasksPanel{position:fixed;right:0;top:52px;bottom:0;width:280px;background:var(--bg2);border-left:1px solid var(--border);z-index:40;display:flex;flex-direction:column}
|
|
43
63
|
/* 输入框上方工作状态条(审计:长任务静默硬伤——实时显示执行步数/耗时与后台任务数)。
|
|
44
64
|
与输入框同宽对齐(复用 #composer 的居中容器),不再横贯整个聊天窗口 */
|
|
45
|
-
#workStatus{max-width:
|
|
65
|
+
#workStatus{width:fit-content;max-width:100%;margin:0 auto 4px;display:flex;align-items:center;gap:10px;padding:6px 14px;background:var(--panel);border:1px solid var(--border);border-radius:12px;font-size:12.5px;color:var(--dim);flex:none;overflow:hidden}
|
|
66
|
+
#workStatus .ws-busy{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
46
67
|
/* 状态条 busy 行:承载完整阶段信息(合并原顶部活动条),与输入框同列不遮挡聊天区 */
|
|
47
68
|
#workStatus .ws-phase{color:var(--accent2);font-weight:600;white-space:nowrap}
|
|
48
69
|
/* 左侧轨迹固定栏 + 右侧子代理面板(布局重构:轨迹在左、子代理在右) */
|
|
@@ -208,7 +208,7 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
|
|
|
208
208
|
const label = String(body.label || '').trim();
|
|
209
209
|
const baseUrl = String(body.baseUrl || '').trim();
|
|
210
210
|
if (!baseUrl) return json(res, 400, { error: 'API 地址(baseUrl)不能为空' });
|
|
211
|
-
const vurl = shared.validateRemoteUrl(baseUrl); // 质检 S1:SSRF 防护
|
|
211
|
+
const vurl = await shared.validateRemoteUrl(baseUrl); // 质检 S1:SSRF 防护
|
|
212
212
|
if (vurl.error) return json(res, 400, { error: vurl.error });
|
|
213
213
|
if (action === 'addCustom' && (cfg.customModels || {})[name]) {
|
|
214
214
|
return json(res, 400, { error: `自定义模型 ${name} 已存在(可修改)` });
|
|
@@ -265,7 +265,7 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
|
|
|
265
265
|
if (action === 'setBaseUrl') {
|
|
266
266
|
const baseUrl = String(body.baseUrl || '').trim();
|
|
267
267
|
if (baseUrl) {
|
|
268
|
-
const vurl = shared.validateRemoteUrl(baseUrl); // 质检 S1:SSRF 防护
|
|
268
|
+
const vurl = await shared.validateRemoteUrl(baseUrl); // 质检 S1:SSRF 防护
|
|
269
269
|
if (vurl.error) return json(res, 400, { error: vurl.error });
|
|
270
270
|
}
|
|
271
271
|
cfg.baseUrl = baseUrl || undefined;
|
|
@@ -30,7 +30,8 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
|
|
|
30
30
|
});
|
|
31
31
|
let body;
|
|
32
32
|
try {
|
|
33
|
-
|
|
33
|
+
// chat 保留默认 40MB 上限(附件 base64 可达 ~26MB);普通 JSON 接口才用 MAX_API_BODY=1MB
|
|
34
|
+
body = await readBody(req);
|
|
34
35
|
} catch (/** @type {any} */ e) {
|
|
35
36
|
refs.inflight -= 1;
|
|
36
37
|
res.write(`data: ${JSON.stringify({ type: 'error', message: e.message })}\n\n`);
|
|
@@ -39,7 +39,7 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
|
|
|
39
39
|
if (method === 'POST' && p === '/api/sync') {
|
|
40
40
|
const body = await readBody(req, MAX_API_BODY);
|
|
41
41
|
if (body.action === 'login') {
|
|
42
|
-
const vurl = validateRemoteUrl(body.url); // 质检 S1:SSRF 防护(同步端点同样只允许公网)
|
|
42
|
+
const vurl = await validateRemoteUrl(body.url); // 质检 S1:SSRF 防护(同步端点同样只允许公网)
|
|
43
43
|
if (vurl.error) return json(res, 400, { error: vurl.error });
|
|
44
44
|
const r = await syncLogin({ url: body.url, username: body.username, password: body.password, deviceName: body.deviceName });
|
|
45
45
|
if (r.error) return json(res, 400, { error: r.error });
|
|
@@ -85,11 +85,17 @@ 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
|
-
// 质检 A3
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
88
|
+
// 质检 A3:目录浏览限定基目录,拒绝越界。Windows(CodeArts 报告):家目录覆盖整个用户配置树
|
|
89
|
+
// (AppData 等)——收紧为 桌面/文档/下载 三常用目录 + 启动目录 + 工作目录 + web.browseRoots 显式授权;
|
|
90
|
+
// 路径比较在 win32 下大小写归一(D:\\ vs d:\\ 不再误拒)。
|
|
91
|
+
const base = process.platform === 'win32' && process.env.USERPROFILE
|
|
92
|
+
? [path.join(process.env.USERPROFILE, 'Desktop'), path.join(process.env.USERPROFILE, 'Documents'), path.join(process.env.USERPROFILE, 'Downloads')]
|
|
93
|
+
: [os.homedir()];
|
|
94
|
+
const norm = (/** @type {string} */ x) => (process.platform === 'win32' ? x.toLowerCase() : x);
|
|
95
|
+
const browseRoots = [...base, startupCwd, state.workingDir, ...(Array.isArray(cfg.web?.browseRoots) ? cfg.web.browseRoots : [])].map((r) => norm(path.resolve(String(r))));
|
|
96
|
+
const ndir = norm(dir);
|
|
97
|
+
const inRoot = browseRoots.some((r) => ndir === r || ndir.startsWith(r + path.sep));
|
|
98
|
+
if (!inRoot) return json(res, 403, { error: '目录不在可浏览范围内(授权目录或 web.browseRoots 显式添加)' });
|
|
93
99
|
try {
|
|
94
100
|
const st = fs.statSync(dir);
|
|
95
101
|
if (!st.isDirectory()) return json(res, 400, { error: '不是目录' });
|
|
@@ -101,7 +107,7 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
|
|
|
101
107
|
.slice(0, 300);
|
|
102
108
|
let parent = path.dirname(dir);
|
|
103
109
|
// 父目录同样不得越出基目录
|
|
104
|
-
if (!browseRoots.some((r) => parent === r || parent.startsWith(r + path.sep))) parent = /** @type {any} */ (null);
|
|
110
|
+
if (!browseRoots.some((r) => norm(parent) === r || norm(parent).startsWith(r + path.sep))) parent = /** @type {any} */ (null);
|
|
105
111
|
json(res, 200, { ok: true, path: dir, parent: parent === dir ? null : parent, entries });
|
|
106
112
|
} catch (/** @type {any} */ err) {
|
|
107
113
|
json(res, 400, { error: String(err?.message || err) });
|
package/src/web/server.js
CHANGED
|
@@ -68,11 +68,16 @@ const INDEX_HTML = path.join(path.dirname(fileURLToPath(import.meta.url)), 'inde
|
|
|
68
68
|
function json(res, code, obj) {
|
|
69
69
|
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
70
70
|
res.end(JSON.stringify(obj));
|
|
71
|
+
// 返回 truthy:域路由 `return json(...)` 必须被编排器视为「已处理」——
|
|
72
|
+
// 否则兜底 404 会对已结束的响应二次写入(Node 18 下 write-after-end 未捕获 → 整服务崩溃)
|
|
73
|
+
return true;
|
|
71
74
|
}
|
|
72
75
|
|
|
73
|
-
/**
|
|
74
|
-
|
|
75
|
-
|
|
76
|
+
/** 读取请求体。limit 默认 40MB(附件 base64 上限:4×5MB 图片 + 文本);
|
|
77
|
+
* 普通 JSON 接口必须显式传较小 limit(质检 A4;此前第二参数被忽略 → 1MB 限制形同虚设)。 */
|
|
78
|
+
/** @param {any} req @param {number} [limit] */
|
|
79
|
+
function readBody(req, limit = 40 * 1024 * 1024) {
|
|
80
|
+
const MAX_BODY = limit; // 附件 base64 上限(4×5MB 图片 + 文本)留余量
|
|
76
81
|
return new Promise((resolve, reject) => {
|
|
77
82
|
/** @type {any[]} */
|
|
78
83
|
const chunks = [];
|
|
@@ -89,9 +94,11 @@ function readBody(req) {
|
|
|
89
94
|
req.on('data', (/** @type {any} */ d) => {
|
|
90
95
|
size += d.length;
|
|
91
96
|
if (size > MAX_BODY) {
|
|
92
|
-
const err = /** @type {Error & { status?: number }} */ (new Error(
|
|
97
|
+
const err = /** @type {Error & { status?: number }} */ (new Error(`请求体过大(>${Math.round(MAX_BODY / 1048576)}MB)`));
|
|
93
98
|
err.status = 413;
|
|
94
|
-
|
|
99
|
+
// 排空残余数据但保留连接:让上层 catch 返回真 413(此前 destroy 导致客户端只收到连接重置)
|
|
100
|
+
req.pause();
|
|
101
|
+
req.resume();
|
|
95
102
|
reject(err);
|
|
96
103
|
return;
|
|
97
104
|
}
|
|
@@ -184,15 +191,20 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
184
191
|
const provider = await getProviderFor(modelName);
|
|
185
192
|
const undoStore = { backups: new Map() };
|
|
186
193
|
|
|
187
|
-
// MCP
|
|
194
|
+
// MCP:A2 预热——await 连接(6s 超时);超时本会话冻结工具集(不再中途注入,保护前缀缓存)
|
|
188
195
|
/** @type {any} */
|
|
189
196
|
let mcpManager = null;
|
|
190
197
|
if (cfg.mcpServers && Object.keys(cfg.mcpServers).length) {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
198
|
+
// 超时后输家 promise 仍在跑:迟到就绪的 manager 立即 stop,防 detached 子进程成孤儿(自查 #2)
|
|
199
|
+
const mcpStartP = startMcpServers(cfg.mcpServers, workingDir).catch(() => null);
|
|
200
|
+
mcpManager = await Promise.race([
|
|
201
|
+
mcpStartP,
|
|
202
|
+
new Promise((/** @type {any} */ r) => setTimeout(() => r(null), 6000)),
|
|
203
|
+
]);
|
|
204
|
+
if (!mcpManager) {
|
|
205
|
+
console.error('[MingDao] ⚠ MCP 连接超时(6s):本会话不注入 MCP 工具(重启 mingdao web 可重试)');
|
|
206
|
+
mcpStartP.then((/** @type {any} */ m) => { if (m) m.stop(); });
|
|
207
|
+
}
|
|
196
208
|
}
|
|
197
209
|
const mcpFacade = {
|
|
198
210
|
toolSchemas: () => (mcpManager ? mcpManager.toolSchemas() : []),
|
|
@@ -228,17 +240,23 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
228
240
|
// —— SSRF 防护(质检 S1):远端地址校验 ——
|
|
229
241
|
/** @param {any} hostname */
|
|
230
242
|
function isPrivateHost(hostname) {
|
|
231
|
-
|
|
243
|
+
let h = String(hostname || '').toLowerCase();
|
|
232
244
|
if (!h) return true;
|
|
233
|
-
|
|
245
|
+
h = h.replace(/^\[|\]$/g, ''); // IPv6 字面量去括号(URL('http://[::1]/').hostname 带方括号)
|
|
246
|
+
if (h === 'localhost' || h.endsWith('.localhost') || h === '::1') return true;
|
|
247
|
+
if (h.includes(':')) {
|
|
248
|
+
// IPv6:回环 ::1、链路本地 fe80::/10、唯一本地 fc00::/7、IPv4 映射 ::ffff:私网
|
|
249
|
+
if (/^::ffff:/.test(h)) return isPrivateHost(h.slice(7));
|
|
250
|
+
return /^fe[89ab]/.test(h) || /^f[c d]/.test(h) || h === '::' || h === '::1';
|
|
251
|
+
}
|
|
234
252
|
const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
235
|
-
if (!m) return false; //
|
|
253
|
+
if (!m) return false; // 域名:由 validateRemoteUrl 的 DNS 解析复检
|
|
236
254
|
const a = Number(m[1]);
|
|
237
255
|
const b = Number(m[2]);
|
|
238
256
|
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);
|
|
239
257
|
}
|
|
240
258
|
/** @param {any} raw */
|
|
241
|
-
function validateRemoteUrl(raw) {
|
|
259
|
+
async function validateRemoteUrl(raw) {
|
|
242
260
|
let u;
|
|
243
261
|
try {
|
|
244
262
|
u = new URL(String(raw));
|
|
@@ -247,10 +265,24 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
247
265
|
}
|
|
248
266
|
if (u.protocol !== 'http:' && u.protocol !== 'https:') return { error: '仅支持 http/https 地址' };
|
|
249
267
|
// 威胁模型:本机回环绑定时(默认/桌面版),本机模型服务(如 Ollama)属可信场景,放行;
|
|
250
|
-
// 对外监听(0.0.0.0)时拒绝私网目标——除非显式 allowPrivateEndpoints
|
|
268
|
+
// 对外监听(0.0.0.0)时拒绝私网目标——除非显式 allowPrivateEndpoints。
|
|
269
|
+
// 域名目标:DNS 解析后逐条校验(2026-09-02 加固,防 DNS 重绑定把域名指向内网绕过字面量检查)。
|
|
251
270
|
const serverBoundLocal = isLoopbackHost(host);
|
|
252
|
-
if (!cfg.web?.allowPrivateEndpoints && !serverBoundLocal
|
|
253
|
-
|
|
271
|
+
if (!cfg.web?.allowPrivateEndpoints && !serverBoundLocal) {
|
|
272
|
+
const h = String(u.hostname || '').toLowerCase();
|
|
273
|
+
let blocked = isPrivateHost(h);
|
|
274
|
+
if (!blocked && h && h !== 'localhost' && !/^\d{1,3}(\.\d{1,3}){3}$/.test(h)) {
|
|
275
|
+
try {
|
|
276
|
+
const { lookup } = await import('node:dns/promises');
|
|
277
|
+
const addrs = await lookup(h, { all: true, verbatim: true });
|
|
278
|
+
blocked = addrs.some((/** @type {any} */ a) => isPrivateHost(a.address));
|
|
279
|
+
} catch {
|
|
280
|
+
// DNS 解析失败:保持放行(服务端会在连接时报错);解析成功且指向内网 → 拦截
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (blocked) {
|
|
284
|
+
return { error: `拒绝访问内网/本机地址(${u.hostname})——服务已对外监听,如确需连接可信内网服务,请在 config.json 设 web.allowPrivateEndpoints: true` };
|
|
285
|
+
}
|
|
254
286
|
}
|
|
255
287
|
return { ok: true };
|
|
256
288
|
}
|