mingdao-harness 0.2.0 → 0.2.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.
@@ -87,8 +87,9 @@
87
87
  - L1/L2/L3/L6/L11/L12/L13 顺手修
88
88
  - 验收:94 组测试 + 38 组 bench 全绿、tsc 0 错误、三平台 CI 过
89
89
 
90
- ### Phase 2 —「结构治理」v0.2.0(≈4–6 天)
91
- 目标:拆巨石、补安全短板,为后续演进铺路。
90
+ ### Phase 2 —「结构治理」v0.2.0(≈4–6 天)✅ 已实施(2026-08-29)
91
+ 实施项:server.js 拆路由模块(src/web/routes/api.js,1299→579 行)、cli.js 命令分发显式化、SPA JS 外置 app.js + CSP 收紧、SSRF 目标校验、inflight 并发、会话写互斥、listen Promise + 桌面端口重试、sync-server 限流加固、API Key 即时生效、更新下载失败可见化。
92
+ (cli.js 的 REPL/worker 进一步拆分与 routes/api.js 按域细分留待 0.2.1 渐进完成。)
92
93
  - H5 server.js 拆 `src/web/routes/*.js`(chat/sessions/config/sync/schedule/skills/workspace/fs-browse 等)+ 共享中间件
93
94
  - M13 cli.js 命令分发重构 + REPL/worker 拆分 + 重复逻辑抽取
94
95
  - M14 index.html JS 外置 `app.js` + CSP 收紧(去 unsafe-inline)+ 图标表/双拉取去重
@@ -0,0 +1,53 @@
1
+ # 下一步迭代规划(v0.2.2 → v0.3.0)
2
+
3
+ 基于四路审计剩余清单、六份评估综合路线与近期反馈收敛。原则不变:每期可独立发布、全绿测试门禁、零依赖不动摇。**待确认后开工。**
4
+
5
+ ## 待办盘点(当前真实剩余)
6
+
7
+ - 审计遗留(QUALITY-REVIEW 清单中未做的 12 项中低危):sync-server devices/meta 互斥、sync login 密码走参数、fs-browse 基目录约束、readBody 按路由分级、draft 单槽、日志双文件 O(n) 重写、桌面崩溃循环护栏、notify 转义、cli 共享助手去重(offpeak/mcpFacade/参数解析)、会话/任务 misc。
8
+ - 结构渐进(0.2.1 标注的渐进项):routes/api.js 按域细分、cli.js REPL/worker 拆分。
9
+ - strict 棘轮:1584 条隐式 any 待消(每版定削减配额,先安全关键模块)。
10
+ - 省钱路线剩余(EVALUATION-SYNTHESIS 中期 6 项):工具 Schema 按需挂载、Batch 输入侧智能、费用二级分账+看板、护栏 downgrade、评测基准扩充(压缩质量集/路由标注集/V4 词表对拍)。
11
+ - 能力演进:跨平台沙箱补位、记忆语义检索、增量上下文。
12
+ - 工程:覆盖率阈值随版本上调、CHANGELOG 历史回填、GitHub Release 附件自动清理(需 PAT)。
13
+
14
+ ## 分期方案
15
+
16
+ ### Phase A「正确性收尾」v0.2.2(约 1–1.5 天)
17
+ 审计遗留全部落地,零新功能:
18
+ 1. sync-server devices/meta 读改写加锁(withFileLockSync 复用)
19
+ 2. `mingdao sync login` 密码改隐藏输入/stdin,拒绝位置参数
20
+ 3. `/api/fs-browse` 限定基目录集合(工作空间根+显式授权),禁止向上越界、不回传绝对路径
21
+ 4. readBody 按路由分级(chat 40MB,其余 1MB)
22
+ 5. draft 按会话维度存储(key=session 而非全局单槽)
23
+ 6. 日志统一工具函数(append+按天滚动+按行截断),桌面/服务端共用口径
24
+ 7. 桌面渲染进程崩溃计数护栏(超 3 次弹窗提示重启)
25
+ 8. notify 结构化 argv(去掉启发式转义)
26
+ 9. cli.js 共享助手抽取(offpeak defer、mcpFacade 构建、run/run-worker 参数解析合一)
27
+ - 验收:全绿 + 新增 fs-browse 越界与限流回归断言。
28
+
29
+ ### Phase B「省钱第二轮」v0.2.3(约 2–3 天)
30
+ 1. **工具 Schema 按需挂载**(每请求省 1–2K tokens):只读阶段只发 read/ls/glob/grep/skill + 本回合已用工具;写类工具首次调用时动态注入
31
+ 2. **Batch 输入侧**:文本哈希去重(结果按 custom_id 回填全部位置)、单问超窗口预检报错、`--max-cost` 上限
32
+ 3. **费用二级分账 + 看板**:cache-stats 增 reasoning/tool 维度与 byTool 累加;WebUI 仪表盘加模型/工具 Top5 与按天折线(零依赖 SVG)
33
+ 4. **护栏 downgrade**:`action:'downgrade'` —— 触顶后自动切 flash + 明确提示(默认仍 warn/block 可选)
34
+ 5. **评测基准扩充**:压缩质量集(5 组长会话样本→压缩→关键事实追问断言)、路由 100 条标注集入 bench
35
+ - 验收:bench 断言数 38→60+;覆盖率阈值上调至 60%。
36
+
37
+ ### Phase C「结构渐进」v0.2.4(约 2 天)
38
+ 1. routes/api.js 按域细分(config/sessions/sync/schedule/skills/workspace/misc 7 模块 + 共享中间件),每域配 API 契约测试
39
+ 2. cli.js REPL → commands/repl.js、worker → tasks/worker.js(e2e-local 16 项全绿做护栏)
40
+ 3. strict 棘轮削 300 条(先 credentials/permissions/config/sync-server/session 五个安全关键模块全量注解)
41
+ - 验收:tsc full 错误数 ≤1284;结构清单在文档登记。
42
+
43
+ ### Phase D「能力演进」v0.3.0(约 3–5 天,可按需裁剪)
44
+ 1. **跨平台沙箱补位**:Windows Job Object + 降权 token(零依赖原生 API)、macOS seatbelt 规则;敏感命令平台无关拦截清单兜底
45
+ 2. **记忆语义检索**:零依赖实现(内存向量 + 余弦/哈希相似度;Node 22 可用 node:sqlite 持久化,Node 18 回退 JSONL 索引),记忆注入从「最近 3 条」升级为「相关 3 条」
46
+ 3. **WebUI 看板扩展**:预算燃尽预测、费用导出 CSV
47
+ 4. GitHub Release 附件自动清理(如你提供 PAT)+ 发布清单文档化
48
+
49
+ ## 需要你拍板的三件事
50
+
51
+ 1. **Phase 顺序**:A→B→C→D 直接推进,还是把 B(省钱)提前到 A 之前?
52
+ 2. **GitHub PAT**:是否提供(用于 Release 附件自动清理与发版自动化)?
53
+ 3. **Phase D 裁剪**:跨平台沙箱与语义检索是否都进 0.3.0,还是只做其一?
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mingdao-harness",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "MingDao Harness —— 开源智能体框架(Agent Harness)。零依赖、开箱即用,针对 DeepSeek-V4 系列优化,开放主流模型接入。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -48,13 +48,18 @@
48
48
  "license": "MIT",
