mingdao-harness 0.6.2 → 0.6.4

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.
Files changed (58) hide show
  1. package/docs/AUDIT-v0.6.1-/347/254/254/344/270/211/346/226/271/346/212/245/345/221/212/347/231/273/350/256/260.md +300 -32
  2. package/docs/CONFIG.md +32 -6
  3. package/docs/DEVELOPER.md +1 -1
  4. package/docs/PACK-API.md +26 -3
  5. package/docs/PROVIDERS.md +22 -0
  6. package/docs/RELEASE-CHECKLIST.md +13 -2
  7. package/package.json +1 -1
  8. package/src/agent.js +133 -15
  9. package/src/atomic-write.js +247 -71
  10. package/src/audit.js +23 -18
  11. package/src/cachestats.js +57 -47
  12. package/src/cli.js +25 -4
  13. package/src/commands/diagnose.js +3 -0
  14. package/src/commands/key.js +23 -1
  15. package/src/commands/ledger.js +41 -3
  16. package/src/commands/net.js +5 -1
  17. package/src/commands/pack.js +23 -6
  18. package/src/commands/repl.js +9 -1
  19. package/src/commands/skill.js +30 -1
  20. package/src/commands/sync.js +7 -0
  21. package/src/commands/workspace.js +3 -3
  22. package/src/compact.js +15 -1
  23. package/src/config.js +71 -6
  24. package/src/constraints.js +55 -6
  25. package/src/credentials.js +36 -5
  26. package/src/hooks.js +37 -2
  27. package/src/ledger.js +3 -3
  28. package/src/log-writer.js +22 -17
  29. package/src/mcp.js +19 -1
  30. package/src/memory.js +8 -8
  31. package/src/packs.js +57 -8
  32. package/src/permissions.js +35 -9
  33. package/src/presets.js +46 -1
  34. package/src/providers/index.js +133 -7
  35. package/src/providers/openai-compatible.js +24 -3
  36. package/src/redact.js +38 -2
  37. package/src/replay.js +15 -3
  38. package/src/safe-fetch.js +8 -2
  39. package/src/schedule.js +22 -4
  40. package/src/session-index.js +2 -2
  41. package/src/session.js +2 -2
  42. package/src/skill-lib.js +43 -14
  43. package/src/skill-registry.js +31 -2
  44. package/src/sync-server.js +47 -28
  45. package/src/sync.js +17 -3
  46. package/src/task-state.js +10 -1
  47. package/src/tasks.js +22 -10
  48. package/src/tools/bash.js +96 -18
  49. package/src/tools/fetch.js +24 -0
  50. package/src/web/app.js +7 -7
  51. package/src/web/attachments.js +7 -1
  52. package/src/web/index.html +2 -2
  53. package/src/web/routes/api.js +27 -2
  54. package/src/web/routes/domains/sessions.js +3 -3
  55. package/src/web/routes/domains/sync.js +2 -1
  56. package/src/web/routes/domains/workspace.js +45 -15
  57. package/src/web/server.js +22 -5
  58. package/src/workspace.js +31 -18
@@ -50,7 +50,7 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
50
50
  const ws = workspaceForDir(wsDir);
51
51
  if (wsDir && path.resolve(wsDir) !== path.resolve(state.workingDir)) {
52
52
  state.workingDir = wsDir; // 审计 P2-5:仅在目录不同时才切换,减少全局状态抖动
53
- if (ws) touchWorkspace(ws.name);
53
+ if (ws) await touchWorkspace(ws.name);
54
54
  }
