mingdao-harness 0.4.0 → 0.4.2
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/CONFIG.md +27 -2
- package/docs/PLAN-v0.4.1.md +65 -0
- package/package.json +1 -1
- package/presets/local-audit.json +2 -2
- package/src/agent.js +50 -13
- package/src/batch.js +4 -3
- package/src/cli.js +9 -1
- package/src/commands/diagnose.js +15 -0
- package/src/commands/repl.js +9 -1
- package/src/context.js +3 -3
- package/src/cost-guard.js +3 -1
- package/src/mcp.js +7 -2
- package/src/memory.js +5 -1
- package/src/permissions.js +4 -2
- package/src/presets.js +21 -0
- package/src/providers/openai-compatible.js +3 -1
- package/src/routing.js +5 -1
- package/src/tools/bash.js +10 -2
- package/src/tools/fetch.js +34 -2
- package/src/tools/fs-tools.js +66 -10
- package/src/tools/git.js +10 -2
- package/src/tools/index.js +54 -22
- package/src/web/app.js +23 -2
- package/src/web/index.html +1 -1
- package/src/web/server.js +10 -2
package/docs/CONFIG.md
CHANGED
|
@@ -111,8 +111,21 @@ WebUI 中每个会话记住自己的工作目录:新会话记录创建时的
|
|
|
111
111
|
}
|
|
112
112
|
```
|
|
113
113
|
|
|
114
|
-
格式与 Claude Code 相同;工具以 `mcp__<服务器>__<工具>` 并入 Agent
|
|
115
|
-
|
|
114
|
+
格式与 Claude Code 相同;工具以 `mcp__<服务器>__<工具>` 并入 Agent 循环。
|
|
115
|
+
|
|
116
|
+
- **只读自动放行需显式授信(v0.4.1 P0)**:带 `readOnlyHint` 的工具默认**不再**自动放行——
|
|
117
|
+
服务器可谎称只读绕过权限确认。只有加了 `"trusted": true` 的服务器,其 `readOnlyHint` 才被
|
|
118
|
+
信任(只读档自动放行、ask 档不询问);未授信服务器的全部工具(含标注只读的)都走权限确认。
|
|
119
|
+
|
|
120
|
+
```json
|
|
121
|
+
{
|
|
122
|
+
"mcpServers": {
|
|
123
|
+
"filesystem": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/你的/目录"], "trusted": true }
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
会话内 `/mcp` 查看状态;`mingdao mcp preset list/add` 一键接入常用服务器。
|
|
116
129
|
|
|
117
130
|
## WebUI 服务器
|
|
118
131
|
|
|
@@ -253,6 +266,18 @@ completion 计费,防止推理吃满上限时空轮白烧)、`compactTrigger
|
|
|
253
266
|
{ "timeout": { "firstTokenMs": 600000, "streamIdleMs": 120000, "totalMs": 1800000 } }
|
|
254
267
|
```
|
|
255
268
|
|
|
269
|
+
### 文件访问边界(v0.4.1 P0 路径穿越防护)
|
|
270
|
+
|
|
271
|
+
read/write/edit/ls/glob/grep/undo 默认限定在**工作目录**内——auto 权限模式下,模型(或被提示注入
|
|
272
|
+
诱导)也无法读 `~/.ssh`、`~/.mingdao/credentials.json`(API Key 明文)等越界文件;`realpath` 逐级
|
|
273
|
+
校验防软链接逃逸。需要访问工作目录外时,显式加白名单:
|
|
274
|
+
|
|
275
|
+
```json
|
|
276
|
+
{ "fsAllowDirs": ["/home/you/projects/shared", "/tmp/build-output"] }
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
白名单目录同样受 `realpath` 校验(目录内的软链接指向白名单外仍拒绝)。
|
|
280
|
+
|
|
256
281
|
## 自定义 Provider 模块(非 OpenAI 兼容协议)
|
|
257
282
|
|
|
258
283
|
在 `~/.mingdao/providers/<name>.mjs` 导出:
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# v0.4.1 规划:安全版本(偿还 v0.4.0 审计安全债)
|
|
2
|
+
|
|
3
|
+
> 依据:`MingDao Harness v0.4.0 完整审计报告.docx`(2026-09-05,源码通读 + 4 项实测复现)。
|
|
4
|
+
> 定调:**暂停新功能,先清零 P0 安全债**——一个能自动执行 shell 的 agent,安全边界优先级高于任何新特性。
|
|
5
|
+
> 纪律:实现后不单独发布,与 v0.4.1 一起发布;等用户在 3820 验收后再发。
|
|
6
|
+
|
|
7
|
+
## 已修复(本批次)
|
|
8
|
+
|
|
9
|
+
### P0 安全(5 项)
|
|
10
|
+
1. **路径穿越防护**(fs-tools.js):read/write/edit/ls/glob/grep/undo 限定工作目录 + `config.fsAllowDirs`
|
|
11
|
+
白名单;`realpath` 逐级校验防软链接逃逸。auto 模式也无法读 ~/.ssh、credentials.json。
|
|
12
|
+
2. **SSRF 302 重定向绕过**(fetch.js):`redirect:'manual'` 手动跟随,每跳重新 isPrivateHost + DNS 复检,
|
|
13
|
+
跳数上限 5。
|
|
14
|
+
3. **权限前缀规则绕过**(permissions.js):黑名单改白名单字符 `[A-Za-z0-9_ ./\\:-]`,单 `&`、重定向
|
|
15
|
+
`< >`、回车 `\r` 及一切 shell 元字符都不匹配前缀规则,回落权限确认。
|
|
16
|
+
4. **预设 permission 提权**(presets.js):新增 `presetPermissionOverride`——预设声明 permission 若
|
|
17
|
+
比当前更宽松(ask→auto 等)则忽略并提示;CLI/REPL/WebUI 三入口统一接入。
|
|
18
|
+
5. **MCP 权限收紧**(mcp.js):`readOnlyHint` 自动放行改为「仅 `mcpServers.<name>.trusted:true` 才信任」,
|
|
19
|
+
未授信服务器谎报只读也走权限确认。
|
|
20
|
+
|
|
21
|
+
### P1 正确性(6 项)
|
|
22
|
+
6. **turnToolCache 跨步去重失效**(agent.js):声明从 while 体内提到 for 轮内——去重跨步生效。
|
|
23
|
+
7. **windowPressure 永不复位**(agent.js):压缩成功后复位,不再每轮 force 压缩持续烧钱。
|
|
24
|
+
8. **mountConfigTools 阻塞事件循环**(tools/index.js):spawnSync → 异步 spawn + Promise。
|
|
25
|
+
9. **batch.js 未跟进 model-caps**:超窗口预检改用 `resolveModelCaps(cfg, model).contextWindow`。
|
|
26
|
+
10. **语义检索无匹配记忆全丢**(memory.js):无共同词时回退最近 N 条(相关性失败 ≠ 记忆不存在)。
|
|
27
|
+
11. **沙箱探测缺陷**(bash.js):`detectSandbox` 从 `--version` 改为最小真实沙箱探测
|
|
28
|
+
(`bwrap --ro-bind / / --tmpfs /tmp true`),容器内 bwrap 不可用则降级 none。
|
|
29
|
+
|
|
30
|
+
### P2 质量(6 项)
|
|
31
|
+
12. 正则笔误 `/^f[c d]/` → `/^f[cd]/`(fetch.js + server.js)。
|
|
32
|
+
13. git.js log/diff 限量实现(log 默认 -n 50、diff 默认 --stat),修注释与实现不符。
|
|
33
|
+
14. cost-guard.js `todayCostWarned` TDZ 前置。
|
|
34
|
+
15. registerTool 增 `readOnly` 选项(进 READONLY_TOOLS,只读档自动放行)。
|
|
35
|
+
16. context.js 幂等守卫死代码移除(回收结果从不回写原 messages,守卫恒 false)。
|
|
36
|
+
17. agent.js reasoning 清洗 O(n²) → 单次遍历 O(n)。
|
|
37
|
+
|
|
38
|
+
### macOS 本地模型「输出截断 / 子代理无反馈」(追加修复,2026-09-05)
|
|
39
|
+
|
|
40
|
+
用户 MacBook 用 mtplx-qwen38-27b(routing.enabled=true)复现,日志 `text=0` 且 `status=done`。两个根因:
|
|
41
|
+
|
|
42
|
+
18. **子代理无反馈**(routing.js):`subagentModel` 在 routing 开启时恒返回 executor
|
|
43
|
+
(deepseek-v4-flash),无 routeTask 的「池外不干预」检查——本地/自定义模型派子代理时,把
|
|
44
|
+
executor 模型名发到本地 baseUrl(8081)→ 服务端不认识 → 400 → 子代理全灭。修复:池外跟随当前模型。
|
|
45
|
+
19. **输出截断/text=0**(agent.js):兜底总结请求此前用 `trimMessages(messages, budget)` 全量历史,
|
|
46
|
+
本地 q8 模型 ≈98k token 的 prefill 逼近/超过 600s 首 token 超时 → 总结请求失败被 `catch {}` 静默吞 →
|
|
47
|
+
返回 text:null。修复:兜底总结改用轻量输入(system + 交付物清单 + 提示,几 k token),慢 prefill
|
|
48
|
+
也能秒出总结;失败不再静默(io.print 提示原因)。
|
|
49
|
+
20. 子代理空输出透出 note 原因(agent.js):不再笼统「(子任务无输出)」,主线程可据此决策。
|
|
50
|
+
21. diagnose 报告新增「当前模型能力」段:本地模型未声明 contextWindow 时显式提示兜底 32k 及其后果
|
|
51
|
+
(输出/预算被压缩),帮用户自诊。
|
|
52
|
+
|
|
53
|
+
## 顺延(明确记录,不强行塞入本批次)
|
|
54
|
+
|
|
55
|
+
- **WebUI 速率限制**:本地优先工具默认回环绑定 + token 认证已挡远程;已有 `MAX_CONCURRENT=8`
|
|
56
|
+
并发上限 + `costGuard` 日费用上限双保险。令牌桶的维度(IP/会话/token)与阈值需产品决策,
|
|
57
|
+
待 v0.4.2 单独设计。
|
|
58
|
+
- **容器环境 CI job**:审计建议加 container 矩阵覆盖 bwrap 不可用路径(沙箱探测修复已从代码层
|
|
59
|
+
缓解,CI 覆盖作为后续工程项)。
|
|
60
|
+
- **省钱机制有效性自检**(/cost 展示各机制实际节省额):依赖更细的分账埋点,顺延。
|
|
61
|
+
|
|
62
|
+
## 验收
|
|
63
|
+
|
|
64
|
+
- smoke 新增路径穿越/SSRF 重定向/权限绕过/预设提权/MCP trusted/语义检索回退等回归断言。
|
|
65
|
+
- 全绿门禁 + strict 0/0 + typecheck;发布前自检;用户 3820 验收后再随 v0.4.1 发布。
|
package/package.json
CHANGED
package/presets/local-audit.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "local-audit",
|
|
3
3
|
"label": "本地模型审计",
|
|
4
|
-
"description": "面向本地/资源受限模型(如 131k 窗口 q8 量化)的只读审计预设:只读工具集 +
|
|
4
|
+
"description": "面向本地/资源受限模型(如 131k 窗口 q8 量化)的只读审计预设:只读工具集 + 只读权限 + 保守参数,配合 v0.3.2 本地自适应长任务不中断",
|
|
5
5
|
"systemPrompt": "你是一名代码审计员。任务是对给定代码库做只读审计:先 ls/glob 摸清结构,再 read/grep 逐文件审查,可用 git 查看历史与 diff、fetch 抓取相关文档。输出审计报告:发现的缺陷按严重度分级,每条给出文件:行号证据;不做任何修改。若上下文紧张,先审查最关键的部分并明示未覆盖区域。",
|
|
6
6
|
"tools": ["read", "ls", "glob", "grep", "skill", "git", "fetch", "todo"],
|
|
7
|
-
"permission": "
|
|
7
|
+
"permission": "readonly",
|
|
8
8
|
"maxRounds": 4,
|
|
9
9
|
"maxOutputTokens": 4096
|
|
10
10
|
}
|
package/src/agent.js
CHANGED
|
@@ -123,7 +123,12 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
123
123
|
const t0 = Date.now();
|
|
124
124
|
const res = await subAgent.runTurn(messages);
|
|
125
125
|
const ms = Date.now() - t0;
|
|
126
|
-
|
|
126
|
+
// v0.4.1:子代理空输出给主线程可用的失败信号(含 note 原因),而非笼统「无输出」——
|
|
127
|
+
// 主智能体据此决定是否重试/换法,而非把子代理静默当作「已完成但没说话」。
|
|
128
|
+
const text =
|
|
129
|
+
res.text ||
|
|
130
|
+
(res.truncated ? '(子任务达到步骤上限,未完成' : '(子任务无输出') +
|
|
131
|
+
(res.note ? ':' + res.note : '') + ')';
|
|
127
132
|
io.print(style(` ↳ 子任务完成(${ms}ms)`, C.magenta));
|
|
128
133
|
return text;
|
|
129
134
|
}
|
|
@@ -209,12 +214,14 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
209
214
|
}
|
|
210
215
|
};
|
|
211
216
|
try {
|
|
217
|
+
// 同回合只读工具去重(Hermes C4):相同 name+args 的只读调用只执行一次,结果复用回填。
|
|
218
|
+
// v0.4.1 P1 修复:turnToolCache 必须声明在 while 之外、for 轮内——此前在 while 体内每步重建,
|
|
219
|
+
// 去重只在本步的多个工具调用间生效,跨步(如先 grep 定位再 read 确认同一文件)完全失效。
|
|
220
|
+
const turnToolCache = new Map();
|
|
212
221
|
for (round = 0; round < maxRounds; round++) {
|
|
213
222
|
steps = 0;
|
|
214
223
|
while (steps < stepLimit) {
|
|
215
224
|
steps += 1;
|
|
216
|
-
// 同回合只读工具去重(Hermes C4):相同 name+args 的只读调用只执行一次,结果复用回填
|
|
217
|
-
const turnToolCache = new Map();
|
|
218
225
|
// 自动压缩(P3-1):预算不足、静默裁剪即将丢弃早期段落时,先用 executor 模型
|
|
219
226
|
// 把被裁段落压成摘要注入,替代「失忆」;失败/不值得时回退普通裁剪。
|
|
220
227
|
if (cfg.autoCompact !== false) {
|
|
@@ -225,7 +232,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
225
232
|
count,
|
|
226
233
|
provider,
|
|
227
234
|
executorModel: subagentModel(cfg, modelName),
|
|
228
|
-
|
|
235
|
+
// 可配置触发线:默认远程 80%;本地模型默认 60% 提前压缩——本地推理内存预算有限,
|
|
236
|
+
// 等到 80% 再压时上下文已累积过多、prefill 与内存双高(v0.4.2 本地模型 507 修复)
|
|
237
|
+
triggerRatio: Number(cfg.compactTrigger) > 0 ? cfg.compactTrigger : (caps.isLocal ? 0.6 : undefined),
|
|
229
238
|
force: windowPressure, // v0.3.2:逼近窗口时强制压缩(忽略最小阈值门槛)
|
|
230
239
|
});
|
|
231
240
|
if (compacted) {
|
|
@@ -240,6 +249,12 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
240
249
|
C.dim
|
|
241
250
|
)
|
|
242
251
|
);
|
|
252
|
+
// v0.4.1 P1 修复:压缩成功后复位 windowPressure——此前永不复位,后续每轮都 force 压缩,
|
|
253
|
+
// 叠加 compact.js force 分支跳过 2000 token 最小阈值,增量段极短也发真实模型调用持续烧钱。
|
|
254
|
+
if (windowPressure) {
|
|
255
|
+
windowPressure = false;
|
|
256
|
+
pressureWarned = false;
|
|
257
|
+
}
|
|
243
258
|
try {
|
|
244
259
|
onCompact?.(messages);
|
|
245
260
|
} catch {}
|
|
@@ -251,14 +266,25 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
251
266
|
// reasoning 回填防护(Kimi P0 修正):带 tool_calls 的 assistant 消息必须「完整」回传
|
|
252
267
|
// reasoning_content(DeepSeek thinking_mode 官方要求:后续请求含 tools 时缺/截断即 400,
|
|
253
268
|
// 多轮工具会话必崩);仅纯文本回复的 reasoning 可裁剪(不回传也不影响)。
|
|
269
|
+
// v0.4.1 P2 修复:单次遍历构建新数组(O(n)),此前循环内每次替换都 sanitized.map 全量重建(O(n²))。
|
|
254
270
|
let sanitized = trimmed;
|
|
255
|
-
|
|
271
|
+
let dirty = false;
|
|
272
|
+
for (let i = 0; i < sanitized.length; i++) {
|
|
273
|
+
const m = sanitized[i];
|
|
256
274
|
const rc = m.reasoning_content;
|
|
257
275
|
if (typeof rc !== 'string' || (Array.isArray(m.tool_calls) && m.tool_calls.length)) continue;
|
|
276
|
+
let replacement = null;
|
|
258
277
|
if (rc.length > 4000) {
|
|
259
|
-
|
|
278
|
+
replacement = `[思考过程已省略(原 ${rc.length} 字)]`;
|
|
260
279
|
} else if (rc.length > 1000) {
|
|
261
|
-
|
|
280
|
+
replacement = rc.slice(-500) + ' …[思考过程已截断]';
|
|
281
|
+
}
|
|
282
|
+
if (replacement !== null) {
|
|
283
|
+
if (!dirty) {
|
|
284
|
+
sanitized = sanitized.slice();
|
|
285
|
+
dirty = true;
|
|
286
|
+
}
|
|
287
|
+
sanitized[i] = { ...m, reasoning_content: replacement };
|
|
262
288
|
}
|
|
263
289
|
}
|
|
264
290
|
|
|
@@ -432,6 +458,10 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
432
458
|
// 其余模式/工具保持串行,避免多个权限对话框交错。事件顺序(start/render/post/回填)不变。
|
|
433
459
|
const READONLY_BATCH = new Set(['read', 'ls', 'glob', 'grep']);
|
|
434
460
|
const canBatch = permission.mode === 'auto';
|
|
461
|
+
// v0.4.2(本地模型 507 memory_refusal 修复):子代理目标为本地模型时,只读子代理不并行——
|
|
462
|
+
// 多路大 prefill 同时冲进本地推理服务会击穿其单进程内存预算(507 memory_refusal),
|
|
463
|
+
// 串行化只读子代理避免并发峰值(只读 read/ls/glob/grep 仍并行,它们不额外触发大 prefill)。
|
|
464
|
+
const subagentIsLocal = resolveModelCaps(cfg, subagentModel(cfg, modelName)).isLocal;
|
|
435
465
|
|
|
436
466
|
// 预检:解析参数 → PreToolUse 钩子 → 权限检查;拒绝/失败只回填不执行(返回 null)
|
|
437
467
|
// task 工具标记 readOnly 时也可并行(评估 A4:只读子代理 Promise.all)
|
|
@@ -584,7 +614,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
584
614
|
const prep = /** @type {any} */ (await prepTool(tc));
|
|
585
615
|
const name = tc.function?.name || '';
|
|
586
616
|
let batchable = canBatch && Boolean(prep) && !prep.isMcp && READONLY_BATCH.has(name);
|
|
587
|
-
if (!batchable && canBatch && Boolean(prep) && name === 'task' && prep.args?.readOnly === true) batchable = true;
|
|
617
|
+
if (!batchable && canBatch && Boolean(prep) && name === 'task' && prep.args?.readOnly === true && !subagentIsLocal) batchable = true;
|
|
588
618
|
batch.push({ prep, batchable });
|
|
589
619
|
i += 1;
|
|
590
620
|
if (!batchable) break;
|
|
@@ -717,13 +747,17 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
717
747
|
stripOrphanCalls();
|
|
718
748
|
// v0.2.8 兜底总结(对齐 DSH):跑满步数/末轮无正文且未中断时,补一次 no-tool 小输出请求,
|
|
719
749
|
// 让任务以「总结文字 + 交付物清单」收尾,而非静默结束;失败则回退旧行为(text:null)。
|
|
750
|
+
// v0.4.1 修复:输入轻量化——此前用 trimMessages(messages, budget) 全量历史,本地 q8 量化模型
|
|
751
|
+
// (prefill ~165 tok/s)≈98k token 的 prefill 逼近/超过 600s 首 token 超时 → 总结请求失败被吞 →
|
|
752
|
+
// 表现为「输出截断/子代理无反馈」。改用 system + 交付物清单 + 提示(几 k token),慢 prefill 也能秒出总结。
|
|
720
753
|
if (!aborted && messages.length) {
|
|
721
754
|
try {
|
|
722
|
-
|
|
723
|
-
const lastMsg = messages[messages.length - 1];
|
|
724
|
-
if (lastMsg.role === 'assistant' && !lastMsg.content && !Array.isArray(lastMsg.tool_calls)) messages.pop();
|
|
755
|
+
const sys = messages.find((/** @type {any} */ m) => m.role === 'system');
|
|
725
756
|
const wrapReq = [
|
|
726
|
-
...
|
|
757
|
+
...(sys ? [sys] : []),
|
|
758
|
+
...(deliverables.length
|
|
759
|
+
? [{ role: 'user', content: '已交付文件:\n' + deliverables.map((/** @type {string} */ f) => `- ${f}`).join('\n') }]
|
|
760
|
+
: []),
|
|
727
761
|
{ role: 'user', content: '(系统提示)任务已执行完毕。请用一段话总结刚才完成的工作,列出交付物(文件路径),并说明遗留问题与后续建议。' },
|
|
728
762
|
];
|
|
729
763
|
currentAc = new AbortController();
|
|
@@ -746,7 +780,10 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
746
780
|
if (wrapRes.text) messages.push({ role: 'assistant', content: wrapRes.text });
|
|
747
781
|
// capHit:本轮因步数上限被迫收尾(任务可能未真正完成)→ 供上层落检查点续跑
|
|
748
782
|
return { text: wrapRes.text || null, reasoning: wrapRes.reasoning || '', usage, steps, finish, truncated: false, aborted: false, capHit: true, durationMs: Date.now() - startedAt, perf: perf() };
|
|
749
|
-
} catch {}
|
|
783
|
+
} catch (/** @type {any} */ err) {
|
|
784
|
+
// v0.4.1:不再静默吞异常——总结失败原因透出,便于定位(此前用户只见「输出截断/无反馈」)
|
|
785
|
+
try { io.print(style(`⚠ 兜底总结失败:${String(err?.message || err)}`, C.yellow)); } catch {}
|
|
786
|
+
}
|
|
750
787
|
}
|
|
751
788
|
return { text: null, reasoning: '', usage, steps, finish, truncated: true, aborted: false, capHit: true, durationMs: Date.now() - startedAt, perf: perf() };
|
|
752
789
|
}
|
package/src/batch.js
CHANGED
|
@@ -13,7 +13,6 @@ import { resolveProviderConfig } from './providers/index.js';
|
|
|
13
13
|
import { estimateBatchCost, BATCH_DISCOUNT } from './pricing.js';
|
|
14
14
|
import { recordCacheStats } from './cachestats.js';
|
|
15
15
|
import { approxTokens } from './context.js';
|
|
16
|
-
import { modelPreset } from './models.js';
|
|
17
16
|
|
|
18
17
|
const DEFAULT_WINDOW = '24h';
|
|
19
18
|
const DEFAULT_ENDPOINT = '/v1/chat/completions';
|
|
@@ -120,8 +119,10 @@ export async function runBatch({ cfg, model, questions, workingDir = process.cwd
|
|
|
120
119
|
if (deduped > 0) onStatus?.(`去重:${deduped} 条重复问题合并(${list.length} → ${unique.length} 条实际提交)`);
|
|
121
120
|
|
|
122
121
|
// —— 省钱 B2:单问超窗口预检(估算输入 + max_tokens 超过模型窗口 95% 即报错,绝不提交烧钱)——
|
|
123
|
-
|
|
124
|
-
|
|
122
|
+
// v0.4.1 P1 修复:接入 resolveModelCaps 取真实 contextWindow(此前用 preset.budgetTokens 当窗口,
|
|
123
|
+
// 本地小模型 32k 窗口会套 128000 默认 → 超窗口预检完全失效,提交后烧钱且失败)。
|
|
124
|
+
const { resolveModelCaps } = await import('./model-caps.js');
|
|
125
|
+
const windowTokens = resolveModelCaps(cfg, model).contextWindow;
|
|
125
126
|
const sysTokens = approxTokens(systemPrompt);
|
|
126
127
|
for (let i = 0; i < unique.length; i++) {
|
|
127
128
|
const est = sysTokens + approxTokens(unique[i]);
|
package/src/cli.js
CHANGED
|
@@ -362,13 +362,21 @@ async function main() {
|
|
|
362
362
|
let presetBlock = '';
|
|
363
363
|
let presetOverlay = /** @type {Record<string, any>} */ ({});
|
|
364
364
|
if (opts.preset) {
|
|
365
|
-
const { loadPreset, presetConfigOverrides, presetSystemBlock, listPresets } = await import('./presets.js');
|
|
365
|
+
const { loadPreset, presetConfigOverrides, presetSystemBlock, presetPermissionOverride, listPresets } = await import('./presets.js');
|
|
366
366
|
activePreset = loadPreset(workingDir, opts.preset);
|
|
367
367
|
if (!activePreset) {
|
|
368
368
|
const names = listPresets(workingDir).map((/** @type {any} */ p) => p.name).join(', ') || '(无可用预设)';
|
|
369
369
|
io.print(style(`⚠ 预设 "${opts.preset}" 不存在。可用:${names}`, C.yellow));
|
|
370
370
|
} else {
|
|
371
371
|
presetOverlay = { ...presetConfigOverrides(activePreset), presetName: activePreset.name };
|
|
372
|
+
// P0(v0.4.1):预设 permission 提权防护——预设不得把 ask/readonly 静默改成 auto
|
|
373
|
+
const permOv = presetPermissionOverride(activePreset, cfg.permission ?? 'ask');
|
|
374
|
+
if (permOv.escalated) {
|
|
375
|
+
delete presetOverlay.permission;
|
|
376
|
+
io.print(style(`⚠ 预设 "${activePreset.name}" 声明 permission=${activePreset.permission} 属提权(当前 ${cfg.permission ?? 'ask'}),已忽略并保持 ${permOv.permission}。`, C.yellow));
|
|
377
|
+
} else if (activePreset.permission !== undefined) {
|
|
378
|
+
presetOverlay.permission = permOv.permission;
|
|
379
|
+
}
|
|
372
380
|
if (!opts.model && presetOverlay.model) modelName = presetOverlay.model;
|
|
373
381
|
presetBlock = presetSystemBlock(activePreset);
|
|
374
382
|
io.print(style(`▣ 已应用智能体预设:${activePreset.name}${activePreset.label ? '(' + activePreset.label + ')' : ''}`, C.cyan));
|
package/src/commands/diagnose.js
CHANGED
|
@@ -52,6 +52,21 @@ export async function handleDiagnose(/** @type {any} */ _cmd, /** @type {any} */
|
|
|
52
52
|
const cfg = loadConfig();
|
|
53
53
|
push(cfg ? redactSensitive(JSON.stringify(cfg, null, 2)) : '(不存在或读取失败)');
|
|
54
54
|
|
|
55
|
+
// v0.4.1:模型能力定位——本地模型未声明 contextWindow 会兜底 32k → maxOutput/budget 偏小,
|
|
56
|
+
// 表现为「输出截断/频繁压缩」。此处显式提示,帮用户自诊。
|
|
57
|
+
if (cfg?.model) {
|
|
58
|
+
push('');
|
|
59
|
+
push('## 当前模型能力');
|
|
60
|
+
try {
|
|
61
|
+
const { resolveModelCaps, safeBudget } = await import('../model-caps.js');
|
|
62
|
+
const caps = resolveModelCaps(cfg, cfg.model);
|
|
63
|
+
push(`- 模型:${cfg.model}(${caps.isLocal ? '本地部署' : '远程'})`);
|
|
64
|
+
push(`- 上下文窗口:${caps.contextWindow}${caps.isLocal && !((cfg.customModels || {})[cfg.model]?.contextWindow) ? '(未在 customModels 声明 contextWindow,按本地兜底 32k——建议声明真实窗口,如 131072,否则输出/预算被压缩)' : ''}`);
|
|
65
|
+
push(`- 最大输出:${caps.maxOutputTokens} · 预算:${safeBudget(cfg, caps)}`);
|
|
66
|
+
push(`- 自动路由:${cfg.routing?.enabled ? '开(planner=' + cfg.routing.planner + ' executor=' + cfg.routing.executor + ')' : '关'}`);
|
|
67
|
+
} catch {}
|
|
68
|
+
}
|
|
69
|
+
|
|
55
70
|
push('');
|
|
56
71
|
push('## 凭证库');
|
|
57
72
|
push(`${redactSensitive(credentialsPath())}:${fs.existsSync(credentialsPath()) ? '已存在(内容不打包)' : '无'}`);
|
package/src/commands/repl.js
CHANGED
|
@@ -316,7 +316,7 @@ export async function runRepl(ctx) {
|
|
|
316
316
|
io.print('已清空上下文(会话文件已同步重置)。');
|
|
317
317
|
} else if (cmd === '/preset') {
|
|
318
318
|
// v0.4.0 Agent Preset:列出/切换声明式智能体预设(工具白名单/权限/参数 + 系统提示定制段)
|
|
319
|
-
const { listPresets, loadPreset, presetConfigOverrides, presetSystemBlock } = await import('../presets.js');
|
|
319
|
+
const { listPresets, loadPreset, presetConfigOverrides, presetSystemBlock, presetPermissionOverride } = await import('../presets.js');
|
|
320
320
|
if (!arg) {
|
|
321
321
|
const ps = listPresets(workingDir);
|
|
322
322
|
if (!ps.length) io.print(style('(无可用预设。目录:<项目>/.mingdao/presets/、~/.mingdao/presets/、内置 presets/)', C.dim));
|
|
@@ -332,6 +332,14 @@ export async function runRepl(ctx) {
|
|
|
332
332
|
const over = presetConfigOverrides(p);
|
|
333
333
|
// 应用覆盖:权限/参数/工具白名单/模型建议——只进 agentCfg(会话级 overlay),
|
|
334
334
|
// 绝不改写 cfg:否则后续 /model、/think 的 saveConfig 会把预设字段持久化进 config.json。
|
|
335
|
+
// P0(v0.4.1):预设 permission 提权防护——不得把 ask/readonly 静默改成 auto
|
|
336
|
+
const permOv = presetPermissionOverride(p, cfg.permission ?? 'ask');
|
|
337
|
+
if (permOv.escalated) {
|
|
338
|
+
delete over.permission;
|
|
339
|
+
io.print(style(`⚠ 预设 "${p.name}" 声明 permission=${p.permission} 属提权(当前 ${cfg.permission ?? 'ask'}),已忽略并保持 ${permOv.permission}。`, C.yellow));
|
|
340
|
+
} else if (p.permission !== undefined) {
|
|
341
|
+
over.permission = permOv.permission;
|
|
342
|
+
}
|
|
335
343
|
agentCfg = { ...cfg, ...over, presetName: p.name };
|
|
336
344
|
if (over.model) await switchToModel(over.model, { silent: true, persist: false });
|
|
337
345
|
presetBlock = presetSystemBlock(p);
|
package/src/context.js
CHANGED
|
@@ -88,9 +88,9 @@ export function trimMessages(/** @type {any} */ messages, /** @type {any} */ bud
|
|
|
88
88
|
if (total <= budget) break;
|
|
89
89
|
const m = out[i];
|
|
90
90
|
const c = typeof m.content === 'string' ? m.content : JSON.stringify(m?.content ?? '');
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
|
|
91
|
+
// 注:无幂等守卫——回收结果只写在投影副本 out 上、从不回写原 messages(trimMessages 是投影,
|
|
92
|
+
// 每次调用从原 messages 重新拷贝),故「已回收」标记不会进入下一次调用的输入,无需守卫。
|
|
93
|
+
// 截断逻辑确定(同输入同输出),字节稳定,不会逐轮嵌套重截(审计 v0.4.1 确认的死代码已移除)。
|
|
94
94
|
let next = null;
|
|
95
95
|
if (m.role === 'tool' && c.length > 40) {
|
|
96
96
|
next = { ...m, content: `[工具 ${m.tool_call_id || ''} 结果摘要:${c.slice(0, 40).replace(/\n/g, ' ')}…(已回收,原 ${c.length} 字)]` };
|
package/src/cost-guard.js
CHANGED
|
@@ -17,6 +17,9 @@ export function costGuardConfig() {
|
|
|
17
17
|
// 质检 M10:按 cache-stats 文件 mtime 缓存统计结果——文件未变直接复用,
|
|
18
18
|
// 避免 Agent 每步对整文件(可能数千行)重复解析与累加。
|
|
19
19
|
let todayCache = { mtime: -1, size: -1, start: 0, sum: 0 };
|
|
20
|
+
// v0.4.1 P2 修复(TDZ):todayCostWarned 提到 todayCost 使用点之前声明——
|
|
21
|
+
// 此前在第 44 行才 let 声明、第 36 行已使用(模块加载顺序碰巧掩盖了风险,循环依赖场景会抛 ReferenceError)。
|
|
22
|
+
let todayCostWarned = false;
|
|
20
23
|
export function todayCost() {
|
|
21
24
|
const start = beijingDayStart().getTime();
|
|
22
25
|
try {
|
|
@@ -40,7 +43,6 @@ export function todayCost() {
|
|
|
40
43
|
return null;
|
|
41
44
|
}
|
|
42
45
|
}
|
|
43
|
-
let todayCostWarned = false;
|
|
44
46
|
|
|
45
47
|
export function costGuardStatus() {
|
|
46
48
|
const g = costGuardConfig();
|
package/src/mcp.js
CHANGED
|
@@ -25,11 +25,12 @@ const HANDSHAKE_TIMEOUT_MS = 20000;
|
|
|
25
25
|
let nextId = 1;
|
|
26
26
|
|
|
27
27
|
export class McpClient {
|
|
28
|
-
constructor(/** @type {any} */ name, /** @type {any} */ { command, args = [], env = {} }, /** @type {any} */ workingDir) {
|
|
28
|
+
constructor(/** @type {any} */ name, /** @type {any} */ { command, args = [], env = {}, trusted = false }, /** @type {any} */ workingDir) {
|
|
29
29
|
this.name = name;
|
|
30
30
|
this.command = command;
|
|
31
31
|
this.args = args;
|
|
32
32
|
this.env = env;
|
|
33
|
+
this.trusted = trusted === true; // v0.4.1 P0:仅 trusted 服务器的 readOnlyHint 才被信任自动放行
|
|
33
34
|
this.workingDir = workingDir;
|
|
34
35
|
this.tools = /** @type {any[]} */ ([]);
|
|
35
36
|
this.child = null;
|
|
@@ -262,7 +263,11 @@ export async function startMcpServers(/** @type {any} */ mcpCfg, /** @type {any}
|
|
|
262
263
|
},
|
|
263
264
|
isReadonly(/** @type {any} */ prefixedName) {
|
|
264
265
|
const found = this.lookup(prefixedName);
|
|
265
|
-
|
|
266
|
+
if (!found) return false;
|
|
267
|
+
// v0.4.1 P0:未授信服务器(未设 mcpServers.<name>.trusted)即使工具标注 readOnlyHint 也不自动放行——
|
|
268
|
+
// 服务器可谎称只读绕过权限确认;只有显式 trusted 才信任其标注。
|
|
269
|
+
if (!found.client.trusted) return false;
|
|
270
|
+
return found.client.isReadonly(found.toolName);
|
|
266
271
|
},
|
|
267
272
|
status() {
|
|
268
273
|
const rows = [];
|
package/src/memory.js
CHANGED
|
@@ -255,7 +255,11 @@ export function retrieveRelevant(/** @type {any[]} */ entries, /** @type {any} *
|
|
|
255
255
|
return { e, score: overlap / union };
|
|
256
256
|
});
|
|
257
257
|
scored.sort((/** @type {any} */ a, /** @type {any} */ b) => b.score - a.score);
|
|
258
|
-
|
|
258
|
+
const hits = scored.filter((/** @type {any} */ s) => s.score > 0).slice(0, topN).map((/** @type {any} */ s) => s.e);
|
|
259
|
+
// v0.4.1 P1 修复:无匹配时回退最近 N 条(entries 按「旧→新」追加,尾部即最新)——此前 filter(score>0)
|
|
260
|
+
// 在 query 与记忆无共同词时返回空数组,项目记忆完全消失(相关性判断失败 ≠ 记忆不存在)。
|
|
261
|
+
if (hits.length) return hits;
|
|
262
|
+
return entries.slice(-topN);
|
|
259
263
|
}
|
|
260
264
|
|
|
261
265
|
export function appendProjectMemory(/** @type {any} */ workingDir, /** @type {any} */ lines) {
|
package/src/permissions.js
CHANGED
|
@@ -31,8 +31,10 @@ function ruleMatches(rule, name, args) {
|
|
|
31
31
|
if (ruleName !== name) return false;
|
|
32
32
|
const want = rule.slice(idx + 1).trim();
|
|
33
33
|
const have = summarize(name, args).trim();
|
|
34
|
-
// bash
|
|
35
|
-
|
|
34
|
+
// bash 前缀规则防链式命令绕过(P0 安全,v0.4.1):白名单字符法——前缀规则仅匹配
|
|
35
|
+
// 单条简单命令(字母数字/空格/下划线/./-/),任何 shell 元字符(&&/||/;|&<>`$()\n\r)都不匹配,
|
|
36
|
+
// 回落权限确认。黑名单枚举永远追不上绕过手法(单 & 后台串联、重定向、\r 等),白名单才可靠。
|
|
37
|
+
if (name === 'bash' && !/^[A-Za-z0-9_ ./\\:-]+$/.test(have)) return false;
|
|
36
38
|
if (want.endsWith('*')) return have.startsWith(want.slice(0, -1));
|
|
37
39
|
return have === want;
|
|
38
40
|
}
|
package/src/presets.js
CHANGED
|
@@ -136,6 +136,7 @@ export function loadPreset(/** @type {any} */ workingDir, /** @type {any} */ nam
|
|
|
136
136
|
/**
|
|
137
137
|
* 预设 → cfg 覆盖:只返回预设声明的参数键(其余键保持调用方当前配置)。
|
|
138
138
|
* tools 单独走 cfg.presetTools(白名单在 agent 的 toolsFor 处生效)。
|
|
139
|
+
* 注意:permission 提权由调用方用 presetPermissionOverride 过滤(防项目级预设提权,见下)。
|
|
139
140
|
*/
|
|
140
141
|
export function presetConfigOverrides(/** @type {any} */ preset) {
|
|
141
142
|
const out = /** @type {Record<string, any>} */ ({});
|
|
@@ -146,6 +147,26 @@ export function presetConfigOverrides(/** @type {any} */ preset) {
|
|
|
146
147
|
return out;
|
|
147
148
|
}
|
|
148
149
|
|
|
150
|
+
// 权限宽松度排序(数值越大越宽松/越危险)。
|
|
151
|
+
const PERM_RANK = /** @type {Record<string, number>} */ ({ readonly: 0, ask: 1, auto: 2 });
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* 预设 permission 提权防护(P0 安全,v0.4.1):预设声明的 permission 不得比当前配置更宽松
|
|
155
|
+
* (如当前 ask → 预设 auto 属提权,忽略并返回当前值)。clone 恶意仓库含 .mingdao/presets/*.json
|
|
156
|
+
* 声明 auto 时,不能静默跳过用户全部确认。返回 { permission, escalated }:escalated=true 表示已拦截提权。
|
|
157
|
+
* @param {any} preset @param {string} currentPermission
|
|
158
|
+
*/
|
|
159
|
+
export function presetPermissionOverride(/** @type {any} */ preset, /** @type {any} */ currentPermission) {
|
|
160
|
+
const want = preset && preset.permission !== undefined ? String(preset.permission) : null;
|
|
161
|
+
if (!want || !(want in PERM_RANK)) return { permission: currentPermission, escalated: false };
|
|
162
|
+
const cur = currentPermission in PERM_RANK ? currentPermission : 'ask';
|
|
163
|
+
if (PERM_RANK[want] > PERM_RANK[cur]) {
|
|
164
|
+
// 提权:忽略预设值,保持当前更保守的权限
|
|
165
|
+
return { permission: cur, escalated: true };
|
|
166
|
+
}
|
|
167
|
+
return { permission: want, escalated: false };
|
|
168
|
+
}
|
|
169
|
+
|
|
149
170
|
/** 预设系统提示定制段(无则空串),插入系统提示 BASE 之后。 */
|
|
150
171
|
export function presetSystemBlock(/** @type {any} */ preset) {
|
|
151
172
|
const s = preset && typeof preset.systemPrompt === 'string' ? preset.systemPrompt.trim() : '';
|
|
@@ -49,8 +49,10 @@ export async function chat(/** @type {any} */ { baseUrl, apiKey, model, messages
|
|
|
49
49
|
const j = JSON.parse(raw);
|
|
50
50
|
if (j?.error?.message) detail = j.error.message;
|
|
51
51
|
} catch {}
|
|
52
|
+
// 本地模型内存不足(507 memory_refusal):给可操作降级提示而非裸状态码(v0.4.2 本地模型 507 修复)
|
|
53
|
+
const hint = res.status === 507 ? '(本地模型内存不足:请压缩上下文、减少并发子任务,或重启模型服务释放内存)' : '';
|
|
52
54
|
/** @type {ApiError} */
|
|
53
|
-
const e = new Error(`[${model}] API 错误 ${res.status}: ${detail}`);
|
|
55
|
+
const e = new Error(`[${model}] API 错误 ${res.status}: ${detail}${hint}`);
|
|
54
56
|
e.status = res.status;
|
|
55
57
|
e.headers = res.headers; // 重试退避读取 Retry-After 用
|
|
56
58
|
throw e;
|
package/src/routing.js
CHANGED
|
@@ -139,9 +139,13 @@ export async function routeTask(/** @type {any} */ { cfg, provider, currentModel
|
|
|
139
139
|
return rc.executor === currentModel ? { model: currentModel, reason: null } : { model: rc.executor, reason: '回退执行模型' };
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
-
// 子代理模型选择:路由开启时固定 executor
|
|
142
|
+
// 子代理模型选择:路由开启时固定 executor——但当前模型不在路由池(如本地/自定义模型)时
|
|
143
|
+
// 必须跟随当前模型,否则子代理会把 executor 模型名(deepseek-v4-flash)发到当前模型的 baseUrl
|
|
144
|
+
// (本地 8081),服务端不认识 → 400 → 子代理全灭,表现为主线程「子代理无反馈」。
|
|
143
145
|
export function subagentModel(/** @type {any} */ cfg, /** @type {any} */ currentModel) {
|
|
144
146
|
const rc = routingConfig(cfg);
|
|
145
147
|
if (!rc) return currentModel;
|
|
148
|
+
// v0.4.1 修复:与 routeTask 的池外检查一致——当前模型不在 planner/executor 池内时不干预
|
|
149
|
+
if (currentModel !== rc.planner && currentModel !== rc.executor) return currentModel;
|
|
146
150
|
return modelPreset(rc.executor) ? rc.executor : currentModel;
|
|
147
151
|
}
|
package/src/tools/bash.js
CHANGED
|
@@ -34,8 +34,16 @@ export function detectSandbox() {
|
|
|
34
34
|
sandboxSupport = 'none';
|
|
35
35
|
if (process.platform === 'linux') {
|
|
36
36
|
try {
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
// v0.4.1 P1 修复:实际能力探测而非仅 --version——bwrap 存在但 /proc 挂载受限(Docker/devcontainer/
|
|
38
|
+
// CI 流水线等容器环境)时 --version 能跑、真正建沙箱却失败。用最小真实沙箱命令验证:
|
|
39
|
+
// --ro-bind / / --tmpfs /tmp true 能成功退出 0 才算可用,否则降级 none(runBash 会注明降级)。
|
|
40
|
+
const v = spawnSync('bwrap', ['--version'], { stdio: 'ignore', timeout: 3000 });
|
|
41
|
+
if (v.error) {
|
|
42
|
+
sandboxSupport = 'none';
|
|
43
|
+
} else {
|
|
44
|
+
const probe = spawnSync('bwrap', ['--die-with-parent', '--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--tmpfs', '/tmp', 'true'], { stdio: 'ignore', timeout: 5000 });
|
|
45
|
+
sandboxSupport = probe.error || probe.status !== 0 ? 'none' : 'bwrap';
|
|
46
|
+
}
|
|
39
47
|
} catch {
|
|
40
48
|
sandboxSupport = 'none';
|
|
41
49
|
}
|
package/src/tools/fetch.js
CHANGED
|
@@ -9,7 +9,7 @@ function isPrivateHost(/** @type {string} */ hostname) {
|
|
|
9
9
|
if (h === 'localhost' || h.endsWith('.localhost') || h === '::1') return true;
|
|
10
10
|
if (h.includes(':')) {
|
|
11
11
|
if (/^::ffff:/.test(h)) return isPrivateHost(h.slice(7));
|
|
12
|
-
return /^fe[89ab]/.test(h) || /^f[
|
|
12
|
+
return /^fe[89ab]/.test(h) || /^f[cd]/.test(h) || h === '::' || h === '::1';
|
|
13
13
|
}
|
|
14
14
|
const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
15
15
|
if (!m) return false;
|
|
@@ -39,10 +39,42 @@ export async function runFetch(/** @type {any} */ args, /** @type {any} */ _ctx)
|
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
if (blocked) return { ok: false, error: `拒绝访问内网/本机地址(${host})——SSRF 防护。` };
|
|
42
|
+
// 302 重定向复检(P0 安全,v0.4.1):redirect:'follow' 只检查初始 URL,攻击者可用公网 302 跳回内网。
|
|
43
|
+
// 改 redirect:'manual' 手动跟随,每一跳重新 isPrivateHost + DNS 复检,跳数上限 5。
|
|
42
44
|
const ac = new AbortController();
|
|
43
45
|
const timer = setTimeout(() => ac.abort(), 15000);
|
|
44
46
|
try {
|
|
45
|
-
|
|
47
|
+
let cur = u;
|
|
48
|
+
let res = /** @type {any} */ (null);
|
|
49
|
+
for (let hop = 0; hop <= 5; hop++) {
|
|
50
|
+
if (hop > 5) return { ok: false, error: '重定向次数超过上限(5 跳)。' };
|
|
51
|
+
const ch = String(cur.hostname || '').toLowerCase();
|
|
52
|
+
let hopBlocked = isPrivateHost(ch);
|
|
53
|
+
if (!hopBlocked && ch && ch !== 'localhost' && !/^\d{1,3}(\.\d{1,3}){3}$/.test(ch)) {
|
|
54
|
+
try {
|
|
55
|
+
const addrs = await lookup(ch, { all: true, verbatim: true });
|
|
56
|
+
hopBlocked = addrs.some((/** @type {any} */ a) => isPrivateHost(a.address));
|
|
57
|
+
} catch {
|
|
58
|
+
// DNS 解析失败:放行,连接阶段会报错
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (hopBlocked) return { ok: false, error: `拒绝访问内网/本机地址(${ch})——SSRF 重定向防护。` };
|
|
62
|
+
res = await fetch(cur, { signal: ac.signal, redirect: 'manual' });
|
|
63
|
+
if (res.status >= 300 && res.status < 400) {
|
|
64
|
+
const loc = res.headers.get('location');
|
|
65
|
+
if (!loc) break; // 无 Location:按最终响应处理
|
|
66
|
+
try {
|
|
67
|
+
cur = new URL(loc, cur);
|
|
68
|
+
} catch {
|
|
69
|
+
return { ok: false, error: `非法重定向地址:${loc}` };
|
|
70
|
+
}
|
|
71
|
+
if (cur.protocol !== 'http:' && cur.protocol !== 'https:') {
|
|
72
|
+
return { ok: false, error: '重定向到非 http(s) 地址,已拒绝。' };
|
|
73
|
+
}
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
46
78
|
const buf = await res.arrayBuffer();
|
|
47
79
|
clearTimeout(timer);
|
|
48
80
|
if (buf.byteLength > 512 * 1024) return { ok: false, error: `响应超过 512KB 上限(实际 ${buf.byteLength} 字节)。` };
|
package/src/tools/fs-tools.js
CHANGED
|
@@ -19,6 +19,53 @@ function resolvePath(cwd, p) {
|
|
|
19
19
|
return path.resolve(cwd, p);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
// —— 路径穿越防护(P0 安全,v0.4.1)——
|
|
23
|
+
// 文件工具限定在「工作目录 + config.fsAllowDirs 白名单」内;realpath 逐级校验防软链接逃逸。
|
|
24
|
+
// auto 模式下模型(或被提示注入诱导)也无法 read ~/.ssh、~/.mingdao/credentials.json 等越界文件。
|
|
25
|
+
// 白名单:config.json 的 fsAllowDirs 数组(绝对目录),需要访问工作目录外时显式添加。
|
|
26
|
+
function normCmp(/** @type {any} */ p) {
|
|
27
|
+
return process.platform === 'win32' ? String(p).toLowerCase() : String(p);
|
|
28
|
+
}
|
|
29
|
+
function realPathOrNull(/** @type {any} */ p) {
|
|
30
|
+
try {
|
|
31
|
+
return normCmp(fs.realpathSync(p));
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// 判断 target 是否在 root 内:对 target 逐级向上找最近的已存在祖先 realpath,再与 root realpath 比较。
|
|
37
|
+
// (write 新文件时父目录可能尚不存在,逐级向上即可正确处理;软链接已被 realpath 展开。)
|
|
38
|
+
function withinRoot(/** @type {any} */ root, /** @type {any} */ target) {
|
|
39
|
+
const rr = realPathOrNull(root);
|
|
40
|
+
if (!rr) return false;
|
|
41
|
+
let cur = target;
|
|
42
|
+
for (let i = 0; i < 64; i++) {
|
|
43
|
+
const rp = realPathOrNull(cur);
|
|
44
|
+
if (rp) return rp === rr || rp.startsWith(rr + path.sep);
|
|
45
|
+
const parent = path.dirname(cur);
|
|
46
|
+
if (parent === cur) return false;
|
|
47
|
+
cur = parent;
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* 解析路径并做工作目录边界检查。
|
|
53
|
+
* @param {any} ctx
|
|
54
|
+
* @param {any} p
|
|
55
|
+
* @returns {{ ok: true, path: string } | { ok: false, error: string }}
|
|
56
|
+
*/
|
|
57
|
+
function boundedPath(/** @type {any} */ ctx, /** @type {any} */ p) {
|
|
58
|
+
const cwd = ctx?.cwd || ctx?.workingDir || process.cwd();
|
|
59
|
+
const raw = resolvePath(cwd, p ?? '');
|
|
60
|
+
if (!raw) return { ok: false, error: '缺少 path 参数。' };
|
|
61
|
+
const allowDirs = Array.isArray(ctx?.cfg?.fsAllowDirs) ? ctx.cfg.fsAllowDirs : [];
|
|
62
|
+
const roots = [cwd, ...allowDirs.map((/** @type {any} */ d) => path.resolve(String(d)))];
|
|
63
|
+
for (const r of roots) {
|
|
64
|
+
if (withinRoot(r, raw)) return { ok: true, path: raw };
|
|
65
|
+
}
|
|
66
|
+
return { ok: false, error: `路径越界(工作目录外):${raw}。如需访问请将目录加入 config.fsAllowDirs 白名单。` };
|
|
67
|
+
}
|
|
68
|
+
|
|
22
69
|
/**
|
|
23
70
|
* @param {any} buf
|
|
24
71
|
*/
|
|
@@ -76,7 +123,7 @@ function backup(ctx, p) {
|
|
|
76
123
|
export function undo(args, ctx) {
|
|
77
124
|
const store = ctx?.undoStore?.backups;
|
|
78
125
|
if (!(store instanceof Map) || !store.size) return { ok: false, error: '没有可撤销的修改。' };
|
|
79
|
-
const p = args.path ?
|
|
126
|
+
const p = args.path ? (() => { const bb = boundedPath(ctx, args.path); return bb.ok ? bb.path : null; })() : null;
|
|
80
127
|
if (p) {
|
|
81
128
|
const list = store.get(p);
|
|
82
129
|
if (!list?.length) return { ok: false, error: `${p} 没有可撤销的修改记录。` };
|
|
@@ -124,8 +171,9 @@ export function invalidateReadCache(p) {
|
|
|
124
171
|
*/
|
|
125
172
|
export function read(args, ctx) {
|
|
126
173
|
try {
|
|
127
|
-
const
|
|
128
|
-
if (!
|
|
174
|
+
const b = boundedPath(ctx, args.path ?? '');
|
|
175
|
+
if (!b.ok) return b;
|
|
176
|
+
const p = b.path;
|
|
129
177
|
const st = fs.statSync(p);
|
|
130
178
|
if (st.isDirectory()) return { ok: false, error: `"${p}" 是目录,请使用 ls 查看。` };
|
|
131
179
|
if (st.size > MAX_FILE_BYTES) {
|
|
@@ -181,8 +229,9 @@ const MAX_WRITE_BYTES = 2 * 1024 * 1024; // write 单次内容上限(防模型
|
|
|
181
229
|
*/
|
|
182
230
|
export function write(args, ctx) {
|
|
183
231
|
try {
|
|
184
|
-
const
|
|
185
|
-
if (!
|
|
232
|
+
const b = boundedPath(ctx, args.path ?? '');
|
|
233
|
+
if (!b.ok) return b;
|
|
234
|
+
const p = b.path;
|
|
186
235
|
const content = String(args.content ?? '');
|
|
187
236
|
if (Buffer.byteLength(content) > MAX_WRITE_BYTES) {
|
|
188
237
|
return { ok: false, error: `内容超过 ${MAX_WRITE_BYTES / 1024 / 1024}MB 上限,请分多次写入。` };
|
|
@@ -222,11 +271,12 @@ function regionAround(text, lineStart, lineCount, context = 2) {
|
|
|
222
271
|
*/
|
|
223
272
|
export function edit(args, ctx) {
|
|
224
273
|
try {
|
|
225
|
-
const
|
|
274
|
+
const b = boundedPath(ctx, args.path ?? '');
|
|
275
|
+
if (!b.ok) return b;
|
|
276
|
+
const p = b.path;
|
|
226
277
|
const oldString = String(args.old_string ?? '');
|
|
227
278
|
const newString = String(args.new_string ?? '');
|
|
228
279
|
const replaceAll = Boolean(args.replace_all);
|
|
229
|
-
if (!p) return { ok: false, error: '缺少 path 参数。' };
|
|
230
280
|
if (!oldString) return { ok: false, error: '缺少 old_string 参数。' };
|
|
231
281
|
try {
|
|
232
282
|
if (fs.statSync(p).size > MAX_FILE_BYTES) {
|
|
@@ -265,7 +315,9 @@ export function edit(args, ctx) {
|
|
|
265
315
|
*/
|
|
266
316
|
export function ls(args, ctx) {
|
|
267
317
|
try {
|
|
268
|
-
const
|
|
318
|
+
const b = boundedPath(ctx, args.path || '.');
|
|
319
|
+
if (!b.ok) return b;
|
|
320
|
+
const p = b.path;
|
|
269
321
|
const entries = fs.readdirSync(p, { withFileTypes: true });
|
|
270
322
|
const rows = entries
|
|
271
323
|
.map((e) => {
|
|
@@ -354,7 +406,9 @@ function walkFiles(root, visitor) {
|
|
|
354
406
|
export function glob(args, ctx) {
|
|
355
407
|
try {
|
|
356
408
|
const pattern = String(args.pattern ?? '*');
|
|
357
|
-
const
|
|
409
|
+
const b = boundedPath(ctx, args.path || '.');
|
|
410
|
+
if (!b.ok) return b;
|
|
411
|
+
const root = b.path;
|
|
358
412
|
if (!fs.existsSync(root)) return { ok: false, error: `目录不存在:${root}` };
|
|
359
413
|
const re = globToRegExp(pattern);
|
|
360
414
|
const matchRel = pattern.includes('/');
|
|
@@ -401,7 +455,9 @@ export function grep(args, ctx) {
|
|
|
401
455
|
const MAX_LINE = 20 * 1024;
|
|
402
456
|
/** @type {(line: any) => any} */
|
|
403
457
|
const testLine = (line) => (line.length > MAX_LINE ? re.test(line.slice(0, MAX_LINE)) : re.test(line));
|
|
404
|
-
const
|
|
458
|
+
const root0 = boundedPath(ctx, args.path || '.');
|
|
459
|
+
if (!root0.ok) return root0;
|
|
460
|
+
const root = root0.path;
|
|
405
461
|
const include = args.include ? globToRegExp(String(args.include)) : null;
|
|
406
462
|
/** @type {any[]} */
|
|
407
463
|
const matches = [];
|
package/src/tools/git.js
CHANGED
|
@@ -14,10 +14,18 @@ export async function runGit(/** @type {any} */ args, /** @type {any} */ ctx) {
|
|
|
14
14
|
if (!GIT_READONLY.has(sub)) {
|
|
15
15
|
return { ok: false, error: `git ${sub} 不是只读子命令(仅支持 ${[...GIT_READONLY].join(' / ')})。写操作请用 bash 并注意授权。` };
|
|
16
16
|
}
|
|
17
|
-
// 追加默认防超大输出:log/diff 限量(除非模型显式给了 -n/--max-count
|
|
17
|
+
// 追加默认防超大输出:log/diff 限量(除非模型显式给了 -n/--max-count)——
|
|
18
|
+
// v0.4.1 P2 修复:此前注释声明限量但无实现,git log -p 在大型仓库会撞 maxBuffer 4MB 报 ENOBUFS。
|
|
19
|
+
let effectiveArgv = argv;
|
|
20
|
+
if ((sub === 'log' || sub === 'shortlog') && !argv.some((/** @type {string} */ a) => /^-(n|\d+)$/.test(a) || a === '--max-count')) {
|
|
21
|
+
effectiveArgv = [...argv, '-n', '50'];
|
|
22
|
+
} else if (sub === 'diff' && !argv.some((/** @type {string} */ a) => a === '--stat')) {
|
|
23
|
+
// diff 全量可能极巨:默认 --stat 概览,模型需要全文再显式加 --no-stat 或指定文件
|
|
24
|
+
effectiveArgv = [...argv, '--stat'];
|
|
25
|
+
}
|
|
18
26
|
const cwd = ctx.workingDir || process.cwd();
|
|
19
27
|
try {
|
|
20
|
-
const { stdout, stderr } = await execFile('git',
|
|
28
|
+
const { stdout, stderr } = await execFile('git', effectiveArgv, {
|
|
21
29
|
cwd,
|
|
22
30
|
timeout: 15000,
|
|
23
31
|
maxBuffer: 4 * 1024 * 1024,
|
package/src/tools/index.js
CHANGED
|
@@ -6,7 +6,7 @@ import { runBash } from './bash.js';
|
|
|
6
6
|
import { runGit } from './git.js';
|
|
7
7
|
import { runFetch } from './fetch.js';
|
|
8
8
|
import { listSkills, loadSkill } from '../skills.js';
|
|
9
|
-
import {
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
10
|
|
|
11
11
|
// 只读工具集合的单一来源:permissions.js 引用此导出,新增只读工具时只需改这里
|
|
12
12
|
export const READONLY_TOOLS = new Set(['read', 'glob', 'grep', 'ls', 'skill', 'git', 'fetch']);
|
|
@@ -254,7 +254,9 @@ const CUSTOM_TOOL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
|
254
254
|
|
|
255
255
|
/**
|
|
256
256
|
* 注册第三方工具(进程级,重复注册同名会报错——避免静默覆盖)。
|
|
257
|
-
*
|
|
257
|
+
* readOnly(v0.4.1 P2 新增):标注后进 READONLY_TOOLS 集合——只读档自动放行、ask 档不询问、
|
|
258
|
+
* 只读子代理可用。默认 false(写类/副作用工具仍走权限询问)。
|
|
259
|
+
* @param {{ name: string, description?: string, parameters?: any, readOnly?: boolean, run: (args: any, ctx: any) => any | Promise<any> }} tool
|
|
258
260
|
*/
|
|
259
261
|
export function registerTool(/** @type {any} */ tool) {
|
|
260
262
|
const name = String(tool?.name ?? '').trim();
|
|
@@ -279,11 +281,18 @@ export function registerTool(/** @type {any} */ tool) {
|
|
|
279
281
|
function: { name, description: String(tool.description || ''), parameters },
|
|
280
282
|
},
|
|
281
283
|
run: tool.run,
|
|
284
|
+
readOnly: tool.readOnly === true,
|
|
282
285
|
};
|
|
283
286
|
customTools.set(name, entry);
|
|
287
|
+
if (tool.readOnly === true) READONLY_TOOLS.add(name);
|
|
284
288
|
return entry.schema;
|
|
285
289
|
}
|
|
286
290
|
|
|
291
|
+
/** 判断第三方工具是否标注只读。 */
|
|
292
|
+
export function isRegisteredToolReadonly(/** @type {any} */ name) {
|
|
293
|
+
return customTools.get(name)?.readOnly === true;
|
|
294
|
+
}
|
|
295
|
+
|
|
287
296
|
/** 已注册的第三方工具名列表。 */
|
|
288
297
|
export function listRegisteredTools() {
|
|
289
298
|
return [...customTools.keys()];
|
|
@@ -318,26 +327,49 @@ export function mountConfigTools(/** @type {any} */ cfg) {
|
|
|
318
327
|
name,
|
|
319
328
|
description: String(e.description || ''),
|
|
320
329
|
parameters: e.parameters,
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
330
|
+
// v0.4.1 P1 修复:异步 spawn 而非 spawnSync——spawnSync 最长阻塞 600s,WebUI 下单个请求
|
|
331
|
+
// 即冻结整个 Node 进程(所有并发会话/权限确认/SSE 流全无响应)。异步执行不阻塞事件循环。
|
|
332
|
+
run: (/** @type {any} */ args, /** @type {any} */ ctx) =>
|
|
333
|
+
new Promise((resolve) => {
|
|
334
|
+
const shell = process.platform === 'win32' ? 'cmd.exe' : '/bin/bash';
|
|
335
|
+
const shellArgs = process.platform === 'win32' ? ['/d', '/s', '/c', e.command] : ['-lc', e.command];
|
|
336
|
+
const timeoutMs = Math.min(Number(e.timeout) > 0 ? Number(e.timeout) : 120, 600) * 1000;
|
|
337
|
+
const child = spawn(shell, shellArgs, {
|
|
338
|
+
cwd: ctx.cwd,
|
|
339
|
+
env: { ...process.env, MINGDAO_TOOL_ARGS: JSON.stringify(args ?? {}) },
|
|
340
|
+
});
|
|
341
|
+
let out = '';
|
|
342
|
+
let err = '';
|
|
343
|
+
let done = false;
|
|
344
|
+
let timedOut = false;
|
|
345
|
+
const cap = (/** @type {any} */ s, /** @type {any} */ d) => {
|
|
346
|
+
const t = s + d;
|
|
347
|
+
return t.length > 20000 * 2 ? t.slice(-20000 * 2) : t;
|
|
348
|
+
};
|
|
349
|
+
const finish = (/** @type {any} */ result) => {
|
|
350
|
+
if (done) return;
|
|
351
|
+
done = true;
|
|
352
|
+
clearTimeout(timer);
|
|
353
|
+
resolve(result);
|
|
354
|
+
};
|
|
355
|
+
const timer = setTimeout(() => {
|
|
356
|
+
timedOut = true;
|
|
357
|
+
try { child.kill('SIGKILL'); } catch {}
|
|
358
|
+
}, timeoutMs);
|
|
359
|
+
child.stdout.on('data', (d) => { out = cap(out, d); });
|
|
360
|
+
child.stderr.on('data', (d) => { err = cap(err, d); });
|
|
361
|
+
child.on('error', (er) => finish({ ok: false, error: `命令执行失败:${String(er?.message || er)}` }));
|
|
362
|
+
child.on('close', (code) => {
|
|
363
|
+
const capped = out.length > 20000 ? out.slice(0, 20000) + `\n…[输出过长已截断,共 ${out.length} 字]` : out;
|
|
364
|
+
finish({
|
|
365
|
+
ok: timedOut ? false : (code ?? 0) === 0,
|
|
366
|
+
exitCode: timedOut ? null : (code ?? null),
|
|
367
|
+
output: (capped || '(无输出)').trim(),
|
|
368
|
+
...(err.trim() ? { stderr: err.trim().slice(0, 4000) } : {}),
|
|
369
|
+
...(timedOut ? { error: `命令执行超时(${Math.round(timeoutMs / 1000)}s),已终止` } : {}),
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
}),
|
|
341
373
|
});
|
|
342
374
|
mountedConfigTools.add(name);
|
|
343
375
|
mounted.push(name);
|
package/src/web/app.js
CHANGED
|
@@ -64,6 +64,7 @@ function initTips() {
|
|
|
64
64
|
const perm = $('#permSel'); if (perm) attachTip(perm, perm.getAttribute('title'));
|
|
65
65
|
const reas = $('#reasoningSel'); if (reas) attachTip(reas, reas.getAttribute('title'));
|
|
66
66
|
const model = $('#modelSel'); if (model) attachTip(model, () => { const o = model.options[model.selectedIndex]; return (o && o.title) ? o.title : '切换模型'; });
|
|
67
|
+
const preset = $('#presetSel'); if (preset) attachTip(preset, () => { const o = preset.options[preset.selectedIndex]; return (o && o.title) ? o.title : '智能体预设:一键切换工具白名单/权限/参数组合(选「无预设」恢复默认)'; });
|
|
67
68
|
const at = $('#attachBtn'); if (at) attachTip(at, at.getAttribute('title'));
|
|
68
69
|
}
|
|
69
70
|
initTips();
|
|
@@ -148,6 +149,7 @@ $('#dirPickOk').onclick = () => { const cb = pickerCb; pickerCb = null; $('#dirM
|
|
|
148
149
|
$('#dirPickNone').onclick = () => { const cb = pickerCb; pickerCb = null; $('#dirModal').style.display = 'none'; if (cb) cb(null); };
|
|
149
150
|
$('#dirPickCancel').onclick = () => { pickerCb = null; $('#dirModal').style.display = 'none'; };
|
|
150
151
|
const chatEl = $('#chat'), input = $('#input'), sendBtn = $('#sendBtn');
|
|
152
|
+
let presetData = []; // v0.4.0 Agent Preset:预设列表缓存(含 description/tools/permission,供下拉提示与选中反馈)
|
|
151
153
|
let activeAiMsg=null, bgRunning=0, curSteps=0, curWorkT0=0, curTools=0, curTasks=0; // 本轮进度(活动条/状态条/轨迹共用)
|
|
152
154
|
let bgTasks=[]; // 后台任务列表快照(chip tooltip 详情用,updateTasksPanel 每 2s 刷新)
|
|
153
155
|
let curPhase='模型推理中'; // 阶段语义(服务端 progress 事件下发)
|
|
@@ -615,6 +617,23 @@ function applyReasoningUI(reasoning){
|
|
|
615
617
|
sel.value = ['off','low','high','max'].includes(effort) ? effort : 'high';
|
|
616
618
|
}
|
|
617
619
|
$('#reasoningSel').onchange=()=>{ applyConfig({reasoningEffort:$('#reasoningSel').value}); };
|
|
620
|
+
// v0.4.0 Agent Preset:选中预设时给出「这是什么 / 覆盖了什么」的即时反馈(否则用户不知道各选项作用)
|
|
621
|
+
$('#presetSel').onchange=()=>{ presetPicked(); };
|
|
622
|
+
function presetPicked(){
|
|
623
|
+
const val=$('#presetSel')?.value||'';
|
|
624
|
+
const p=presetData.find((x)=>x.name===val);
|
|
625
|
+
if(!p){ renderBanner({ text: '已取消预设,恢复默认配置(不注入预设定制段/白名单/权限覆盖)。' }); return; } // 选回「无预设」= 退出预设模式
|
|
626
|
+
const over=[];
|
|
627
|
+
if(Array.isArray(p.tools)) over.push('工具白名单 '+p.tools.length+' 个');
|
|
628
|
+
if(p.permission) over.push('权限 '+p.permission);
|
|
629
|
+
if(p.model) over.push('模型 '+p.model);
|
|
630
|
+
if(p.maxRounds) over.push('maxRounds '+p.maxRounds);
|
|
631
|
+
if(p.maxOutputTokens) over.push('maxOutput '+p.maxOutputTokens);
|
|
632
|
+
if(p.temperature!==undefined) over.push('温度 '+p.temperature);
|
|
633
|
+
if(p.contextBudget) over.push('预算 '+p.contextBudget);
|
|
634
|
+
const summary=over.length?'(覆盖:'+over.join(' · ')+')':'';
|
|
635
|
+
renderBanner({ text: '🧩 已选预设「'+(p.label||p.name)+'」:'+(p.description||'(无描述)')+summary+'。随本次发送生效,其余设置保持不变。' });
|
|
636
|
+
}
|
|
618
637
|
async function init(){
|
|
619
638
|
try{
|
|
620
639
|
const r=await fetch('/api/state',{cache:'no-store'}); const j=await r.json();
|
|
@@ -653,7 +672,8 @@ async function init(){
|
|
|
653
672
|
try{
|
|
654
673
|
// v0.4.0 Agent Preset:加载预设列表进下拉(项目 → 用户 → 内置)
|
|
655
674
|
const pr=await fetch('/api/presets',{cache:'no-store'}).catch(()=>null); const pj=pr?await pr.json():{presets:[]};
|
|
656
|
-
|
|
675
|
+
presetData = Array.isArray(pj.presets) ? pj.presets : [];
|
|
676
|
+
const psel=$('#presetSel'); if(psel&&presetData.length){ for(const p of presetData){ const o=document.createElement('option'); o.value=p.name; o.textContent=p.label+'('+(p.source==='project'?'项目':p.source==='user'?'用户':'内置')+')'; o.title=p.description||p.name; psel.appendChild(o); } }
|
|
657
677
|
}catch(e){}
|
|
658
678
|
try{
|
|
659
679
|
const dr=await fetch('/api/draft?file='+encodeURIComponent(currentSession||''),{cache:'no-store'}); const dj=await dr.json();
|
|
@@ -930,7 +950,8 @@ async function refreshModelsCfg(){
|
|
|
930
950
|
div.innerHTML='<span style="flex:1;min-width:0"><b>'+esc(c.name)+'</b> <span style="color:var(--faint)">'+esc(c.label)+' · '+esc(c.baseUrl)+'</span></span><span style="white-space:nowrap">'+kstate+'</span>';
|
|
931
951
|
const sk=document.createElement('button'); sk.textContent='设Key'; sk.style.cssText='padding:1px 8px;font-size:11px'; sk.onclick=async()=>{ const k=await uiPrompt('API Key('+c.name+'):', null, {hidden:true}); if(k===null) return; fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'setCustomKey',name:c.name,key:k})}).then(rr=>rr.json()).then(jj=>{ if(jj.ok) refreshModelsCfg(); else uiAlert(jj.error||'设置失败'); }); };
|
|
932
952
|
const test=document.createElement('button'); test.textContent='测试'; test.style.cssText='padding:1px 8px;font-size:11px'; test.onclick=async()=>{ test.disabled=true; test.textContent='测试中…'; try{ const rr=await fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'testCustom',name:c.name})}); const jj=await rr.json().catch(()=>({error:'请求失败'})); if(jj.ok){ await uiAlert('✅ 连接成功('+jj.latencyMs+'ms):'+esc(jj.reply||'空回复')); } else { await uiAlert('✖ 连接失败:'+esc(jj.error||'未知错误')); } } finally { test.disabled=false; test.textContent='测试'; } };
|
|
933
|
-
const ed=document.createElement('button'); ed.textContent='修改'; ed.style.cssText='padding:1px 8px;font-size:11px'; ed.onclick=async()=>{ const u=await uiPrompt('API 地址:', c.baseUrl); if(u===null) return; const l=await uiPrompt('标签:', c.label); if(l===null) return;
|
|
953
|
+
const ed=document.createElement('button'); ed.textContent='修改'; ed.style.cssText='padding:1px 8px;font-size:11px'; ed.onclick=async()=>{ const u=await uiPrompt('API 地址:', c.baseUrl); if(u===null) return; const l=await uiPrompt('标签:', c.label); if(l===null) return; // v0.4.1:本地模型上下文窗口/最大输出可在设置里直接改(此前只能改 baseUrl+label,本地模型补 contextWindow 需手改 config.json)
|
|
954
|
+
const cw=await uiPrompt('上下文窗口 tokens(本地模型必填,如 131072;留空=不变)', c.contextWindow||''); if(cw===null) return; const mo=await uiPrompt('最大输出 tokens(可选,如 8192;留空=不变)', c.maxOutputTokens||''); if(mo===null) return; fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'updateCustom',name:c.name,baseUrl:u,label:l,contextWindow:Number(cw)||undefined,maxOutputTokens:Number(mo)||undefined})}).then(rr=>rr.json()).then(jj=>{ if(jj.ok){ refreshModelsCfg(); reloadModels(); } else uiAlert(jj.error||'修改失败'); }); };
|
|
934
955
|
const rm=document.createElement('button'); rm.textContent='删除'; rm.className='danger'; rm.style.cssText='padding:1px 8px;font-size:11px'; rm.onclick=async()=>{ if(!await uiConfirm('删除自定义模型 '+c.name+'?')) return; const rr=await fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'removeCustom',name:c.name})}); const jj=await rr.json().catch(()=>({error:'失败'})); if(jj.ok){ refreshModelsCfg(); reloadModels(); } else uiAlert(jj.error||'删除失败'); };
|
|
935
956
|
div.appendChild(sk); div.appendChild(test); div.appendChild(ed); div.appendChild(rm); cl.appendChild(div);
|
|
936
957
|
}
|
package/src/web/index.html
CHANGED
|
@@ -332,7 +332,7 @@ footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);paddin
|
|
|
332
332
|
<option value="readonly">只读</option>
|
|
333
333
|
</select>
|
|
334
334
|
<select id="modelSel" title="切换模型"></select>
|
|
335
|
-
<select id="presetSel" title="
|
|
335
|
+
<select id="presetSel" title="智能体预设:一键切换工具白名单/权限/参数组合(选「无预设」恢复默认)"><option value="">无预设</option></select>
|
|
336
336
|
<select id="reasoningSel" title="思考模式(推理等级):关=不推理省 token · 低 · 高(默认)· 最高=最强推理" style="display:none">
|
|
337
337
|
<option value="off">关</option>
|
|
338
338
|
<option value="low">低</option>
|
package/src/web/server.js
CHANGED
|
@@ -257,7 +257,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
257
257
|
if (h.includes(':')) {
|
|
258
258
|
// IPv6:回环 ::1、链路本地 fe80::/10、唯一本地 fc00::/7、IPv4 映射 ::ffff:私网
|
|
259
259
|
if (/^::ffff:/.test(h)) return isPrivateHost(h.slice(7));
|
|
260
|
-
return /^fe[89ab]/.test(h) || /^f[
|
|
260
|
+
return /^fe[89ab]/.test(h) || /^f[cd]/.test(h) || h === '::' || h === '::1';
|
|
261
261
|
}
|
|
262
262
|
const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
263
263
|
if (!m) return false; // 域名:由 validateRemoteUrl 的 DNS 解析复检
|
|
@@ -421,10 +421,18 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
421
421
|
let presetBlock = '';
|
|
422
422
|
let chatCfg = cfg;
|
|
423
423
|
if (typeof body.preset === 'string' && body.preset) {
|
|
424
|
-
const { loadPreset, presetConfigOverrides, presetSystemBlock } = await import('../presets.js');
|
|
424
|
+
const { loadPreset, presetConfigOverrides, presetSystemBlock, presetPermissionOverride } = await import('../presets.js');
|
|
425
425
|
chatPreset = loadPreset(taskDir, body.preset);
|
|
426
426
|
if (chatPreset) {
|
|
427
427
|
const over = presetConfigOverrides(chatPreset);
|
|
428
|
+
// P0(v0.4.1):预设 permission 提权防护——不得把 ask/readonly 静默改成 auto
|
|
429
|
+
const permOv = presetPermissionOverride(chatPreset, cfg.permission ?? 'ask');
|
|
430
|
+
if (permOv.escalated) {
|
|
431
|
+
delete over.permission;
|
|
432
|
+
send({ type: 'banner', text: `⚠ 预设 "${chatPreset.name}" 声明 permission=${chatPreset.permission} 属提权(当前 ${cfg.permission ?? 'ask'}),已忽略并保持 ${permOv.permission}。` });
|
|
433
|
+
} else if (chatPreset.permission !== undefined) {
|
|
434
|
+
over.permission = permOv.permission;
|
|
435
|
+
}
|
|
428
436
|
chatCfg = { ...cfg, ...over, presetName: chatPreset.name };
|
|
429
437
|
presetBlock = presetSystemBlock(chatPreset);
|
|
430
438
|
} else {
|