mingdao-harness 0.2.1 → 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.
@@ -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.1",
3
+ "version": "0.2.2",
4
4
  "description": "MingDao Harness —— 开源智能体框架(Agent Harness)。零依赖、开箱即用,针对 DeepSeek-V4 系列优化,开放主流模型接入。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
@@ -634,7 +634,7 @@ async function init(){
634
634
  refreshSessions();
635
635
  refreshWsSel();
636
636
  try{
637
- 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();
638
638
  if(dj.text){ input.value=dj.text; input.style.height=Math.min(input.scrollHeight,200)+'px'; input.focus(); }
639
639
  }catch(e){}
640
640
  }
@@ -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';
@@ -49,9 +50,10 @@ import { presetList, buildPreset } from '../../mcp-presets.js';
49
50
  import { syncStatus, syncLogin, syncLogout, syncPush, syncPull, syncRemoteList, maybeAutoSync, syncChangePassword, syncShareCreate, syncShareList, syncShareAccept, syncShareRevoke, listSyncConflicts, resolveSyncConflict } from '../../sync.js';
50
51
 
51
52
  export function createApiDispatch(deps) {
52
- const { json, srvlog, readBody, cfg, home, providerCache, getProviderFor, tasks, MAX_CONCURRENT, pruneTasks, handleChat, mcpFacade, provider, validateRemoteUrl, isPrivateHost, authEnabled, tokenMatches, requestToken, trustedHost, INDEX_HTML } = deps;
53
- 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 }
54
55
  const refs = deps.refs; // { inflight }