55
55
  json(res, 200, {
56
56
  ok: true,
@@ -74,13 +74,13 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
74
74
  const title = sanitizeTitle(String(body.title || '会话'));
75
75
  const renamed = renameSessionFile(fs, path, home, { file: full }, title);
76
76
  if (!renamed) return json(res, 500, { error: '重命名失败(可能存在同名会话)' });
77
- moveSessionWorkspace(file, path.basename(renamed));
77
+ await moveSessionWorkspace(file, path.basename(renamed));
78
78
  return json(res, 200, { ok: true, file: path.basename(renamed) });
79
79
  }
80
80
  if (body.action === 'delete') {
81
81
  try {
82
82
  fs.unlinkSync(full);
83
- removeSessionWorkspace(file);
83
+ await removeSessionWorkspace(file);
84
84
  return json(res, 200, { ok: true });
85
85
  } catch (/** @type {any} */ err) {
86
86
  return json(res, 500, { error: String(err?.message || err) });
@@ -52,7 +52,8 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
52
52
  if (body.action === 'push') {
53
53
  const r = /** @type {any} */ (await syncPush(body.name));
54
54
  if (r.error) return json(res, 400, { error: r.error });
55
- return json(res, 200, { ok: true, pushed: r.pushed.length, conflicts: r.conflicts });
55
+ // v0.6.3(审计 H-2):把"上传前已脱敏"的会话数透给前端,避免用户以为远端是原文
56
+ return json(res, 200, { ok: true, pushed: r.pushed.length, conflicts: r.conflicts, redacted: (r.redacted || []).length });
56
57
  }
57
58
  if (body.action === 'pull') {
58
59
  const r = /** @type {any} */ (await syncPull(body.name));
@@ -36,13 +36,39 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
36
36
  os.tmpdir(),
37
37
  ];
38
38
  const normPath = (/** @type {string} */ x) => (process.platform === 'win32' ? x.toLowerCase() : x);
39
+
40
+ // v0.6.3(P0-3):围栏必须按**真实路径**判定,否则一个符号链接就够了。
41
+ // 原实现只用 path.resolve(纯字符串消解 `..`)做前缀比较,而后续 statSync/readdirSync 会
42
+ // **跟随符号链接**:`ln -s / /tmp/root` 之后 `?dir=/tmp/root` 通过前缀检查、stat 落到 `/`,
43
+ // 于是任意目录枚举;再用 `POST /api/workspaces {dir:'/tmp/root'}` 登记,Agent 的
44
+ // bash/write/edit 就整体跑到围栏之外。`os.tmpdir()` 被无条件算作允许根,门槛更低。
45
+ //
46
+ // 两处都归一化才有意义:目标 realpath + 根 realpath(macOS 的 /Users、Linux 的 /home
47
+ // 常是符号链接,只归一化一边会把合法目录误判越界)。
48
+ /** 归一化到真实路径;目标不存在时回退到「最近的存在祖先」的 realpath 再接回剩余段 */
49
+ const realpathDeep = (/** @type {string} */ p0) => {
50
+ let cur = path.resolve(String(p0));
51
+ /** @type {string[]} */
52
+ const rest = [];
53
+ for (;;) {
54
+ try {
55
+ const real = fs.realpathSync(cur);
56
+ return rest.length ? path.join(real, ...rest.slice().reverse()) : real;
57
+ } catch {
58
+ const parent = path.dirname(cur);
59
+ if (parent === cur) return path.resolve(String(p0)); // 连根都读不到:退回原路径
60
+ rest.push(path.basename(cur));
61
+ cur = parent;
62
+ }
63
+ }
64
+ };
39
65
  const allowedRoots = () =>
40
66
  [...baseRoots, startupCwd, state.workingDir, ...(Array.isArray(cfg?.web?.browseRoots) ? cfg.web.browseRoots : [])]
41
67
  .filter(Boolean)
42
- .map((r) => normPath(path.resolve(String(r))));
43
- /** 目标目录是否落在允许根内(含根自身) */
68
+ .map((r) => normPath(realpathDeep(String(r))));
69
+ /** 目标目录是否落在允许根内(含根自身)——按 realpath 比较,符号链接逃逸会在这里被拒 */
44
70
  const withinAllowed = (/** @type {string} */ dir) => {
45
- const d = normPath(dir);
71
+ const d = normPath(realpathDeep(dir));
46
72
  return allowedRoots().some((r) => d === r || d.startsWith(r + path.sep));
47
73
  };
48
74
  // 显式放开(默认 false):确需登记家目录之外的位置(外置卷/网络盘)时由用户显式开启
@@ -58,7 +84,7 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
58
84
  const name = String(body.name || '').trim();
59
85
  if (body.action === 'add') {
60
86
  if (!name) return json(res, 400, { error: '名称不能为空' });
61
- const target = path.resolve(body.dir || state.workingDir);
87
+ const target = realpathDeep(path.resolve(body.dir || state.workingDir)); // P0-3:登记前先消解符号链接
62
88
  // v0.4.7(T1):登记工作空间 == 授权它可被目录浏览(fs-browse 的基目录含 state.workingDir)。
63
89
  // 若允许登记任意绝对路径,围栏就能被「先 add 再 set」一步自行解除(登记 / 即可枚举全盘)。
64
90
  // 因此登记与自动建目录都限定在允许根内;需要家目录之外的位置请显式配置
@@ -76,12 +102,12 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
76
102
  return json(res, 400, { error: `无法创建目录:${target}(${e.message})` });
77
103
  }
78
104
  }
79
- const r = addWorkspace(name, target);
105
+ const r = await addWorkspace(name, target);
80
106
  if (r.error) return json(res, 400, { error: r.error });
81
107
  return json(res, 200, { ok: true, name: r.name, dir: r.dir, created: body.create !== false });
82
108
  }
83
109
  if (body.action === 'rename') {
84
- const r = renameWorkspace(name, body.newName);
110
+ const r = await renameWorkspace(name, body.newName);
85
111
  if (r.error) return json(res, 400, { error: r.error });
86
112
  return json(res, 200, { ok: true, name: r.name });
87
113
  }
@@ -90,13 +116,13 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
90
116
  // 携带 file 时同时把当前会话的工作空间切过去(P3-4:会话跟随显式切换)。
91
117
  if (body.dir) {
92
118
  // v0.4.7(T1):改目录同样受允许根约束——否则「先登记一个合法目录,再 set 到 /」即可绕过上面的闸门
93
- const t2 = path.resolve(String(body.dir));
119
+ const t2 = realpathDeep(path.resolve(String(body.dir))); // P0-3:同上
94
120
  if (!allowAnyDir && !withinAllowed(t2)) {
95
121
  return json(res, 400, {
96
122
  error: `目录 ${t2} 不在允许范围内(家目录 / 启动目录 / 当前工作目录 / web.browseRoots)。确需切换请配置 web.allowAnyWorkspaceDir: true。`,
97
123
  });
98
124
  }
99
- const r = setWorkspaceDir(name, body.dir);
125
+ const r = await setWorkspaceDir(name, body.dir);
100
126
  if (r.error) return json(res, 400, { error: r.error });
101
127
  }
102
128
  const dir = workspacePath(name);
@@ -106,15 +132,15 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
106
132
  } catch (/** @type {any} */ e) {
107
133
  return json(res, 400, { error: `无法创建目录:${dir}(${e.message})` });
108
134
  }