49
49
  "scripts": {
50
50
  "start": "node src/cli.js",
51
- "test": "node test/smoke.js",
51
+ "test": "node test/run-all.mjs",
52
52
  "typecheck": "tsc -p tsconfig.json",
53
53
  "prepublishOnly": "node test/smoke.js && node test/e2e-local.js && node test/e2e-schedule.js",
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",
58
+ "coverage": "node test/run-all.mjs --coverage && node scripts/coverage-report.mjs",
59
+ "typecheck:strict": "node scripts/strict-ratchet.mjs",
60
+ "preversion": "node test/smoke.js && node test/e2e-local.js && npm run bench",
61
+ "version": "node scripts/bump-changelog.mjs",
62
+ "postversion": "git add package.json package-lock.json CHANGELOG.md 2>/dev/null; git commit -m \"chore: release v$npm_package_version\" --no-verify || true"
58
63
  },
59
64
  "devDependencies": {
60
65
  "@types/node": "^26.2.0",
@@ -11,7 +11,7 @@ import fs from 'node:fs';
11
11
  import path from 'node:path';
12
12
  import crypto from 'node:crypto';
13
13
 
14
- export function atomicWriteFileSync(target, data, options = {}) {
14
+ export function atomicWriteFileSync(/** @type {string} */ target, /** @type {string|Buffer} */ data, /** @type {any} */ options = {}) {
15
15
  const dir = path.dirname(target);
16
16
  const tmp = path.join(dir, `.${path.basename(target)}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`);
17
17
  try {
@@ -25,16 +25,16 @@ export function atomicWriteFileSync(target, data, options = {}) {
25
25
  }
26
26
  }
27
27
 
28
- export function atomicWriteJsonSync(target, value, { mode = 0o600 } = {}) {
28
+ export function atomicWriteJsonSync(/** @type {string} */ target, /** @type {any} */ value, { mode = 0o600 } = {}) {
29
29
  atomicWriteFileSync(target, JSON.stringify(value, null, 2) + '\n', { mode });
30
30
  }
31
31
 
32
32
  // 极简互斥锁:O_EXCL 创建 lockfile;持锁期间执行 fn(同步);异常/完成释放。
33
33
  // 进程崩溃遗留的陈旧锁(> staleMs 未更新)自动回收,避免永久卡死。
34
34
  const sleepBuf = new Int32Array(new SharedArrayBuffer(4));
35
- const sleepMs = (ms) => Atomics.wait(sleepBuf, 0, 0, ms);
35
+ const sleepMs = (/** @type {number} */ ms) => Atomics.wait(sleepBuf, 0, 0, ms);
36
36
 
37
- export function withFileLockSync(lockPath, fn, { timeoutMs = 5000, staleMs = 15000 } = {}) {
37
+ export function withFileLockSync(/** @type {string} */ lockPath, /** @type {() => any} */ fn, { timeoutMs = 5000, staleMs = 15000 } = {}) {
38
38
  fs.mkdirSync(path.dirname(lockPath), { recursive: true });
39
39
  const t0 = Date.now();
40
40
  for (;;) {
@@ -53,7 +53,7 @@ export function withFileLockSync(lockPath, fn, { timeoutMs = 5000, staleMs = 150
53
53
  } catch {}
54
54
  }
55
55
  } catch (err) {
56
- if (err.code !== 'EEXIST') throw err;
56
+ if (/** @type {any} */ (err).code !== 'EEXIST') throw err;
57
57
  try {
58
58
  const st = fs.statSync(lockPath);
59
59
  if (Date.now() - st.mtimeMs > staleMs) {
package/src/cli.js CHANGED
@@ -296,45 +296,36 @@ async function main() {
296
296
  }
297
297
  }
298
298
 
299
- // 后台任务 worker(内部入口,由 mingdao run 启动)
300
- if (opts.prompt[0] === 'run-worker') {
301
- const id = opts.prompt[1];
302
- let question = '';
303
- let permission = null;
304
- let model = null;
305
- let offpeak = false;
306
- for (let i = 2; i < opts.prompt.length; i++) {
307
- const a = opts.prompt[i];
308
- if (a === '--permission') permission = opts.prompt[++i];
309
- else if (a === '--model') model = opts.prompt[++i];
310
- else if (a === '--offpeak') offpeak = true;
299
+ // 质检 A9:run/run-worker 共用同一参数解析(此前两份重复且语义微妙不同)
300
+ const parseRunArgs = (argv, { questionFlag = false } = {}) => {
301
+ const out = { question: '', permission: null, model: null, offpeak: false };
302
+ for (let i = 0; i < argv.length; i++) {
303
+ const a = argv[i];
304
+ if (a === '--permission') out.permission = argv[++i];
305
+ else if (a === '--model') out.model = argv[++i];
306
+ else if (a === '--offpeak') out.offpeak = true;
311
307
  else if (a === '--question') {
312
- // 收集到下一个已知 flag 为止(参数顺序:--question <任务> --permission … --model …)
313
308
  const parts = [];
314
- while (i + 1 < opts.prompt.length && !['--permission', '--model', '--offpeak'].includes(opts.prompt[i + 1])) {
315
- parts.push(opts.prompt[++i]);
309
+ while (i + 1 < argv.length && !['--permission', '--model', '--offpeak'].includes(argv[i + 1])) {
310
+ parts.push(argv[++i]);
316
311
  }
317
- question = parts.join(' ');
318
- }
312
+ out.question = parts.join(' ');
313
+ } else if (!out.question) out.question = a;
319
314
  }
315
+ return out;
316
+ };
317
+
318
+ // 后台任务 worker(内部入口,由 mingdao run 启动)
319
+ if (opts.prompt[0] === 'run-worker') {
320
+ const id = opts.prompt[1];
321
+ const { question, permission, model, offpeak } = parseRunArgs(opts.prompt.slice(2));
320
322
  await runWorkerTask(id, question, { permission, model, offpeak });
321
323
  return;
322
324
  }
323
325
 
324
326
  // 后台任务启动:mingdao run "<任务>" [--permission auto] [--model x]
325
327
  if (opts.prompt[0] === 'run') {
326
- const rest = opts.prompt.slice(1);
327
- let question = '';
328
- let permission = null;
329
- let model = null;
330
- let offpeak = false;
331
- for (let i = 0; i < rest.length; i++) {
332
- const a = rest[i];
333
- if (a === '--permission') permission = rest[++i];
334
- else if (a === '--model') model = rest[++i];
335
- else if (a === '--offpeak') offpeak = true;
336
- else if (question === '') question = a;
337
- }
328
+ const { question, permission, model, offpeak } = parseRunArgs(opts.prompt.slice(1));
338
329
  if (!question) {
339
330
  console.log('用法:mingdao run "<任务>" [--permission auto|readonly] [--model 模型名] [--offpeak]');
340
331
  process.exitCode = 1;
@@ -35,21 +35,31 @@ export async function handleSync(cmd, args) {
35
35
  if (sub === 'login') {
36
36
  const username = args[1];
37
37
  if (!username) {
38
- console.log('用法:mingdao sync login <用户名> [密码] [服务器地址](地址默认取已配置项)');
38
+ console.log('用法:mingdao sync login <用户名> [服务器地址] [--insecure](密码将隐藏输入,绝不走命令行参数)');
39
+ process.exitCode = 1;
40
+ return true;
41
+ }
42
+ // 质检 A2:密码绝不接受位置参数(明文出现在 ps/shell history)——隐藏输入或 stdin 单行
43
+ if (args[2] && !String(args[2]).startsWith('http')) {
44
+ console.log('[错误] 出于安全考虑,密码不再支持命令行参数——运行 mingdao sync login <用户名> <地址> 后按提示隐藏输入。');
39
45
  process.exitCode = 1;
40
46
  return true;
41
47
  }
42
48
  const s0 = syncStatus();
43
- const url = args[3] || s0.url;
49
+ const url = args[2] && String(args[2]).startsWith('http') ? args[2] : s0.url;
44
50
  if (!url) {
45
- console.log('缺少服务器地址:mingdao sync login <用户名> [密码] <http(s)://地址>');
51
+ console.log('缺少服务器地址:mingdao sync login <用户名> <http(s)://地址>');
52
+ process.exitCode = 1;
53
+ return true;
54
+ }
55
+ const insecureFlag = args.includes('--insecure');
56
+ let password = await askHidden('密码(至少 8 位):');
57
+ if (!password) {
58
+ console.log('[错误] 未输入密码');
46
59
  process.exitCode = 1;
47
60
  return true;
48
61
  }
49
- let password = args[2];
50
- if (!password) password = await askHidden('密码(至少 8 位):');
51
- const insecureFlag = args[4] === '--insecure' || args[5] === '--insecure';
52
- const r = await syncLogin({ url, username, password, deviceName: args[4] === '--insecure' ? undefined : args[4], insecure: insecureFlag });
62
+ const r = await syncLogin({ url, username, password, deviceName: undefined, insecure: insecureFlag });
53
63
  if (r.error) {
54
64
  console.log('[错误] ' + r.error);
55
65
  process.exitCode = 1;
@@ -0,0 +1,27 @@
1
+ // 统一日志写入器(质检 A6):追加写 + 超限按行截断(原子替换,绝不截断在行中间)。
2
+ // 桌面主进程(appLog)与 Web 服务端(srvlog)共用同一实现,消除双文件口径漂移与 O(n) 全量重写。
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import crypto from 'node:crypto';
6
+
7
+ export function createLogWriter(/** @type {string} */ file, { maxBytes = 512 * 1024 } = {}) {
8
+ return (/** @type {string} */ msg) => {
9
+ try {
10
+ fs.mkdirSync(path.dirname(file), { recursive: true });
11
+ const line = new Date().toISOString() + ' ' + String(msg) + '\n';
12
+ fs.appendFileSync(file, line);
13
+ const st = fs.statSync(file);
14
+ if (st.size > maxBytes) {
15
+ const raw = fs.readFileSync(file, 'utf8');
16
+ const cut = raw.length - maxBytes;
17
+ const nl = raw.indexOf('\n', cut);
18
+ const keep = nl === -1 ? raw.slice(cut) : raw.slice(nl + 1); // 行对齐保留尾部
19
+ const tmp = file + '.' + process.pid + '.' + crypto.randomBytes(3).toString('hex') + '.tmp';
20
+ fs.writeFileSync(tmp, keep);
21
+ fs.renameSync(tmp, file);
22
+ }
23
+ } catch {
24
+ // 日志失败绝不抛错(best-effort)
25
+ }
26
+ };
27
+ }
package/src/notify.js CHANGED
@@ -16,15 +16,15 @@ export function notify(title, message) {
16
16
  child.on('error', () => {});
17
17
  child.unref();
18
18
  } else if (process.platform === 'darwin') {
19
- const safe = text.replace(/[\\"]/g, '\\$&');
20
- const safeT = t.replace(/[\\"]/g, '\\$&');
21
- const child = spawn('osascript', ['-e', `display notification "${safe}" with title "${safeT}"`], { detached: true, stdio: 'ignore' });
19
+ // 质检 A8:osascript 逐参数传递(spawn 不经 shell,无需手工引号转义)
20
+ const child = spawn('osascript', ['-e', 'display notification ' + JSON.stringify(text) + ' with title ' + JSON.stringify(t)], { detached: true, stdio: 'ignore' });
22
21
  child.on('error', () => {});
23
22
  child.unref();
24
23
  } else if (process.platform === 'win32') {
25
- const safe = text.replace(/'/g, "''");
26
- const safeT = t.replace(/'/g, "''");
27
- const child = spawn('powershell', ['-NoProfile', '-Command', `(New-Object -ComObject Wscript.Shell).Popup('${safe}', 10, '${safeT}', 64)`], { detached: true, stdio: 'ignore' });
24
+ // 质检 A8:PowerShell -EncodedCommand(Base64 UTF-16LE)传递,彻底消除引号/元字符问题
25
+ const script = `(New-Object -ComObject Wscript.Shell).Popup(${JSON.stringify(text)}, 10, ${JSON.stringify(t)}, 64)`;
26
+ const enc = Buffer.from(script, 'utf16le').toString('base64');
27
+ const child = spawn('powershell', ['-NoProfile', '-NonInteractive', '-EncodedCommand', enc], { detached: true, stdio: 'ignore' });
28
28
  child.on('error', () => {});
29
29
  child.unref();
30
30
  }
@@ -201,6 +201,21 @@ function findDeviceByToken(token) {
201
201
  }
202
202
 
203
203
  // ---------- 业务 ----------
204
+ // 质检 A1:通用写互斥(注册/pair/lastSeen/meta 更新串行化),防 devices.json/meta.json 读改写竞态
205
+ let writeLock = null;
206
+ async function withWriteLock(fn) {
207
+ const prev = writeLock || Promise.resolve();
208
+ let release;
209
+ writeLock = new Promise((resolve) => {
210
+ release = resolve;
211
+ });
212
+ await prev;
213
+ try {
214
+ return await fn();
215
+ } finally {
216
+ release();
217
+ }
218
+ }
204
219
  let registerLock = null;
205
220
  async function doRegister(body) {
206
221
  const username = String(body.username || '').trim();
@@ -232,7 +247,7 @@ async function doRegister(body) {
232
247
  }
233
248
  }
234
249
 
235
- function doPair(body) {
250
+ async function doPair(body) {
236
251
  const username = String(body.username || '').trim();
237
252
  const password = String(body.password || '');
238
253
  const deviceName = String(body.deviceName || '').trim().slice(0, 60) || '未命名设备';
@@ -240,15 +255,17 @@ function doPair(body) {
240
255
  const u = users[username];
241
256
  if (!u) return { notFound: '用户不存在(请先注册)' };
242
257
  if (!verifyPassword(password, u.salt, u.hash)) return { unauthorized: '密码错误' };
243
- const deviceId = crypto.randomBytes(8).toString('hex');
244
- const token = crypto.randomBytes(24).toString('hex');
245
- const devices = readJson(devicesFile(), {});
246
- devices[username] = devices[username] || {};
247
- devices[username][deviceId] = { name: deviceName, tokenHash: sha(token), createdAt: Date.now(), lastSeen: Date.now() };
248
- writeJson(devicesFile(), devices);
249
- invalidateDeviceCache();
250
- log('pair', username, deviceName, deviceId);
251
- return { ok: true, username, deviceId, deviceName, token };
258
+ return withWriteLock(() => {
259
+ const deviceId = crypto.randomBytes(8).toString('hex');
260
+ const token = crypto.randomBytes(24).toString('hex');
261
+ const devices = readJson(devicesFile(), {});
262
+ devices[username] = devices[username] || {};
263
+ devices[username][deviceId] = { name: deviceName, tokenHash: sha(token), createdAt: Date.now(), lastSeen: Date.now() };
264
+ writeJson(devicesFile(), devices);
265
+ invalidateDeviceCache();
266
+ log('pair', username, deviceName, deviceId);
267
+ return { ok: true, username, deviceId, deviceName, token };
268
+ });
252
269
  }
253
270
 
254
271
  function doListSessions(username) {
@@ -280,10 +297,12 @@ function doPush(username, body) {
280
297
  const tmp = file + '.tmp';
281
298
  fs.writeFileSync(tmp, content, { mode: 0o600 });
282
299
  fs.renameSync(tmp, file);
283
- const meta = readJson(metaFile(username), {});
284
- meta[name] = { mtime: Date.now(), size: Buffer.byteLength(content) };
285
- writeJson(metaFile(username), meta);
286
- return { ok: true, name, mtime: meta[name].mtime, size: meta[name].size };
300
+ return withWriteLock(() => {
301
+ const meta = readJson(metaFile(username), {});
302
+ meta[name] = { mtime: Date.now(), size: Buffer.byteLength(content) };
303
+ writeJson(metaFile(username), meta);
304
+ return { ok: true, name, mtime: meta[name].mtime, size: meta[name].size };
305
+ });
287
306
  }
288
307
 
289
308
  function doPull(username, name) {
@@ -307,10 +326,12 @@ function doDelete(username, name) {
307
326
  } catch {
308
327
  return { notFound: '会话不存在' };
309
328
  }
310
- const meta = readJson(metaFile(username), {});
311
- delete meta[name];
312
- writeJson(metaFile(username), meta);
313
- return { ok: true, name };
329
+ return withWriteLock(() => {
330
+ const meta = readJson(metaFile(username), {});
331
+ delete meta[name];
332
+ writeJson(metaFile(username), meta);
333
+ return { ok: true, name };
334
+ });
314
335
  }
315
336
 
316
337
  // ---------- 密码修改 ----------
@@ -410,23 +431,25 @@ function doShareAccept(username, shareId) {
410
431
  fs.mkdirSync(sessionsDir(username), { recursive: true, mode: 0o700 });
411
432
  atomicWriteFileSync(target, content, { mode: 0o600 }); // 质检 H4
412
433
  }
413
- const meta = readJson(metaFile(username), {});
414
- meta[savedAs] = { mtime: Date.now(), size: Buffer.byteLength(content) };
415
- writeJson(metaFile(username), meta);
416
- accepted[username] = accepted[username] || {};
417
- accepted[username][shareId] = { owner: s.owner, name: s.name, savedAs, acceptedAt: Date.now(), copyHash: sha(content) };
418
- writeJson(acceptedFile(), accepted);
419
- shares[shareId].pulls = (shares[shareId].pulls || 0) + 1;
420
- writeJson(sharesFile(), shares);
421
- log('share-accept', username, shareId, savedAs, conflict ? '(conflict-copy)' : '(refresh)');
422
- return {
423
- ok: true,
424
- shareId,
425
- savedAs,
426
- conflict,
427
- content,
428
- mtime: meta[savedAs]?.mtime || Date.now(),
429
- };
434
+ return withWriteLock(() => {
435
+ const meta = readJson(metaFile(username), {});
436
+ meta[savedAs] = { mtime: Date.now(), size: Buffer.byteLength(content) };
437
+ writeJson(metaFile(username), meta);
438
+ accepted[username] = accepted[username] || {};
439
+ accepted[username][shareId] = { owner: s.owner, name: s.name, savedAs, acceptedAt: Date.now(), copyHash: sha(content) };
440
+ writeJson(acceptedFile(), accepted);
441
+ shares[shareId].pulls = (shares[shareId].pulls || 0) + 1;
442
+ writeJson(sharesFile(), shares);
443
+ log('share-accept', username, shareId, savedAs, conflict ? '(conflict-copy)' : '(refresh)');
444
+ return {
445
+ ok: true,
446
+ shareId,
447
+ savedAs,
448
+ conflict,
449
+ content,
450
+ mtime: meta[savedAs]?.mtime || Date.now(),
451
+ };
452
+ });
430
453
  }
431
454
 
432
455
  // ---------- 路由 ----------
@@ -457,7 +480,7 @@ async function handle(req, res) {
457
480
  const body = await parseBody(req);
458
481
  if (body.__error) return json(res, 400, { error: 'JSON 解析失败' });
459
482
  if (rateLimited(req, 5, String(body.username || '').toLowerCase())) return json(res, 429, { error: '该用户名的尝试过于频繁,请稍后再试' });
460
- const r = doPair(body);
483
+ const r = await doPair(body);
461
484
  if (r.notFound) return json(res, 404, r);
462
485
  if (r.unauthorized) return json(res, 401, r);
463
486
  return json(res, 200, r);
@@ -469,11 +492,14 @@ async function handle(req, res) {
469
492
  const nowSeen = Date.now();
470
493
  if (nowSeen - (dev.device.lastSeen || 0) > 60000) {
471
494
  dev.device.lastSeen = nowSeen;
472
- const devices = readJson(devicesFile(), {});
473
- if (devices[dev.username]?.[dev.deviceId]) {
474
- devices[dev.username][dev.deviceId] = dev.device;
475
- writeJson(devicesFile(), devices);
476
- }
495
+ // 质检 A1:与 pair 的 devices 写入互斥(60s 节流只降频,不消除竞态——加锁消除)
496
+ withWriteLock(() => {
497
+ const devices = readJson(devicesFile(), {});
498
+ if (devices[dev.username]?.[dev.deviceId]) {
499
+ devices[dev.username][dev.deviceId] = dev.device;
500
+ writeJson(devicesFile(), devices);
501
+ }
502
+ });
477
503
  }
478
504
 
479
505
  if (req.method === 'POST' && p === '/api/devices') {
@@ -492,7 +518,7 @@ async function handle(req, res) {
492
518
  if (req.method === 'POST' && p === '/api/sessions/push') {
493
519
  const body = await parseBody(req);
494
520
  if (body.__error) return json(res, 400, { error: 'JSON 解析失败' });
495
- const r = doPush(dev.username, body);
521
+ const r = await doPush(dev.username, body);
496
522
  if (r.error) return json(res, 400, r);
497
523
  return json(res, 200, r);
498
524
  }
@@ -504,7 +530,7 @@ async function handle(req, res) {
504
530
  }
505
531
  if (req.method === 'POST' && p === '/api/sessions/delete') {
506
532
  const body = await parseBody(req);
507
- const r = doDelete(dev.username, String(body.name || '').trim());
533
+ const r = await doDelete(dev.username, String(body.name || '').trim());
508
534
  if (r.notFound) return json(res, 404, r);
509
535
  return json(res, 200, r);
510
536
  }
@@ -536,7 +562,7 @@ async function handle(req, res) {
536
562
  if (req.method === 'POST' && p === '/api/share/accept') {
537
563
  if (rateLimited(req, 10)) return json(res, 429, { error: '尝试过于频繁,请稍后再试' });
538
564
  const body = await parseBody(req);
539
- const r = doShareAccept(dev.username, String(body.shareId || '').trim());
565
+ const r = await doShareAccept(dev.username, String(body.shareId || '').trim());
540
566
  if (r.error) return json(res, 400, r);
541
567
  if (r.notFound) return json(res, 404, r);
542
568
  return json(res, 200, r);
package/src/web/app.js CHANGED
@@ -423,7 +423,7 @@ function renderWorkStatus(){
423
423
  const secs=curWorkT0?Math.round((Date.now()-curWorkT0)/1000):0;
424
424
  html='<span class="ws-busy"><span class="spinner"></span>⏳ '+curSteps+' 步 · '+Math.floor(secs/60)+' 分 '+Math.round(secs%60)+' 秒 · '+curTools+' 工具步</span>';
425
425
  } else if(bgRunning>0){
426
- html='<span class="ws-bg">🛠 '+bgRunning+' 个后台任务运行中 — 点击查看</span>';
426
+ html='<span class="ws-bg">🛠 '+bgRunning+' 个后台任务运行中(下载/定时任务等)— 点击查看,完成后会自动提示</span>';
427
427
  }
428
428
  el.style.display=html?'flex':'none';
429
429
  el.innerHTML=html;
@@ -481,10 +481,8 @@ function openTraj(msg){
481
481
  list.appendChild(div);
482
482
  }
483
483
  $('#trajPanel').style.display='flex';
484
- $('#tasksPanel').style.display='none';
485
- $('#subPanel').style.display='none';
484
+ $('#tasksPanel').style.display='none'; // 仅任务面板(右侧)与轨迹互斥;子代理面板可与轨迹同显
486
485
  $('#trajRailBtn').classList.add('on');
487
- $('#subRailBtn').classList.remove('on');
488
486
  }
489
487
  function resultText(r){
490
488
  if(r==null||r===undefined) return '';
@@ -529,7 +527,7 @@ const toggleSubPanel=()=>{
529
527
  const willShow=p.style.display==='none';
530
528
  p.style.display=willShow?'flex':'none';
531
529
  $('#subRailBtn').classList.toggle('on', willShow);
532
- if(willShow){ $('#tasksPanel').style.display='none'; $('#trajPanel').style.display='none'; $('#trajRailBtn').classList.remove('on'); renderSubPanel(); }
530
+ if(willShow){ $('#tasksPanel').style.display='none'; renderSubPanel(); } // 轨迹(左侧)可与子代理(右侧)同显
533
531
  };
534
532
  $('#subRailBtn').onclick=toggleSubPanel;
535
533
  $('#sbClose').onclick=()=>{ $('#subPanel').style.display='none'; $('#subRailBtn').classList.remove('on'); };
@@ -538,10 +536,21 @@ function setBtn(){
538
536
  sendBtn.textContent = generating ? '■ 停止' : '发送';
539
537
  sendBtn.className = generating ? 'danger' : 'primary';
540
538
  }
539
+ const lastBgStatus = new Map(); // 后台任务状态跟踪(完成/失败转换 → 聊天横幅反馈)
541
540
  async function updateTasksPanel(){
542
541
  const r=await fetch('/api/tasks').catch(()=>null); if(!r) return;
543
542
  const j=await r.json(); const list=$('#tpList'); list.innerHTML='';
544
- bgRunning=Number(j.running)||0; renderWorkStatus();
543
+ // 质检(等待状态静默):后台任务(worker/调度)并入计数与面板;状态转换时在聊天区弹可见横幅
544
+ bgRunning=(Number(j.running)||0)+(Number(j.bgRunning)||0);
545
+ for(const t of (j.background||[])){
546
+ const prev=lastBgStatus.get(t.id);
547
+ if(prev && prev.status==='running' && t.status!=='running'){
548
+ if(t.status==='done') renderBanner({text:'✅ 后台任务「'+esc(String(t.message||'').slice(0,30)||'任务')+'」已完成'+(t.durationMs!=null?'(用时 '+(t.durationMs/1000).toFixed(1)+' 秒)':'')});
549
+ else if(t.status==='failed') renderBanner({text:'✖ 后台任务「'+esc(String(t.message||'').slice(0,30)||'任务')+'」失败:'+esc(String(t.error||'').slice(0,60))});
550
+ }
551
+ lastBgStatus.set(t.id,{status:t.status});
552
+ }
553
+ renderWorkStatus();
545
554
  $('#tpCount').textContent='('+j.running+' 运行中 / 上限 '+j.maxConcurrent+')';
546
555
  // 审计(第二问无反应修复):generating 改为「本轮在途」本地状态,由 send/finally 维护,
547
556
  // 不再由 /api/tasks 轮询推导——任务面板瞬时波动或一次轮询失败曾让按钮永久卡在停止态,
@@ -554,6 +563,14 @@ async function updateTasksPanel(){
554
563
  if(t.status==='running'){ const b=document.createElement('button'); b.textContent='中断'; b.className='danger'; b.style.cssText='margin-left:auto;padding:2px 8px;font-size:11px'; b.onclick=()=>fetch('/api/abort',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({taskId:t.id})}).catch(()=>{}); div.querySelector('.tp-meta').appendChild(b); }
555
564
  list.appendChild(div);
556
565
  }
566
+ for(const t of (j.background||[])){
567
+ const div=document.createElement('div'); div.className='tp-item';
568
+ const dot = t.status==='running'?'tp-run':(t.status==='done'?'tp-done':'tp-bad');
569
+ const secs = t.status==='running'?'…':(t.durationMs!=null?' '+(t.durationMs/1000).toFixed(1)+'s':'');
570
+ const kindLabel = t.kind==='schedule'?'⏰ ':(t.kind==='worker'?'🛠 ':'');
571
+ div.innerHTML='<div class="tp-title">'+kindLabel+esc(t.message||t.id||'任务')+'</div><div class="tp-meta"><span class="tp-dot '+dot+'"></span>'+t.status+secs+'</div>';
572
+ list.appendChild(div);
573
+ }
557
574
  }
558
575
  setInterval(updateTasksPanel, 2000);
559
576
  // 费用徽标:今日费用 / 缓存命中率 / 护栏(15s 刷新)
@@ -617,7 +634,7 @@ async function init(){
617
634
  refreshSessions();
618
635
  refreshWsSel();
619
636
  try{
620
- const dr=await fetch('/api/draft',{cache:'no-store'}); const dj=await dr.json();
637
+ const dr=await fetch('/api/draft?file='+encodeURIComponent(currentSession||''),{cache:'no-store'}); const dj=await dr.json();
621
638
  if(dj.text){ input.value=dj.text; input.style.height=Math.min(input.scrollHeight,200)+'px'; input.focus(); }
622
639
  }catch(e){}
623
640
  }
@@ -856,9 +873,10 @@ async function refreshModelsCfg(){
856
873
  const kstate=c.keyState==='stored'?'<span style="color:var(--accent)">'+esc(c.keyMasked||'')+'</span>':'<span style="color:var(--err)">无Key</span>';
857
874
  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>';
858
875
  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||'设置失败'); }); };
876
+ 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='测试'; } };
859
877
  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; fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'updateCustom',name:c.name,baseUrl:u,label:l})}).then(rr=>rr.json()).then(jj=>{ if(jj.ok){ refreshModelsCfg(); reloadModels(); } else uiAlert(jj.error||'修改失败'); }); };
860
878
  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||'删除失败'); };
861
- div.appendChild(sk); div.appendChild(ed); div.appendChild(rm); cl.appendChild(div);
879
+ div.appendChild(sk); div.appendChild(test); div.appendChild(ed); div.appendChild(rm); cl.appendChild(div);
862
880
  }
863
881
  $('#baseUrlOverride').value=j.baseUrlOverride||'';
864
882
  }
@@ -43,11 +43,8 @@ header select{flex:none}
43
43
  /* 输入框上方工作状态条(审计:长任务静默硬伤——实时显示执行步数/耗时与后台任务数)。
44
44
  与输入框同宽对齐(复用 #composer 的居中容器),不再横贯整个聊天窗口 */
45
45
  #workStatus{max-width:860px;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}
46
- /* 顶部常驻活动条(静默深度优化):与输入框同列、生成期间常驻——即使滚动离开消息区也始终可见 */
47
- #liveBar{position:sticky;top:0;z-index:20;max-width:860px;margin:0 auto 8px;display:flex;align-items:center;gap:10px;padding:8px 16px;background:linear-gradient(135deg,rgba(61,220,151,.12),rgba(34,184,207,.12));border:1px solid rgba(61,220,151,.45);border-radius:12px;font-size:13px;color:var(--text);box-shadow:0 2px 14px rgba(0,0,0,.25)}
48
- #liveBar .spinner{margin-right:2px}
49
- #liveBar .lb-phase{color:var(--accent2);font-weight:600;white-space:nowrap}
50
- #liveBar .lb-stat{color:var(--dim);white-space:nowrap}
46
+ /* 状态条 busy 行:承载完整阶段信息(合并原顶部活动条),与输入框同列不遮挡聊天区 */
47
+ #workStatus .ws-phase{color:var(--accent2);font-weight:600;white-space:nowrap}
51
48
  /* 左侧轨迹固定栏 + 右侧子代理面板(布局重构:轨迹在左、子代理在右) */
52
49
  #trajRail{position:fixed;left:0;top:52px;bottom:0;width:42px;background:var(--bg2);border-right:1px solid var(--border);z-index:35;display:flex;flex-direction:column;align-items:center;padding-top:10px;gap:10px}
53
50
  #trajRail .rail-btn{width:34px;height:34px;border-radius:10px;border:1px solid var(--border);background:var(--bg3);cursor:pointer;font-size:15px;display:flex;align-items:center;justify-content:center}
@@ -73,7 +70,7 @@ header select{flex:none}
73
70
  .trajBtn{cursor:pointer;background:var(--bg3);border:1px solid var(--border);color:var(--dim);border-radius:20px;padding:2px 10px;font-size:11.5px}
74
71
  .trajBtn:hover{color:var(--text);border-color:var(--accent2)}
75
72
  /* 轨迹面板(与任务面板互斥展示):置于聊天窗口左侧(DSH 式侧栏) */
76
- #trajPanel{position:fixed;left:42px;top:52px;bottom:0;width:330px;background:var(--bg2);border-right:1px solid var(--border);z-index:40;display:flex;flex-direction:column;box-shadow:4px 0 24px rgba(0,0,0,.35)}
73
+ #trajPanel{position:fixed;left:42px;top:52px;bottom:0;width:252px;background:var(--bg2);border-right:1px solid var(--border);z-index:40;display:flex;flex-direction:column;box-shadow:4px 0 24px rgba(0,0,0,.35)}
77
74
  .tj-head{padding:12px 14px;font-size:14px;font-weight:600;border-bottom:1px solid var(--border);color:var(--accent2);display:flex;align-items:center;gap:8px}
78
75
  .tj-head button{margin-left:auto}
79
76
  #tjList{flex:1;overflow-y:auto;padding:8px}
@@ -240,7 +237,7 @@ footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);paddin
240
237
  <div class="tp-head">任务面板 <span id="tpCount"></span><button id="tpClose" style="float:right">✕</button></div>
241
238
  <div id="tpList"></div>
242
239
  </aside>
243
- <main><div id="liveBar" style="display:none"></div><div id="chat"></div></main>
240
+ <main><div id="chat"></div></main>
244
241
  <footer>
245
242
  <div id="workStatus" style="display:none"></div>
246
243
  <input type="file" id="fileInput" accept="image/png,image/jpeg,image/gif,image/webp,.txt,.md,.json,.js,.py,.log,.csv,.html,.css" multiple style="display:none">
@@ -1,6 +1,7 @@
1
1
  import http from 'node:http';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
+ import os from 'node:os';
4
5
  import crypto from 'node:crypto';
5
6
  import { fileURLToPath } from 'node:url';
6
7
  import { ensureHome, loadConfig, saveConfig, mingdaoHome } from '../../config.js';
@@ -39,6 +40,7 @@ import {
39
40
  reconcileSchedules,
40
41
  } from '../../schedule.js';
41
42
  import { enableAutostart, disableAutostart, autostartStatus } from '../../autostart.js';
43
+ import { listTasks } from '../../tasks.js';
42
44
  import { currentWorkspace, workspaceForDir, listWorkspaces, addWorkspace, removeWorkspace, renameWorkspace, setWorkspaceDir, workspacePath, touchWorkspace, getSessionWorkspace, setSessionWorkspace, removeSessionWorkspace, moveSessionWorkspace } from '../../workspace.js';
43
45
  import { loadMemory, writeMemory, dedupeMemory } from '../../memory.js';
44
46
  import { recordUsage, listCacheStats, summarizeCacheStats, costBreakdown } from '../../cachestats.js';
@@ -48,9 +50,10 @@ import { presetList, buildPreset } from '../../mcp-presets.js';
48
50
  import { syncStatus, syncLogin, syncLogout, syncPush, syncPull, syncRemoteList, maybeAutoSync, syncChangePassword, syncShareCreate, syncShareList, syncShareAccept, syncShareRevoke, listSyncConflicts, resolveSyncConflict } from '../../sync.js';
49
51
 
50
52
  export function createApiDispatch(deps) {
51
- const { json, srvlog, readBody, cfg, home, providerCache, getProviderFor, tasks, MAX_CONCURRENT, pruneTasks, handleChat, mcpFacade, provider, validateRemoteUrl, isPrivateHost, authEnabled, tokenMatches, requestToken, trustedHost, INDEX_HTML } = deps;
52
- const state = deps.state; // { modelName, workingDir, draftText }
53
+ const { json, srvlog, readBody, cfg, home, providerCache, getProviderFor, tasks, MAX_CONCURRENT, pruneTasks, handleChat, mcpFacade, provider, validateRemoteUrl, isPrivateHost, authEnabled, tokenMatches, requestToken, trustedHost, INDEX_HTML, draftTexts, startupCwd } = deps;
54
+ const state = deps.state; // { modelName, workingDir }
53
55
  const refs = deps.refs; // { inflight }
56
+ const MAX_API_BODY = 1024 * 1024; // 质检 A4:普通 JSON 接口 1MB;chat 保持 readBody 默认 40MB
54
57
  const dispatch = async (req, res) => {
55
58
  const url = new URL(req.url, 'http://localhost');
56
59
  const p = url.pathname;
@@ -150,7 +153,7 @@ export function createApiDispatch(deps) {
150
153
  return;
151
154
  }
152
155
  if (req.method === 'POST' && p === '/api/config') {
153
- const body = await readBody(req);
156
+ const body = await readBody(req, MAX_API_BODY);
154
157
  // 内存 cfg 可能比磁盘旧(如 CLI 改过同步地址):先从磁盘刷新 sync,避免保存时回滚
155
158
  cfg.sync = loadConfig()?.sync || cfg.sync;
156
159
  const next = { model: state.modelName, permission: cfg.permission ?? 'ask' };
@@ -257,7 +260,7 @@ export function createApiDispatch(deps) {
257
260
  return;
258
261
  }
259
262
  if (req.method === 'POST' && p === '/api/models-config') {
260
- const body = await readBody(req);
263
+ const body = await readBody(req, MAX_API_BODY);
261
264
  const action = body.action;
262
265
  // —— 服务商 Key 管理 ——
263
266
  if (action === 'setProviderKey') {
@@ -330,6 +333,22 @@ export function createApiDispatch(deps) {
330
333
  saveConfig(cfg);
331
334
  return json(res, 200, { ok: true, name, model: state.modelName });
332
335
  }
336
+ // 质检(自定义模型连通性):发起一次最小对话验证 baseUrl+Key 可用
337
+ if (action === 'testCustom') {
338
+ const name = String(body.name || '').trim();
339
+ const cm = (cfg.customModels || {})[name];
340
+ if (!cm) return json(res, 400, { error: `自定义模型 ${name} 不存在` });
341
+ const pc = resolveProviderConfig(cfg, name);
342
+ if (!pc.apiKey) return json(res, 400, { error: '该模型未设置 API Key,请先「设Key」再测试' });
343
+ const t0 = Date.now();
344
+ try {
345
+ const tp = await createProvider(cfg, name);
346
+ const r = await tp.chat({ model: name, messages: [{ role: 'user', content: 'ping' }], tools: [], temperature: 0, maxTokens: 8 });
347
+ return json(res, 200, { ok: true, name, latencyMs: Date.now() - t0, reply: String(r.text || r.reasoning || '').slice(0, 60) || '(空回复,但连接成功)' });
348
+ } catch (e) {
349
+ return json(res, 200, { ok: false, name, latencyMs: Date.now() - t0, error: String(e?.message || e) });
350
+ }
351
+ }
333
352
  if (action === 'setCustomKey') {
334
353
  const name = String(body.name || '').trim();
335
354
  if (!(cfg.customModels || {})[name]) return json(res, 400, { error: `自定义模型 ${name} 不存在` });
@@ -384,7 +403,7 @@ export function createApiDispatch(deps) {
384
403
  return;
385
404
  }
386
405
  if (req.method === 'POST' && p === '/api/session') {
387
- const body = await readBody(req);
406
+ const body = await readBody(req, MAX_API_BODY);
388
407
  const file = path.basename(String(body.file || ''));
389
408
  const full = path.join(home, 'sessions', file);
390
409
  if (!file || !fs.existsSync(full)) return json(res, 404, { error: '会话不存在' });
@@ -420,7 +439,36 @@ export function createApiDispatch(deps) {
420
439
  durationMs: t.status === 'running' ? Date.now() - t.startedAt : t.durationMs,
421
440
  session: t.session ? path.basename(t.session.file) : null,
422
441
  }));
423
- json(res, 200, { ok: true, running, maxConcurrent: MAX_CONCURRENT, tasks: list });
442
+ // 质检(等待状态静默):后台 worker 任务与运行中的调度任务并入面板与状态条计数——
443
+ // 此前 /api/tasks 只含聊天 SSE 任务,后台下载/长任务期间界面完全无感知
444
+ let bgRunning = 0;
445
+ const background = [];
446
+ try {
447
+ for (const t of listTasks(home)) {
448
+ if (t.status === 'running') bgRunning += 1;
449
+ background.push({
450
+ id: t.id,
451
+ kind: 'worker',
452
+ status: t.status,
453
+ message: t.message || t.question || '',
454
+ startedAt: t.startedAt,
455
+ durationMs: t.status === 'running' ? Date.now() - t.startedAt : t.durationMs,
456
+ error: t.error || '',
457
+ });
458
+ }
459
+ for (const sch of listSchedules(home)) {
460
+ if (sch.status === 'running') bgRunning += 1;
461
+ background.push({
462
+ id: sch.id,
463
+ kind: 'schedule',
464
+ status: sch.status,
465
+ message: sch.question || sch.id || '',
466
+ startedAt: sch.createdAt || null,
467
+ durationMs: sch.status === 'running' ? Date.now() - (sch.lastRunAt || Date.now()) : null,
468
+ });
469
+ }
470
+ } catch {}
471
+ json(res, 200, { ok: true, running, bgRunning, maxConcurrent: MAX_CONCURRENT, tasks: list, background });
424
472
  return;
425
473
  }
426
474
  if (req.method === 'POST' && p === '/api/chat') {
@@ -439,7 +487,7 @@ export function createApiDispatch(deps) {
439
487
  });
440
488
  let body;
441
489
  try {
442
- body = await readBody(req);
490
+ body = await readBody(req, MAX_API_BODY);
443
491
  } catch (e) {
444
492
  refs.inflight -= 1;
445
493
  res.write(`data: ${JSON.stringify({ type: 'error', message: e.message })}\n\n`);
@@ -463,7 +511,7 @@ export function createApiDispatch(deps) {
463
511
  return;
464
512
  }
465
513
  if (req.method === 'POST' && p === '/api/permission') {
466
- const body = await readBody(req);
514
+ const body = await readBody(req, MAX_API_BODY);
467
515
  const entry = tasks.get(body.taskId);
468
516
  if (!entry || !entry.pendingAsk) return json(res, 409, { error: '没有挂起的权限确认' });
469
517
  const pa = entry.pendingAsk;
@@ -479,7 +527,7 @@ export function createApiDispatch(deps) {
479
527
  return;
480
528
  }
481
529
  if (req.method === 'POST' && p === '/api/abort') {
482
- const body = await readBody(req);
530
+ const body = await readBody(req, MAX_API_BODY);
483
531
  if (body.taskId) {
484
532
  const entry = tasks.get(body.taskId);
485
533
  if (entry?.abortHandler) {
@@ -501,14 +549,17 @@ export function createApiDispatch(deps) {
501
549
  return;
502
550
  }
503
551
  if (req.method === 'GET' && p === '/api/draft') {
504
- const text = state.draftText;
505
- state.draftText = ''; // 读取即清除
552
+ // 质检 A5:按会话维度读取(无 file 参数用全局槽,兼容旧客户端)
553
+ const key = String(url.searchParams.get('file') || '');
554
+ const text = draftTexts.get(key) || '';
555
+ draftTexts.delete(key); // 读取即清除
506
556
  json(res, 200, { ok: true, text });
507
557
  return;
508
558
  }
509
559
  if (req.method === 'POST' && p === '/api/draft') {
510
- const body = await readBody(req);
511
- state.draftText = String(body.text ?? '').slice(0, 200000);
560
+ const body = await readBody(req, MAX_API_BODY);
561
+ const key = String(body.file || '');
562
+ draftTexts.set(key, String(body.text ?? '').slice(0, 200000));
512
563
  json(res, 200, { ok: true });
513
564
  return;
514
565
  }
@@ -533,7 +584,7 @@ export function createApiDispatch(deps) {
533
584
  return;
534
585
  }
535
586
  if (req.method === 'POST' && p === '/api/schedule') {
536
- const body = await readBody(req);
587
+ const body = await readBody(req, MAX_API_BODY);
537
588
  const action = String(body.action || '');
538
589
  try {
539
590
  if (action === 'add') {
@@ -573,7 +624,7 @@ export function createApiDispatch(deps) {
573
624
  return;
574
625
  }
575
626
  if (req.method === 'POST' && p === '/api/workspaces') {
576
- const body = await readBody(req);
627
+ const body = await readBody(req, MAX_API_BODY);
577
628
  const name = String(body.name || '').trim();
578
629
  if (body.action === 'add') {
579
630
  if (!name) return json(res, 400, { error: '名称不能为空' });
@@ -626,6 +677,11 @@ export function createApiDispatch(deps) {
626
677
  if (req.method === 'GET' && p === '/api/fs-browse') {
627
678
  let dir = String(url.searchParams.get('dir') || '').trim();
628
679
  if (!path.isAbsolute(dir)) return json(res, 400, { error: '需要绝对路径' });
680
+ // 质检 A3:目录浏览限定基目录(默认家目录 + config.web.browseRoots 显式授权),
681
+ // 拒绝越界——此前可枚举服务器任意绝对路径(整机目录结构探测面)
682
+ const browseRoots = [os.homedir(), startupCwd, state.workingDir, ...(Array.isArray(cfg.web?.browseRoots) ? cfg.web.browseRoots : [])].map((r) => path.resolve(String(r)));
683
+ const inRoot = browseRoots.some((r) => dir === r || dir.startsWith(r + path.sep));
684
+ if (!inRoot) return json(res, 403, { error: '目录不在可浏览范围内(家目录或 web.browseRoots 授权目录)' });
629
685
  try {
630
686
  const st = fs.statSync(dir);
631
687
  if (!st.isDirectory()) return json(res, 400, { error: '不是目录' });
@@ -635,7 +691,9 @@ export function createApiDispatch(deps) {
635
691
  .map((e) => ({ name: e.name, path: path.join(dir, e.name) }))
636
692
  .sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'))
637
693
  .slice(0, 300);
638
- const parent = path.dirname(dir);
694
+ let parent = path.dirname(dir);
695
+ // 父目录同样不得越出基目录
696
+ if (!browseRoots.some((r) => parent === r || parent.startsWith(r + path.sep))) parent = null;
639
697
  json(res, 200, { ok: true, path: dir, parent: parent === dir ? null : parent, entries });
640
698
  } catch (err) {
641
699
  json(res, 400, { error: String(err?.message || err) });
@@ -647,7 +705,7 @@ export function createApiDispatch(deps) {
647
705
  return;
648
706
  }
649
707
  if (req.method === 'POST' && p === '/api/memory') {
650
- const body = await readBody(req);
708
+ const body = await readBody(req, MAX_API_BODY);
651
709
  if (body.action === 'dedupe') {
652
710
  const removed = dedupeMemory();
653
711
  return json(res, 200, { ok: true, removed });
@@ -674,7 +732,7 @@ export function createApiDispatch(deps) {
674
732
  return;
675
733
  }
676
734
  if (req.method === 'POST' && p === '/api/mcp-presets') {
677
- const body = await readBody(req);
735
+ const body = await readBody(req, MAX_API_BODY);
678
736
  const name = String(body.name || '').trim();
679
737
  const r = buildPreset(name, body.arg, state.workingDir);
680
738
  if (r.error) return json(res, 400, { error: r.error });
@@ -708,7 +766,7 @@ export function createApiDispatch(deps) {
708
766
  return;
709
767
  }
710
768
  if (req.method === 'POST' && p === '/api/skills') {
711
- const body = await readBody(req);
769
+ const body = await readBody(req, MAX_API_BODY);
712
770
  if (body.action === 'install') {
713
771
  const r = await installSkill(body.arg || body.name || '');
714
772
  if (r.error) return json(res, 400, { error: r.error });
@@ -732,7 +790,7 @@ export function createApiDispatch(deps) {
732
790
  return;
733
791
  }
734
792
  if (req.method === 'POST' && p === '/api/sync') {
735
- const body = await readBody(req);
793
+ const body = await readBody(req, MAX_API_BODY);
736
794
  if (body.action === 'login') {
737
795
  const vurl = validateRemoteUrl(body.url); // 质检 S1:SSRF 防护(同步端点同样只允许公网)
738
796
  if (vurl.error) return json(res, 400, { error: vurl.error });
package/src/web/server.js CHANGED
@@ -16,6 +16,7 @@ import fs from 'node:fs';
16
16
  import path from 'node:path';
17
17
  import crypto from 'node:crypto';
18
18
  import { fileURLToPath } from 'node:url';
19
+ import { createLogWriter } from '../log-writer.js';
19
20
  import { createApiDispatch } from './routes/api.js';
20
21
  import { ensureHome, loadConfig, saveConfig, mingdaoHome } from '../config.js';
21
22
  import { setStoredKey, removeStoredKey, getStoredKey, maskKey } from '../credentials.js';
@@ -163,6 +164,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
163
164
  }
164
165
 
165
166
  let workingDir = process.cwd(); // 工作空间切换时随之更新(后续会话/工具都跟随新目录)
167
+ const startupCwd = workingDir; // 质检 A3:启动工作目录恒为合法浏览根(工作空间切换后仍可浏览原位)
166
168
  // Provider 按模型懒加载缓存:界面切换模型后即时生效
167
169
  const providerCache = new Map();
168
170
  async function getProviderFor(m) {
@@ -206,23 +208,12 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
206
208
  sessionLocks.set(file, next.then(() => sessionLocks.delete(file), () => sessionLocks.delete(file)));
207
209
  return next;
208
210
  }
209
- let draftText = ''; // 外部注入的草稿(VS Code 插件选中代码发送)
211
+ const draftTexts = new Map(); // 质检 A5:草稿按会话维度存储(此前单槽多客户端互相覆盖)
210
212
 
211
213
  // —— 服务端诊断日志(第二问无反应排查 + 长期运维):<mingdao-home>/logs/web-server.log ——
212
214
  // 记录每次对话的关键阶段与耗时;2MB 滚动。桌面版与 WebUI 共用同一日志。
213
- function srvlog(msg) {
214
- try {
215
- const dir = path.join(mingdaoHome(), 'logs');
216
- fs.mkdirSync(dir, { recursive: true });
217
- const f = path.join(dir, 'web-server.log');
218
- let prev = '';
219
- try {
220
- prev = fs.readFileSync(f, 'utf8');
221
- } catch {}
222
- if (prev.length > 2 * 1024 * 1024) prev = prev.slice(-1024 * 1024);
223
- fs.writeFileSync(f, prev + new Date().toISOString() + ' ' + msg + '\n');
224
- } catch {}
225
- }
215
+ // 质检 A6:统一日志写入器(append + 按行截断 + 原子替换),与桌面 appLog 同款实现
216
+ const srvlog = createLogWriter(path.join(mingdaoHome(), 'logs', 'web-server.log'));
226
217
 
227
218
  // —— SSRF 防护(质检 S1):远端地址校验 ——
228
219
  function isPrivateHost(hostname) {
@@ -506,10 +497,9 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
506
497
  providerCache, getProviderFor, tasks,
507
498
  MAX_CONCURRENT, pruneTasks, handleChat,
508
499
  mcpFacade, provider, validateRemoteUrl, isPrivateHost,
509
- authEnabled, tokenMatches, requestToken, trustedHost, INDEX_HTML,
500
+ authEnabled, tokenMatches, requestToken, trustedHost, INDEX_HTML, draftTexts, startupCwd,
510
501
  state: { get modelName() { return modelName; }, set modelName(v) { modelName = v; },
511
- get workingDir() { return workingDir; }, set workingDir(v) { workingDir = v; },
512
- get draftText() { return draftText; }, set draftText(v) { draftText = v; } },
502
+ get workingDir() { return workingDir; }, set workingDir(v) { workingDir = v; } },
513
503
  refs: { get inflight() { return inflight; }, set inflight(v) { inflight = v; } },
514
504
  });
515
505
  const server = http.createServer((req, res) => {