56
+ const MAX_API_BODY = 1024 * 1024; // 质检 A4:普通 JSON 接口 1MB;chat 保持 readBody 默认 40MB
55
57
  const dispatch = async (req, res) => {
56
58
  const url = new URL(req.url, 'http://localhost');
57
59
  const p = url.pathname;
@@ -151,7 +153,7 @@ export function createApiDispatch(deps) {
151
153
  return;
152
154
  }
153
155
  if (req.method === 'POST' && p === '/api/config') {
154
- const body = await readBody(req);
156
+ const body = await readBody(req, MAX_API_BODY);
155
157
  // 内存 cfg 可能比磁盘旧(如 CLI 改过同步地址):先从磁盘刷新 sync,避免保存时回滚
156
158
  cfg.sync = loadConfig()?.sync || cfg.sync;
157
159
  const next = { model: state.modelName, permission: cfg.permission ?? 'ask' };
@@ -258,7 +260,7 @@ export function createApiDispatch(deps) {
258
260
  return;
259
261
  }
260
262
  if (req.method === 'POST' && p === '/api/models-config') {
261
- const body = await readBody(req);
263
+ const body = await readBody(req, MAX_API_BODY);
262
264
  const action = body.action;
263
265
  // —— 服务商 Key 管理 ——
264
266
  if (action === 'setProviderKey') {
@@ -401,7 +403,7 @@ export function createApiDispatch(deps) {
401
403
  return;
402
404
  }
403
405
  if (req.method === 'POST' && p === '/api/session') {
404
- const body = await readBody(req);
406
+ const body = await readBody(req, MAX_API_BODY);
405
407
  const file = path.basename(String(body.file || ''));
406
408
  const full = path.join(home, 'sessions', file);
407
409
  if (!file || !fs.existsSync(full)) return json(res, 404, { error: '会话不存在' });
@@ -485,7 +487,7 @@ export function createApiDispatch(deps) {
485
487
  });
486
488
  let body;
487
489
  try {
488
- body = await readBody(req);
490
+ body = await readBody(req, MAX_API_BODY);
489
491
  } catch (e) {
490
492
  refs.inflight -= 1;
491
493
  res.write(`data: ${JSON.stringify({ type: 'error', message: e.message })}\n\n`);
@@ -509,7 +511,7 @@ export function createApiDispatch(deps) {
509
511
  return;
510
512
  }
511
513
  if (req.method === 'POST' && p === '/api/permission') {
512
- const body = await readBody(req);
514
+ const body = await readBody(req, MAX_API_BODY);
513
515
  const entry = tasks.get(body.taskId);
514
516
  if (!entry || !entry.pendingAsk) return json(res, 409, { error: '没有挂起的权限确认' });
515
517
  const pa = entry.pendingAsk;
@@ -525,7 +527,7 @@ export function createApiDispatch(deps) {
525
527
  return;
526
528
  }
527
529
  if (req.method === 'POST' && p === '/api/abort') {
528
- const body = await readBody(req);
530
+ const body = await readBody(req, MAX_API_BODY);
529
531
  if (body.taskId) {
530
532
  const entry = tasks.get(body.taskId);
531
533
  if (entry?.abortHandler) {
@@ -547,14 +549,17 @@ export function createApiDispatch(deps) {
547
549
  return;
548
550
  }
549
551
  if (req.method === 'GET' && p === '/api/draft') {
550
- const text = state.draftText;
551
- state.draftText = ''; // 读取即清除
552
+ // 质检 A5:按会话维度读取(无 file 参数用全局槽,兼容旧客户端)
553
+ const key = String(url.searchParams.get('file') || '');
554
+ const text = draftTexts.get(key) || '';
555
+ draftTexts.delete(key); // 读取即清除
552
556
  json(res, 200, { ok: true, text });
553
557
  return;
554
558
  }
555
559
  if (req.method === 'POST' && p === '/api/draft') {
556
- const body = await readBody(req);
557
- 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));
558
563
  json(res, 200, { ok: true });
559
564
  return;
560
565
  }
@@ -579,7 +584,7 @@ export function createApiDispatch(deps) {
579
584
  return;
580
585
  }
581
586
  if (req.method === 'POST' && p === '/api/schedule') {
582
- const body = await readBody(req);
587
+ const body = await readBody(req, MAX_API_BODY);
583
588
  const action = String(body.action || '');
584
589
  try {
585
590
  if (action === 'add') {
@@ -619,7 +624,7 @@ export function createApiDispatch(deps) {
619
624
  return;
620
625
  }
621
626
  if (req.method === 'POST' && p === '/api/workspaces') {
622
- const body = await readBody(req);
627
+ const body = await readBody(req, MAX_API_BODY);
623
628
  const name = String(body.name || '').trim();
624
629
  if (body.action === 'add') {
625
630
  if (!name) return json(res, 400, { error: '名称不能为空' });
@@ -672,6 +677,11 @@ export function createApiDispatch(deps) {
672
677
  if (req.method === 'GET' && p === '/api/fs-browse') {
673
678
  let dir = String(url.searchParams.get('dir') || '').trim();
674
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 授权目录)' });
675
685
  try {
676
686
  const st = fs.statSync(dir);
677
687
  if (!st.isDirectory()) return json(res, 400, { error: '不是目录' });
@@ -681,7 +691,9 @@ export function createApiDispatch(deps) {
681
691
  .map((e) => ({ name: e.name, path: path.join(dir, e.name) }))
682
692
  .sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'))
683
693
  .slice(0, 300);
684
- 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;
685
697
  json(res, 200, { ok: true, path: dir, parent: parent === dir ? null : parent, entries });
686
698
  } catch (err) {
687
699
  json(res, 400, { error: String(err?.message || err) });
@@ -693,7 +705,7 @@ export function createApiDispatch(deps) {
693
705
  return;
694
706
  }
695
707
  if (req.method === 'POST' && p === '/api/memory') {
696
- const body = await readBody(req);
708
+ const body = await readBody(req, MAX_API_BODY);
697
709
  if (body.action === 'dedupe') {
698
710
  const removed = dedupeMemory();
699
711
  return json(res, 200, { ok: true, removed });
@@ -720,7 +732,7 @@ export function createApiDispatch(deps) {
720
732
  return;
721
733
  }
722
734
  if (req.method === 'POST' && p === '/api/mcp-presets') {
723
- const body = await readBody(req);
735
+ const body = await readBody(req, MAX_API_BODY);
724
736
  const name = String(body.name || '').trim();
725
737
  const r = buildPreset(name, body.arg, state.workingDir);
726
738
  if (r.error) return json(res, 400, { error: r.error });
@@ -754,7 +766,7 @@ export function createApiDispatch(deps) {
754
766
  return;
755
767
  }
756
768
  if (req.method === 'POST' && p === '/api/skills') {
757
- const body = await readBody(req);
769
+ const body = await readBody(req, MAX_API_BODY);
758
770
  if (body.action === 'install') {
759
771
  const r = await installSkill(body.arg || body.name || '');
760
772
  if (r.error) return json(res, 400, { error: r.error });
@@ -778,7 +790,7 @@ export function createApiDispatch(deps) {
778
790
  return;
779
791
  }
780
792
  if (req.method === 'POST' && p === '/api/sync') {
781
- const body = await readBody(req);
793
+ const body = await readBody(req, MAX_API_BODY);
782
794
  if (body.action === 'login') {
783
795
  const vurl = validateRemoteUrl(body.url); // 质检 S1:SSRF 防护(同步端点同样只允许公网)
784
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) => {