109
- touchWorkspace(name);
135
+ await touchWorkspace(name);
110
136
  state.workingDir = dir;
111
137
  // 不再 process.chdir:运行中任务的 cwd 在创建时已固定,全局切换只影响新会话
112
- if (body.file) setSessionWorkspace(String(body.file), dir, /** @type {any} */ (name));
138
+ if (body.file) await setSessionWorkspace(String(body.file), dir, /** @type {any} */ (name));
113
139
  return json(res, 200, { ok: true, name, dir, current: name });
114
140
  }
115
141
  if (body.action === 'remove') {
116
142
  if (!name) return json(res, 400, { error: '缺少名称' });
117
- const rm = removeWorkspace(name);
143
+ const rm = await removeWorkspace(name);
118
144
  if (rm && /** @type {any} */ (rm).error) return json(res, 500, { error: /** @type {any} */ (rm).error });
119
145
  return json(res, 200, { ok: rm === true });
120
146
  }
@@ -128,7 +154,7 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
128
154
  if (!path.isAbsolute(dir)) return json(res, 400, { error: '需要绝对路径' });
129
155
  // 评估 6.1(v0.4.3):先 path.resolve 消解 .. 段,再做前缀比较与 stat/readdir——此前字符串
130
156
  // 前缀比较用未规范化的 dir,`/home/u/../../etc` 能通过 startsWith('/home/u/') 但 stat 解析到 /etc。
131
- dir = path.resolve(dir);
157
+ dir = realpathDeep(dir); // v0.6.3(P0-3):消解符号链接——`path.resolve` 只消解 `..`,挡不住 `ln -s /`
132
158
  // 质检 A3:目录浏览限定基目录,拒绝越界。Windows(CodeArts 报告):家目录覆盖整个用户配置树
133
159
  // (AppData 等)——收紧为 桌面/文档/下载 三常用目录 + 启动目录 + 工作目录 + web.browseRoots 显式授权;
134
160
  // 路径比较在 win32 下大小写归一(D:\\ vs d:\\ 不再误拒)。
@@ -144,13 +170,17 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
144
170
  if (!st.isDirectory()) return json(res, 400, { error: '不是目录' });
145
171
  const entries = fs
146
172
  .readdirSync(dir, { withFileTypes: true })
147
- .filter((e) => e.isDirectory() && !e.name.startsWith('.'))
173
+ // P0-3:符号链接条目一律不列出。**如实说明**:`Dirent.isDirectory()` 走的是 lstat 语义,
174
+ // 对符号链接本就返回 false,所以这一条 `!e.isSymbolicLink()` 是双保险而**不是**主要防线
175
+ // (实测:去掉它也列不出链接条目)。真正的防线是上面的 realpath 判定——
176
+ // 即便有人把链接路径直接喂进来,也会因为 realpath 落在允许根之外而被 403。
177
+ .filter((e) => e.isDirectory() && !e.isSymbolicLink() && !e.name.startsWith('.'))
148
178
  .map((e) => ({ name: e.name, path: path.join(dir, e.name) }))
149
179
  .sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'))
150
180
  .slice(0, 300);
151
181
  let parent = path.dirname(dir);
152
- // 父目录同样不得越出基目录
153
- if (!browseRoots.some((r) => norm(parent) === r || norm(parent).startsWith(r + path.sep))) parent = /** @type {any} */ (null);
182
+ // 父目录同样不得越出基目录(与上面的判定共用同一个 realpath 版 withinAllowed,避免两处漂移)
183
+ if (!withinAllowed(parent)) parent = /** @type {any} */ (null);
154
184
  json(res, 200, { ok: true, path: dir, parent: parent === dir ? null : parent, entries });
155
185
  } catch (/** @type {any} */ err) {
156
186
  json(res, 400, { error: String(err?.message || err) });
package/src/web/server.js CHANGED
@@ -22,14 +22,14 @@ import { createApiDispatch } from './routes/api.js';
22
22
  import { ensureHome, loadConfig, saveConfig, mingdaoHome } from '../config.js';
23
23
  import { setStoredKey, removeStoredKey, getStoredKey, maskKey } from '../credentials.js';
24
24
  import { availableModels, fetchProviderModels, providerHasKey } from '../model-discovery.js';
25
- import { createProvider, resolveProviderConfig, helperProvider } from '../providers/index.js';
25
+ import { createProvider, resolveProviderConfig, helperProvider, resolveVisionSupport } from '../providers/index.js';
26
26
  import { MODELS, modelPreset, PROVIDERS } from '../models.js';
27
27
  import { routeTask, routingConfig } from '../routing.js';
28
28
  import { buildUserContent } from './attachments.js';
29
29
  import { MAX_CONCURRENT, SECURITY_HEADERS } from './constants.js';
30
30
  import { createAgent } from '../agent.js';
31
31
  import { createPermission } from '../permissions.js';
32
- import { isPrivateHost as sharedIsPrivateHost } from '../tools/fetch.js';
32
+ import { isPrivateHost as sharedIsPrivateHost, isMetadataHost } from '../tools/fetch.js';
33
33
  import { buildSystemPrompt } from '../prompts.js';
34
34
  import { loadProjectMemory, loadProjectMemoryEntries, retrieveRelevant, extractAndAppendProjectMemory } from '../memory.js';
35
35
  import { saveTaskStateMerge, clearTaskState, loadTaskState, resumePrompt, checkpointHint } from '../task-state.js';
@@ -315,6 +315,11 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
315
315
  return { error: '地址必须是合法的 http(s) URL' };
316
316
  }
317
317
  if (u.protocol !== 'http:' && u.protocol !== 'https:') return { error: '仅支持 http/https 地址' };
318
+ // v0.6.3(P1-8):云元数据端点**无条件拒绝**——它不在"本机可信模型服务"那一类里,
319
+ // 因此回环绑定与 web.allowPrivateEndpoints 都不放行(原实现把整段校验都跳过了)。
320
+ if (isMetadataHost(String(u.hostname || ''))) {
321
+ return { error: `拒绝访问云元数据端点(${u.hostname})——SSRF 防护,此地址无任何放行开关` };
322
+ }
318
323
  // 威胁模型:本机回环绑定时(默认/桌面版),本机模型服务(如 Ollama)属可信场景,放行;
319
324
  // 对外监听(0.0.0.0)时拒绝私网目标——除非显式 allowPrivateEndpoints。
320
325
  // 域名目标:DNS 解析后逐条校验(2026-09-02 加固,防 DNS 重绑定把域名指向内网绕过字面量检查)。
@@ -393,7 +398,11 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
393
398
  }, 5000);
394
399
  const userMessage = String(body.message ?? '').trim();
395
400
  entry.message = (userMessage || '[附件]').slice(0, 40);
396
- const visionSupported = Boolean(modelPreset(modelName)?.supportsVision || cfg.customModels?.[modelName]?.vision);
401
+ // v0.6.3(下游 Dify 工作流反馈):门控必须**也咨询自定义 Provider**。
402
+ // 原式只看内置预设与 customModels,于是自定义 Provider(Dify/网关适配器)永远被判不支持图片;
403
+ // 而为了让门控通过去写 customModels,又会把 provider 解析劫持到 openai-compatible 直连
404
+ // (见 providers/index.js 的 isEndpointDeclaration 注释)。现在统一走 resolveVisionSupport。
405
+ const visionSupported = await resolveVisionSupport(cfg, modelName);
397
406
  const built = buildUserContent(userMessage, body.attachments, visionSupported);
398
407
  if (built.error) {
399
408
  entry.status = 'failed';
@@ -479,7 +488,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
479
488
  const sessionWsDir = getSessionWorkspace(sessionName);
480
489
  const taskDir = sessionWsDir || workingDir;
481
490
  if (!sessionWsDir) {
482
- setSessionWorkspace(sessionName, taskDir, currentWorkspace(workingDir)?.name || null);
491
+ await setSessionWorkspace(sessionName, taskDir, currentWorkspace(workingDir)?.name || null);
483
492
  }
484
493
  // v0.3.0 P0-3:项目记忆会话内快照——本会话全程用同一份快照(前缀稳定),
485
494
  // 每轮结束提取的新条目只写文件、不回灌当前会话。
@@ -670,7 +679,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
670
679
  const oldName = path.basename(session.file);
671
680
  const renamed = renameSessionFile(fs, path, home, session, title);
672
681
  if (renamed) {
673
- moveSessionWorkspace(oldName, path.basename(renamed)); // 会话改名 → 工作空间映射跟随
682
+ await moveSessionWorkspace(oldName, path.basename(renamed)); // 会话改名 → 工作空间映射跟随
674
683
  claimSessionKey(session.file); // 忙锁键同步迁移,否则新文件名发起的回合不会被判「忙」
675
684
  }
676
685
  }
@@ -796,6 +805,14 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
796
805
  // 质检 L6:实际 trustedHost 白名单只放行回环 Host(远程浏览器带 LAN IP 的 Host 会 403),
797
806
  // 文案按真实行为修正——非浏览器客户端/伪造 Host 仍可达,务必启用令牌
798
807
  console.warn(' ⚠ 警告:当前监听 ' + host + '(非本机回环)且未启用令牌,浏览器直接访问会被拒绝(Host 白名单),但非浏览器客户端仍可直连。强烈建议:mingdao web --auth-token <令牌>。');
808
+ } else {
809
+ // v0.6.3(H-5):回环 + 未配置令牌时**把"本机信任"这个决定说出来**。
810
+ // 静默无认证的危险不在于它一定被利用,而在于用户不知道自己处在这个模式里:
811
+ // · 跨站浏览器请求(含 <img>/no-cors 盲打)已在 api.js 用 Sec-Fetch-Site 一律拒绝;
812
+ // · 但**同机的其它进程/其它用户**仍可直连 API(多用户机器、共享 CI runner 上尤其要注意)。
813
+ // 一句话给出两个可选动作,而不是替用户决定(改默认会打断"浏览器直接打开 127.0.0.1"的日常用法)。
814
+ console.log(' ℹ 本机信任模式:未配置访问令牌,本机其它进程可直接访问 /api/*。');
815
+ console.log(' 多用户机器 / 共享 CI 上建议启用令牌:mingdao web --auth-token <令牌>,或 config.json 的 web.token。');
799
816
  }
800
817
  console.log(` 模型: ${modelName} · 权限: ${cfg.permission ?? 'ask'} · 工作目录: ${workingDir}`);
801
818
  if (cfg.mcpServers && Object.keys(cfg.mcpServers).length) console.log(' MCP: 后台连接中,/api/state 可查看状态');
package/src/workspace.js CHANGED
@@ -1,3 +1,16 @@
1
+ // v0.6.2(自评 P2-7 阻塞面):本模块**全部**加锁函数已迁到异步版 `withFileLock`。
2
+ //
3
+ // 为什么先迁这一组:它正好在 WebUI 的请求路径上(开会话设目录、切换工作空间、会话改名),
4
+ // 而同步锁等待期间 `Atomics.wait` 会让**整个事件循环停摆**——实测持锁方存活 2.6 秒时,
5
+ // 一个 100ms 的定时器在锁返回前根本没触发,表现为所有并发会话/权限确认/SSE 流一起卡住。
6
+ // 且这组函数的 11 个调用点**全部**已在 async 处理函数里(web 路由 / 命令处理 / 会话改名),
7
+ // 迁移只是加 `await`,不产生 async 传染。
8
+ //
9
+ // 仍用同步版的调用点(tasks 2 处 / schedule 12 处 / cachestats 1 处 / sync 1 处)见
10
+ // docs/AUDIT-v0.6.1-第三方报告登记.md §3.26 的说明:它们要么在纯同步读-改-写链里
11
+ // (强行异步化会传染 24 个调用点,回归风险大于收益),要么在调度守护进程内部
12
+ // (阻塞只会推迟定时任务,不会冻结用户请求)。
13
+ //
1
14
  // 工作空间:项目目录注册表(参考 WorkBuddy 的项目组织思路)——
2
15
  // 为常做的项目登记名称与目录,一键回到对应项目,配置与记忆随目录(AGENTS.md / .mingdao/skills 等)自然跟随。
3
16
  // 注册表:<mingdao-home>/workspaces.json → { "<名称>": { dir, createdAt, lastUsed } }
@@ -6,7 +19,7 @@
6
19
  import fs from 'node:fs';
7
20
  import path from 'node:path';
8
21
  import { mingdaoHome, ensureHome } from './config.js';
9
- import { atomicWriteFileSync, withFileLockSync } from './atomic-write.js';
22
+ import { atomicWriteFileSync, atomicWritePrivateSync, withFileLock } from './atomic-write.js';
10
23
 
11
24
  export function workspacesFile() {
12
25
  return path.join(mingdaoHome(), 'workspaces.json');
@@ -33,14 +46,14 @@ export function saveWorkspaces(/** @type {any} */ ws) {
33
46
  try {
34
47
  ensureHome();
35
48
  // 原子写(评估 6.6):随机 tmp 名 + rename,避免崩溃冲空与跨进程共名 tmp 串扰
36
- atomicWriteFileSync(workspacesFile(), JSON.stringify(ws, null, 2) + '\n');
49
+ atomicWritePrivateSync(workspacesFile(), JSON.stringify(ws, null, 2) + '\n');
37
50
  return { ok: true, error: null };
38
51
  } catch (err) {
39
52
  return { ok: false, error: String(/** @type {any} */ (err)?.message ?? err) };
40
53
  }
41
54
  }
42
55
 
43
- export function addWorkspace(/** @type {any} */ name, /** @type {any} */ dir) {
56
+ export async function addWorkspace(/** @type {any} */ name, /** @type {any} */ dir) {
44
57
  const key = String(name).trim();
45
58
  if (!key) return { error: '名称不能为空' };
46
59
  if (/[\\/]/.test(key)) return { error: '名称不能包含路径分隔符' };
@@ -48,7 +61,7 @@ export function addWorkspace(/** @type {any} */ name, /** @type {any} */ dir) {
48
61
  if (!fs.existsSync(target)) return { error: `目录不存在:${target}` };
49
62
  // v0.4.7(T19):读-改-写必须在跨进程锁内——WebUI 每次建会话都会 touch 工作空间,
50
63
  // 与 CLI 的 add/remove 并发时未加锁会丢更新(注册表少一条工作空间)。
51
- return withFileLockSync(workspacesFile() + '.lock', () => {
64
+ return withFileLock(workspacesFile() + '.lock', () => {
52
65
  const ws = loadWorkspaces();
53
66
  ws[key] = { dir: target, createdAt: ws[key]?.createdAt || Date.now(), lastUsed: Date.now() };
54
67
  const saved = saveWorkspaces(ws);
@@ -57,8 +70,8 @@ export function addWorkspace(/** @type {any} */ name, /** @type {any} */ dir) {
57
70
  });
58
71
  }
59
72
 
60
- export function removeWorkspace(/** @type {any} */ name) {
61
- return withFileLockSync(workspacesFile() + '.lock', () => {
73
+ export async function removeWorkspace(/** @type {any} */ name) {
74
+ return withFileLock(workspacesFile() + '.lock', () => {
62
75
  const ws = loadWorkspaces();
63
76
  if (!ws[name]) return false;
64
77
  delete ws[name];
@@ -70,12 +83,12 @@ export function removeWorkspace(/** @type {any} */ name) {
70
83
  });
71
84
  }
72
85
 
73
- export function renameWorkspace(/** @type {any} */ name, /** @type {any} */ newName) {
86
+ export async function renameWorkspace(/** @type {any} */ name, /** @type {any} */ newName) {
74
87
  const key = String(newName).trim();
75
88
  if (!key) return { error: '新名称不能为空' };
76
89
  if (/[\\/]/.test(key)) return { error: '名称不能包含路径分隔符' };
77
90
  if (key === name) return { name: key }; // 原样改名:无操作,避免自删条目
78
- return withFileLockSync(workspacesFile() + '.lock', () => {
91
+ return withFileLock(workspacesFile() + '.lock', () => {
79
92
  const ws = loadWorkspaces();
80
93
  if (!ws[name]) return { error: `工作空间 ${name} 不存在` };
81
94
  if (ws[key]) return { error: `名称 ${key} 已存在` };
@@ -88,7 +101,7 @@ export function renameWorkspace(/** @type {any} */ name, /** @type {any} */ newN
88
101
  }
89
102
 
90
103
  // 修改目录:登记同名即可覆盖目录
91
- export function setWorkspaceDir(/** @type {any} */ name, /** @type {any} */ dir) {
104
+ export async function setWorkspaceDir(/** @type {any} */ name, /** @type {any} */ dir) {
92
105
  return addWorkspace(name, dir);
93
106
  }
94
107
 
@@ -96,9 +109,9 @@ export function workspacePath(/** @type {any} */ name) {
96
109
  return loadWorkspaces()[name]?.dir || null;
97
110
  }
98
111
 
99
- export function touchWorkspace(/** @type {any} */ name) {
112
+ export async function touchWorkspace(/** @type {any} */ name) {
100
113
  // v0.4.7(T19):同上,加锁防并发丢更新
101
- return withFileLockSync(workspacesFile() + '.lock', () => {
114
+ return withFileLock(workspacesFile() + '.lock', () => {
102
115
  const ws = loadWorkspaces();
103
116
  if (!ws[name]) return false;
104
117
  ws[name].lastUsed = Date.now();
@@ -160,7 +173,7 @@ export function saveSessionWorkspaces(/** @type {any} */ map) {
160
173
  try {
161
174
  ensureHome();
162
175
  // 原子写(评估 6.6):随机 tmp 名 + rename
163
- atomicWriteFileSync(sessionWorkspacesFile(), JSON.stringify(map, null, 2) + '\n', { mode: 0o600 });
176
+ atomicWritePrivateSync(sessionWorkspacesFile(), JSON.stringify(map, null, 2) + '\n');
164
177
  return { ok: true, error: null };
165
178
  } catch (err) {
166
179
  const msg = String(/** @type {any} */ (err)?.message ?? err);
@@ -180,17 +193,17 @@ export function getSessionWorkspace(/** @type {any} */ sessionName) {
180
193
  return loadSessionWorkspaces()[sessionName]?.dir || null;
181
194
  }
182
195
 
183
- export function setSessionWorkspace(/** @type {any} */ sessionName, /** @type {any} */ dir, wsName = null) {
196
+ export async function setSessionWorkspace(/** @type {any} */ sessionName, /** @type {any} */ dir, wsName = null) {
184
197
  // v0.4.7(T19):多标签页/多任务并行时会话级映射同样会被并发改写
185
- return withFileLockSync(sessionWorkspacesFile() + '.lock', () => {
198
+ return withFileLock(sessionWorkspacesFile() + '.lock', () => {
186
199
  const map = loadSessionWorkspaces();
187
200
  map[sessionName] = { dir: path.resolve(dir), name: wsName || workspaceForDir(dir)?.name || null, at: Date.now() };
188
201
  saveSessionWorkspaces(map);
189
202
  });
190
203
  }
191
204
 
192
- export function removeSessionWorkspace(/** @type {any} */ sessionName) {
193
- return withFileLockSync(sessionWorkspacesFile() + '.lock', () => {
205
+ export async function removeSessionWorkspace(/** @type {any} */ sessionName) {
206
+ return withFileLock(sessionWorkspacesFile() + '.lock', () => {
194
207
  const map = loadSessionWorkspaces();
195
208
  if (!map[sessionName]) return false;
196
209
  delete map[sessionName];
@@ -200,8 +213,8 @@ export function removeSessionWorkspace(/** @type {any} */ sessionName) {
200
213
  }
201
214
 
202
215
  // 会话改名时迁移映射(记录保留)
203
- export function moveSessionWorkspace(/** @type {any} */ oldName, /** @type {any} */ newName) {
204
- return withFileLockSync(sessionWorkspacesFile() + '.lock', () => {
216
+ export async function moveSessionWorkspace(/** @type {any} */ oldName, /** @type {any} */ newName) {
217
+ return withFileLock(sessionWorkspacesFile() + '.lock', () => {
205
218
  const map = loadSessionWorkspaces();
206
219
  if (!map[oldName]) return false;
207
220
  map[newName] = map[oldName];