dsh-any-agent 0.3.3 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/hub.mjs CHANGED
@@ -14,6 +14,8 @@
14
14
  */
15
15
 
16
16
  import {
17
+ copyFileSync,
18
+ cpSync,
17
19
  mkdirSync,
18
20
  readFileSync,
19
21
  writeFileSync,
@@ -41,13 +43,17 @@ import {
41
43
  SENTINEL_INCLUDE_REASONING,
42
44
  SENTINEL_TTL_MS,
43
45
  STATE_FILE,
46
+ LEGACY_STATE_FILE,
47
+ USING_DEFAULT_STATE,
44
48
  DSH_HOME,
49
+ assertWritableDir,
45
50
  defaultApprovalPolicy,
46
51
  } from './config.mjs';
47
52
  import { AcpProcess } from './acp.mjs';
48
53
  import { readConversationMeta } from './titles.mjs';
49
54
  import { registerWorkspace, findSessionHeader } from './workspace.mjs';
50
55
  import {
56
+ LOCKS_DIR,
51
57
  classifyHolder,
52
58
  describeLeaseConflict,
53
59
  heartbeat as lockHeartbeat,
@@ -240,13 +246,18 @@ export function sanitizeResultForSentinel(result) {
240
246
  if (!result || typeof result !== 'object') return result ?? null;
241
247
  if (SENTINEL_INCLUDE_REASONING) return result;
242
248
  const { thinking, ...rest } = result;
249
+ // 正文/错误/标题同样可能夹带密钥;哨兵是落盘副本,统一套脱敏。
250
+ for (const key of ['answer', 'error', 'title']) {
251
+ if (typeof rest[key] === 'string') rest[key] = sanitizeApprovalContext(rest[key], key);
252
+ }
243
253
  // 工具输入里会有完整命令、路径,甚至写入的文件内容。哨兵会保留 7 天,
244
254
  // 所以按审批上下文同一套规则脱敏(密钥/令牌/密码类字段与常见内联字面量)。
245
255
  if (Array.isArray(rest.tools_used)) {
246
256
  rest.tools_used = rest.tools_used.map((t) => {
247
257
  if (!t || typeof t !== 'object') return t;
248
258
  const { input, ...meta } = t;
249
- return input === undefined ? meta : { ...meta, input: sanitizeApprovalContext(input) };
259
+ const safeMeta = sanitizeApprovalContext(meta);
260
+ return input === undefined ? safeMeta : { ...safeMeta, input: sanitizeApprovalContext(input) };
250
261
  });
251
262
  }
252
263
  // 始终给出布尔值:调用方才能区分“思考被剥离”与“本来没有思考”。
@@ -271,6 +282,8 @@ export class Conversation {
271
282
  model: state.model ?? null,
272
283
  };
273
284
  this.lastConfigApplied = null;
285
+ /** 是否已回读并确认会话记录的权限档(null=尚未核对,true/false=结果)。 */
286
+ this.permissionVerified = null;
274
287
  /** 工作区登记结果(决定你的 DSH GUI 里能不能看到这个会话)。 */
275
288
  this.workspace = state.workspace ?? null;
276
289
  this.#hub = hub;
@@ -350,6 +363,8 @@ export class Conversation {
350
363
  title_hint: this.titleHint ?? null,
351
364
  cwd: this.cwd,
352
365
  permission: this.permission,
366
+ /** true=已回读会话记录且一致;false/null=缓存暂时没有记录(read-only 仍由审批层兜底)。 */
367
+ permission_verified: this.permissionVerified,
353
368
  on_approval: this.onApproval,
354
369
  alive: !!this.proc?.alive,
355
370
  busy: this.busy,
@@ -418,6 +433,8 @@ export class Conversation {
418
433
  // 新进程不记得会话级配置(思考深度/模型),resume 后必须重套
419
434
  this.lastConfigApplied = await applySessionConfig(proc, this.id, this.cfg);
420
435
  for (const f of this.lastConfigApplied.failed) this.#hub.log.info(`[conv ${this.id}] ${f}`);
436
+ // ★ 回读会话自己的权限记录,防止 profile defaultPreset 无声提权。
437
+ await this.verifyPermissionRecorded();
421
438
  // 锁到手了,这时才登记;从此别人能查到"锁在我们手上"
422
439
  writeMarker(this.id, { childPid: proc.pid, cwd: this.cwd });
423
440
  this.lockConflict = null; // 拿到锁了,之前的撞锁记录作废
@@ -465,6 +482,39 @@ export class Conversation {
465
482
  }
466
483
  }
467
484
 
485
+ /**
486
+ * 核对**会话自己记录**的权限档是否与请求一致。
487
+ *
488
+ * 为什么不能只信入参:DSH profile 里的 `permission.defaultPreset` 会在
489
+ * session/new 时覆盖 `DSH_PERMISSION_MODE`(test/permission.mjs 实测抓到过
490
+ * read-only 被静默提升)。投影缓存可能稍后才刷盘,所以短轮询;读不到时
491
+ * 不阻断(read-only 仍有审批层的硬拒绝兜底),但读到不一致就立刻失败。
492
+ */
493
+ async verifyPermissionRecorded({ attempts = 6, delayMs = 250 } = {}) {
494
+ for (let i = 0; i < attempts; i++) {
495
+ const meta = readConversationMeta(this.id);
496
+ const recorded = meta?.permissions?.preset;
497
+ if (recorded) {
498
+ if (!PERMISSION_TIERS.includes(recorded) || recorded !== this.permission) {
499
+ throw new Error(
500
+ `会话实际权限档为 ${JSON.stringify(recorded)},与请求的 ${this.permission} 不一致。\n` +
501
+ ' 这通常意味着 DSH profile 里写了 permission.defaultPreset,覆盖了启动时传入的权限档。\n' +
502
+ ` 请检查 ${join(DSH_HOME, 'profiles', process.env.DSH_MCP_PROFILE ?? 'dsh-mcp', 'cordis.patch.yml')},` +
503
+ '删掉 permission.defaultPreset 后重试。',
504
+ );
505
+ }
506
+ this.permissionVerified = true;
507
+ return recorded;
508
+ }
509
+ if (i < attempts - 1) await new Promise((r) => setTimeout(r, delayMs));
510
+ }
511
+ this.permissionVerified = false;
512
+ this.#hub.log.info(
513
+ `[conv ${this.id}] 投影缓存里暂时没有权限记录,跳过运行期核对(read-only 仍由审批层强制拒绝)`,
514
+ );
515
+ return null;
516
+ }
517
+
468
518
  /**
469
519
  * 抢占写锁。
470
520
  *
@@ -806,7 +856,9 @@ export class Conversation {
806
856
  // 之前只在"调用方没在原地等"时才写,于是插话那一轮没有哨兵 —— 调用方的
807
857
  // 等待逻辑(等文件出现)就永远空等,即使 run 早已 status=done(真实事故)。
808
858
  // 统一规则也消除了整类"有 run_id 却没有哨兵"的坑。
809
- this.#hub.writeRunSentinel(this.id, runRecord);
859
+ const sentinelFile = this.#hub.writeRunSentinel(this.id, runRecord);
860
+ result.sentinel_file = sentinelFile ?? this.#hub.runSentinelPath(this.id, runRecord.run_id);
861
+ if (!sentinelFile) result.sentinel_error = runRecord.sentinel_error ?? '哨兵文件写入失败';
810
862
  return result;
811
863
  } catch (e) {
812
864
  this.#hub.log.info(`[conv ${this.id}] 回合收尾异常(已兜底): ${e.message}`);
@@ -830,7 +882,9 @@ export class Conversation {
830
882
  runRecord.status = 'error';
831
883
  runRecord.error = fallback.error;
832
884
  runRecord.result = fallback;
833
- this.#hub.writeRunSentinel(this.id, runRecord);
885
+ const sentinelFile = this.#hub.writeRunSentinel(this.id, runRecord);
886
+ fallback.sentinel_file = sentinelFile ?? this.#hub.runSentinelPath(this.id, runRecord.run_id);
887
+ if (!sentinelFile) fallback.sentinel_error = runRecord.sentinel_error ?? '哨兵文件写入失败';
834
888
  return fallback;
835
889
  }
836
890
  };
@@ -1051,8 +1105,17 @@ export class Conversation {
1051
1105
 
1052
1106
  /** 收集当前工作区的 git 变更摘要(非 git 目录返回 null)。 */
1053
1107
  export function gitStatus(cwd) {
1108
+ if (process.env.DSH_MCP_GIT_STATUS === '0') return null;
1054
1109
  try {
1055
- const r = spawnSync('git', ['status', '--porcelain'], { cwd, encoding: 'utf8', timeout: 8000 });
1110
+ // ★ 在不可信仓库里 `git status` 可能触发仓库配置的 fsmonitor/filter;这些
1111
+ // 命令发生在 DSH 沙箱与审批层之外。禁用可选锁与 fsmonitor,并允许用
1112
+ // DSH_MCP_GIT_STATUS=0 完全关掉。
1113
+ const r = spawnSync('git', ['-c', 'core.fsmonitor=false', 'status', '--porcelain', '--no-optional-locks'], {
1114
+ cwd,
1115
+ encoding: 'utf8',
1116
+ timeout: 8000,
1117
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
1118
+ });
1056
1119
  if (r.status !== 0 || !r.stdout) return null;
1057
1120
  const lines = r.stdout.split('\n').filter(Boolean);
1058
1121
  if (!lines.length) return null;
@@ -1120,7 +1183,10 @@ export class Hub {
1120
1183
  constructor({ log } = {}) {
1121
1184
  this.conversations = new Map();
1122
1185
  this.log = log ?? NOOP_LOG;
1123
- this.approvalTimeoutMs = Number(process.env.DSH_MCP_APPROVAL_TIMEOUT_MS ?? 5 * 60 * 1000);
1186
+ const rawApprovalTimeout = Number(process.env.DSH_MCP_APPROVAL_TIMEOUT_MS ?? 5 * 60 * 1000);
1187
+ // 空串会变成 0 / NaN → setTimeout(0) 会把 ask 审批立刻取消;统一回落到 5 分钟。
1188
+ this.approvalTimeoutMs =
1189
+ Number.isFinite(rawApprovalTimeout) && rawApprovalTimeout > 0 ? rawApprovalTimeout : 5 * 60 * 1000;
1124
1190
  this.#reaper = null;
1125
1191
  }
1126
1192
 
@@ -1129,6 +1195,30 @@ export class Hub {
1129
1195
  #probeCache = null;
1130
1196
 
1131
1197
  load() {
1198
+ // ★ 启动即确认三个关键目录可写。以前"写失败只记 silent 日志"会让服务
1199
+ // 看起来正常,但注册表/哨兵/锁登记全部丢失,异步等待永远醒不过来。
1200
+ assertWritableDir(dirname(STATE_FILE), '状态目录');
1201
+ assertWritableDir(RUNS_DIR, '哨兵目录');
1202
+ assertWritableDir(LOCKS_DIR, '锁登记目录');
1203
+
1204
+ // 旧版本的默认状态文件在包安装目录里;一次性迁移到新的用户可写目录。
1205
+ // 只在没有显式设置 DSH_MCP_STATE 时做,避免覆盖用户指定的路径。
1206
+ if (USING_DEFAULT_STATE && !existsSync(STATE_FILE) && existsSync(LEGACY_STATE_FILE)) {
1207
+ try {
1208
+ copyFileSync(LEGACY_STATE_FILE, STATE_FILE);
1209
+ this.log.info(`[hub] 已迁移旧状态文件: ${LEGACY_STATE_FILE} → ${STATE_FILE}`);
1210
+ // 侧车元数据(审批策略/思考深度/标题/轮数)也一并搬过来;
1211
+ // 否则旧会话按 id 重建时会悄悄回落到默认值。
1212
+ const legacyMeta = join(dirname(LEGACY_STATE_FILE), 'meta');
1213
+ if (existsSync(legacyMeta) && !existsSync(META_DIR)) {
1214
+ cpSync(legacyMeta, META_DIR, { recursive: true });
1215
+ this.log.info(`[hub] 已迁移旧元数据侧车: ${legacyMeta} → ${META_DIR}`);
1216
+ }
1217
+ } catch (e) {
1218
+ this.log.info(`[hub] 旧状态文件迁移失败(继续用新路径): ${e.message}`);
1219
+ }
1220
+ }
1221
+
1132
1222
  const stale = pruneStaleTombstones();
1133
1223
  if (stale) this.log.info(`[hub] 清理 ${stale} 个失效的 forget 墓碑`);
1134
1224
  if (!existsSync(STATE_FILE)) return;
@@ -1186,24 +1276,34 @@ export class Hub {
1186
1276
  return join(RUNS_DIR, conversationId, `${runId}.json`);
1187
1277
  }
1188
1278
 
1189
- /** 写完成哨兵(原子)。失败只记日志,绝不影响主流程。 */
1279
+ /**
1280
+ * 写完成哨兵(原子)。
1281
+ * @returns {string|null} 成功返回文件路径;失败返回 null,并把原因记进 run.sentinel_error。
1282
+ *
1283
+ * 失败不阻断回合收尾,但**必须让调用方看得见**:以前只记 silent 日志,
1284
+ * 调用方会拿着一个永远不会出现的路径一直等。
1285
+ */
1190
1286
  writeRunSentinel(conversationId, run) {
1287
+ const file = this.runSentinelPath(conversationId, run.run_id);
1191
1288
  try {
1192
- const file = this.runSentinelPath(conversationId, run.run_id);
1193
1289
  mkdirSync(dirname(file), { recursive: true });
1194
1290
  const payload = {
1195
1291
  conversation_id: conversationId,
1196
1292
  run_id: run.run_id,
1197
1293
  status: run.status ?? 'unknown',
1198
1294
  ended_at: run.ended_at ?? Date.now(),
1199
- error: run.error ?? run.result?.error ?? null,
1295
+ error: sanitizeApprovalContext(run.error ?? run.result?.error ?? null, 'error'),
1200
1296
  result: sanitizeResultForSentinel(run.result ?? null),
1201
1297
  };
1202
1298
  const tmp = `${file}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
1203
1299
  writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8');
1204
1300
  renameSync(tmp, file);
1301
+ return file;
1205
1302
  } catch (e) {
1206
- this.log.info(`[hub] 写完成哨兵失败: ${e.message}`);
1303
+ const msg = `写完成哨兵失败(${file}): ${e.message}`;
1304
+ run.sentinel_error = msg;
1305
+ this.log.info(`[hub] ${msg}`);
1306
+ return null;
1207
1307
  }
1208
1308
  }
1209
1309
 
@@ -1287,12 +1387,15 @@ export class Hub {
1287
1387
  const recordedPreset = meta?.permissions?.preset;
1288
1388
  const recordedPermission = PERMISSION_TIERS.includes(recordedPreset) ? recordedPreset : null;
1289
1389
  // 安全优先:会话记录说 read-only 就绝不能被侧车“提升”为可写档。
1390
+ // ★ 档位未知(没有投影缓存、也没有本服务侧车)时 **fail-closed 到 read-only**。
1391
+ // 以前回落到 DEFAULT_PERMISSION(danger-full-access),等于把一个可能是
1392
+ // 只读的外部会话悄悄提升为完全访问。宁可让用户显式新建会话,也不要无声提权。
1290
1393
  const permission =
1291
1394
  recordedPermission === 'read-only'
1292
1395
  ? 'read-only'
1293
1396
  : PERMISSION_TIERS.includes(persisted?.permission)
1294
1397
  ? persisted.permission
1295
- : (recordedPermission ?? DEFAULT_PERMISSION);
1398
+ : (recordedPermission ?? 'read-only');
1296
1399
  const recordedApproval = meta?.permissions?.approval;
1297
1400
  let onApproval = APPROVAL_POLICIES.includes(persisted?.onApproval)
1298
1401
  ? persisted.onApproval
@@ -1452,6 +1555,8 @@ export class Hub {
1452
1555
  ` 请检查 ${provider}/${model} 是否在 profile 中声明;不传这两个参数可使用 profile 默认模型。`,
1453
1556
  );
1454
1557
  }
1558
+ // ★ 回读会话自己的权限记录;不一致会在这里失败并走 catch(停进程、删登记)。
1559
+ await conv.verifyPermissionRecorded();
1455
1560
  // 登记工作区,否则这些会话在你的 DSH GUI 里看不到。
1456
1561
  // ★ 必须容错:registerWorkspace 会写 workspace.json(磁盘满/被占时会抛),
1457
1562
  // 而它在 this.conversations.set 之前 —— 抛出去就会**丢掉这个会话并泄漏进程**。
@@ -1619,10 +1724,17 @@ export class Hub {
1619
1724
  * 场景:默认"永不回收"下每个用过的会话会常驻一个 DSH 进程(实测约 300-480MB ✗)。
1620
1725
  * 一批活干完了就调它,内存立刻归零 ✓;下次派活按需 resume ✓。
1621
1726
  */
1622
- async releaseAll() {
1727
+ async releaseAll({ force = false } = {}) {
1623
1728
  const released = [];
1729
+ const skippedBusy = [];
1624
1730
  for (const c of this.conversations.values()) {
1625
1731
  if (!c.proc?.alive) continue;
1732
+ // ★ 默认不打断正在跑的回合:all=true 是"腾内存",不是"取消任务"。
1733
+ // 确实要连跑着的也停掉时传 force=true。
1734
+ if (c.busy && !force) {
1735
+ skippedBusy.push(c.id);
1736
+ continue;
1737
+ }
1626
1738
  const proc = c.proc;
1627
1739
  c.proc = null;
1628
1740
  await proc.stop().catch(() => {});
@@ -1630,7 +1742,7 @@ export class Hub {
1630
1742
  released.push(c.id);
1631
1743
  }
1632
1744
  this.save();
1633
- return { released: released.length, conversations: released };
1745
+ return { released: released.length, conversations: released, skipped_busy: skippedBusy };
1634
1746
  }
1635
1747
 
1636
1748
  async shutdown() {
package/src/tools.mjs CHANGED
@@ -45,7 +45,7 @@ export const TOOL_DEFS = [
45
45
  type: 'string',
46
46
  enum: PERMISSION_TIERS,
47
47
  description:
48
- `权限档。默认 ${DEFAULT_PERMISSION}。read-only 适合分析不信任样本(如恶意软件);workspace-write 越界操作会冒泡为待决审批。` +
48
+ `权限档。默认 ${DEFAULT_PERMISSION}。read-only 适合分析不信任样本(如恶意软件);workspace-write 由 DSH 沙箱限制可写范围,越界请求默认 auto-allow,想在调用方挂起审批请传 on_approval=ask。` +
49
49
  '**档位在创建时定下**:DSH 设计上"resume 的会话保留它自己记录的权限",所以事后改这个参数不会改变已有会话,要换档需新建。' +
50
50
  '(本档位是**真实生效**的 —— 由启动时传入的 env 决定,并有 test/permission.mjs 对着会话自己的记录逐档核验。)',
51
51
  },
@@ -188,7 +188,7 @@ export const TOOL_DEFS = [
188
188
  enum: REASONING_MODES,
189
189
  description: `思考内容展示档位,默认 ${DEFAULT_REASONING}。`,
190
190
  },
191
- timeout_ms: { type: 'number', description: '新回合的**等待**上限(毫秒),**默认 0 = 一直等**;queue 模式也用它限制等待上一轮结束。到期转后台,不取消回合。' },
191
+ timeout_ms: { type: 'number', description: '新回合的**等待**上限(毫秒),**默认 0 = 一直等**;queue 模式也用它限制等待上一轮结束。忙时等待超时会报错(本次插话未发送,会话不受损);空闲时到期只转后台,不取消回合。' },
192
192
  },
193
193
  ['conversation_id', 'message'],
194
194
  ),
@@ -229,14 +229,19 @@ export const TOOL_DEFS = [
229
229
  '会话本体留在磁盘上,之后仍可 resume;**forget=true 会写墓碑**:从注册表移除,此后按 id 查询明确报“已被 forget”' +
230
230
  '(想恢复可按错误里的路径删除墓碑文件,默认在 `$DSH_HOME/.dsh-any-agent-forgotten/<id>.json`)。' +
231
231
  '★ 用完了想一次性腾内存就传 all=true(每个会话约 300-480MB;默认 `MAX_LIVE=8` 会回收最久未用者,' +
232
- 'all=true 可立即全部释放,之后随时 resume)。',
232
+ 'all=true 会释放**空闲**会话,正在跑回合的默认跳过;确实要连跑着的也停掉请传 force=true)。' +
233
+ '对单个会话 release 会停掉其 DSH 进程;若它正在跑回合,该回合会被中断。',
233
234
  inputSchema: S(
234
235
  {
235
236
  conversation_id: CONV_ID,
236
237
  forget: { type: 'boolean', description: '同时从本服务的注册表里移除,默认 false。' },
237
238
  all: {
238
239
  type: 'boolean',
239
- description: '忽略 conversation_id,释放**所有**活着的会话进程(腾内存用)。默认 false。',
240
+ description: '忽略 conversation_id,释放所有**空闲**会话进程(腾内存用)。正在跑回合的默认跳过。默认 false。',
241
+ },
242
+ force: {
243
+ type: 'boolean',
244
+ description: '仅配合 all=true:连正在跑回合的会话也停掉(会中断那些回合)。默认 false。',
240
245
  },
241
246
  },
242
247
  [],
@@ -587,12 +592,15 @@ export function createHandlers(hub, opts = {}) {
587
592
  // ★ all=true:一次性放掉所有活着的会话进程(腾内存)。
588
593
  // 无损:会话日志都在磁盘上,之后照样 resume。
589
594
  if (args.all === true) {
590
- const r = await hub.releaseAll();
595
+ const r = await hub.releaseAll({ force: args.force === true });
591
596
  return {
592
- structured: { released: r.released, conversations: r.conversations },
597
+ structured: { released: r.released, conversations: r.conversations, skipped_busy: r.skipped_busy },
593
598
  text:
594
- `已释放 ${r.released} 个会话进程(写锁全部让出,内存立刻回收)。` +
595
- `\n会话日志都在磁盘上,之后派活会自动 resume;其中一个都没在跑时 released=0。`,
599
+ `已释放 ${r.released} 个空闲会话进程(写锁让出,内存回收)。` +
600
+ (r.skipped_busy.length
601
+ ? `\n跳过 ${r.skipped_busy.length} 个正在跑回合的会话;要连它们一起停掉请传 force=true。`
602
+ : '') +
603
+ `\n会话日志都在磁盘上,之后派活会自动 resume;没有一个可释放时 released=0。`,
596
604
  };
597
605
  }
598
606
  const conv = hub.get(args.conversation_id);
package/src/workspace.mjs CHANGED
@@ -17,7 +17,7 @@
17
17
 
18
18
  import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, readdirSync, rmSync, realpathSync } from 'node:fs';
19
19
  import { randomUUID } from 'node:crypto';
20
- import { basename, dirname, join } from 'node:path';
20
+ import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path';
21
21
  import { tmpdir } from 'node:os';
22
22
  import { zstdDecompressSync } from 'node:zlib';
23
23
  import { DSH_HOME, REGISTER_WORKSPACE } from './config.mjs';
@@ -42,11 +42,46 @@ export function canonicalPath(p) {
42
42
  }
43
43
  }
44
44
 
45
- /** 路径是否位于系统临时目录下(这类"工作区"不该出现在 GUI 里)。 */
45
+ /** `child` 是否真的在 `parent` 里面(按路径分段,不能用字符串前缀)。 */
46
+ function isInside(parent, child) {
47
+ try {
48
+ const rel = relative(parent, child);
49
+ if (!rel) return true;
50
+ return rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel);
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * 路径是否位于系统临时目录下(这类"工作区"不该出现在 GUI 里)。
58
+ *
59
+ * ★ 必须按路径分段判断,不能用 `startsWith(tmpdir())`:那样
60
+ * `/tmpfoo`、`%LOCALAPPDATA%\Temporary\proj` 都会被当成临时目录,
61
+ * 而清理逻辑会据此**递归删除真实会话目录**(真实数据丢失级 bug)。
62
+ */
46
63
  export function isTempPath(p) {
47
64
  try {
48
- const t = normPath(tmpdir());
49
- return normPath(p).startsWith(t);
65
+ return Boolean(p) && isInside(tmpdir(), p);
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * 是否是本仓库测试自己造的临时工作区。
73
+ *
74
+ * 只认系统临时目录下、第一段目录名以 `dsh-any-agent-` 开头的路径
75
+ * (测试统一用 `mkdtempSync(join(tmpdir(), 'dsh-any-agent-...'))`)。
76
+ * 这不是"所有临时目录"——用户完全可能把真实项目放在 %TEMP% 的同级目录里。
77
+ */
78
+ export function isTestTempPath(p) {
79
+ try {
80
+ if (!p || !isTempPath(p)) return false;
81
+ const rel = relative(tmpdir(), p);
82
+ if (!rel || rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel)) return false;
83
+ const first = rel.split(/[\\/]/).filter(Boolean)[0] ?? '';
84
+ return /^dsh-any-agent-/.test(first);
50
85
  } catch {
51
86
  return false;
52
87
  }
@@ -87,16 +122,25 @@ function emptyDoc() {
87
122
  };
88
123
  }
89
124
 
125
+ /**
126
+ * 读取工作区注册表。
127
+ *
128
+ * ★ 解析失败必须 **fail-closed**:以前会静默回落到空结构,下一次 register/prune
129
+ * 就会把 GUI 的整个工作区列表覆盖掉(不可恢复)。现在抛错,并且绝不写盘;
130
+ * 调用方(register/backfill/prune)各自把错误返回给上层。
131
+ */
90
132
  function readDoc() {
91
- let doc = emptyDoc();
92
- if (existsSync(WORKSPACE_FILE)) {
93
- try {
94
- const parsed = JSON.parse(readFileSync(WORKSPACE_FILE, 'utf8'));
95
- if (parsed && typeof parsed === 'object') doc = parsed;
96
- } catch {
97
- /* 坏文件就用空结构重建,避免把 GUI 的注册表彻底写坏 */
98
- }
133
+ if (!existsSync(WORKSPACE_FILE)) return emptyDoc();
134
+ let parsed;
135
+ try {
136
+ parsed = JSON.parse(readFileSync(WORKSPACE_FILE, 'utf8'));
137
+ } catch (e) {
138
+ throw new Error(`workspace.json 无法解析,已保持原样不写入: ${WORKSPACE_FILE}(${e.message})`);
99
139
  }
140
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
141
+ throw new Error(`workspace.json 结构异常(不是对象),已保持原样不写入: ${WORKSPACE_FILE}`);
142
+ }
143
+ const doc = parsed;
100
144
  doc.global ??= emptyDoc().global;
101
145
  doc.global.workspaceIds ??= [];
102
146
  doc.global.archivedSessionIds ??= [];
@@ -108,9 +152,17 @@ function readDoc() {
108
152
 
109
153
  function writeDoc(doc) {
110
154
  mkdirSync(dirname(WORKSPACE_FILE), { recursive: true });
111
- const tmp = `${WORKSPACE_FILE}.dshmcp.tmp`;
112
- writeFileSync(tmp, JSON.stringify(doc, null, 2), 'utf8');
113
- renameSync(tmp, WORKSPACE_FILE);
155
+ // 临时文件名必须唯一:固定 `.dshmcp.tmp` 会让两个实例/CLI 并发写互相覆盖。
156
+ const tmp = `${WORKSPACE_FILE}.${process.pid}.${randomUUID()}.tmp`;
157
+ try {
158
+ writeFileSync(tmp, JSON.stringify(doc, null, 2), 'utf8');
159
+ renameSync(tmp, WORKSPACE_FILE);
160
+ } catch (e) {
161
+ try {
162
+ rmSync(tmp, { force: true });
163
+ } catch {}
164
+ throw e;
165
+ }
114
166
  }
115
167
 
116
168
  /** 在已读入的 doc 里找到(或新建)某路径对应的工作区条目。 */
@@ -172,10 +224,17 @@ function readHeader(file) {
172
224
  }
173
225
  }
174
226
 
175
- /** 扫描磁盘上所有会话,按 header.cwd 归组。数据源是会话头,不是目录名(目录名有损)。 */
227
+ /**
228
+ * 扫描磁盘上所有会话,按 header.cwd 归组。数据源是会话头,不是目录名(目录名有损)。
229
+ *
230
+ * 会话根目录**不可读时抛错**,而不是返回空 Map —— 否则 backfill 的
231
+ * `pruneMissing` 会以为"所有会话都消失了",把工作区注册表里的 sessionIds 清空。
232
+ */
176
233
  export function scanSessions() {
177
234
  const byCwd = new Map();
178
- if (!existsSync(SESSIONS_ROOT)) return byCwd;
235
+ if (!existsSync(SESSIONS_ROOT)) {
236
+ throw new Error(`会话目录不存在或不可读: ${SESSIONS_ROOT}`);
237
+ }
179
238
  for (const bucket of readdirSync(SESSIONS_ROOT)) {
180
239
  const bucketPath = join(SESSIONS_ROOT, bucket);
181
240
  let dirs;
@@ -210,7 +269,13 @@ export function scanSessions() {
210
269
  export function findSessionHeader(sessionId) {
211
270
  if (typeof sessionId !== 'string' || !/^[A-Za-z0-9._-]{4,80}$/.test(sessionId)) return null;
212
271
  if (!existsSync(SESSIONS_ROOT)) return null;
213
- for (const bucket of readdirSync(SESSIONS_ROOT)) {
272
+ let buckets;
273
+ try {
274
+ buckets = readdirSync(SESSIONS_ROOT);
275
+ } catch {
276
+ return null;
277
+ }
278
+ for (const bucket of buckets) {
214
279
  const f = join(SESSIONS_ROOT, bucket, sessionId, 'session.v4.jsonl.zstd');
215
280
  if (!existsSync(f)) continue;
216
281
  const header = readHeader(f);
@@ -224,9 +289,6 @@ export function findSessionHeader(sessionId) {
224
289
  * 用于修复"本功能上线前创建的会话在 GUI 里看不到"。
225
290
  */
226
291
  export function backfillWorkspaces({ includeTemp = false, dryRun = false, pruneMissing = true } = {}) {
227
- const byCwd = scanSessions();
228
- const doc = readDoc();
229
- const nowIso = new Date().toISOString();
230
292
  const stats = {
231
293
  workspaces_created: 0,
232
294
  workspaces_existing: 0,
@@ -236,7 +298,19 @@ export function backfillWorkspaces({ includeTemp = false, dryRun = false, pruneM
236
298
  temp_skipped_workspaces: 0,
237
299
  /** 磁盘上已不存在、被从注册表摘掉的 sessionId 数量。 */
238
300
  sessions_pruned: 0,
301
+ /** 读取失败时的说明;非空表示**没有写盘**。 */
302
+ error: null,
239
303
  };
304
+ let byCwd;
305
+ let doc;
306
+ try {
307
+ byCwd = scanSessions();
308
+ doc = readDoc();
309
+ } catch (e) {
310
+ // 看不到会话存储 / 注册表坏了时绝不做对账:那会把所有 sessionIds 清空。
311
+ return { ...stats, error: e.message };
312
+ }
313
+ const nowIso = new Date().toISOString();
240
314
 
241
315
  /** 磁盘上真实存在的会话 id 全集。 */
242
316
  const live = new Set();
@@ -286,12 +360,14 @@ export function backfillWorkspaces({ includeTemp = false, dryRun = false, pruneM
286
360
  * 3) ids 里显式指定的
287
361
  *
288
362
  * @param {object} opts
289
- * @param {boolean} [opts.tempOnly] 只删临时目录下的。自动清理(测试套件收尾)用这条,
290
- * 绝不动桌面或真实项目里的任何会话。
291
- * @param {boolean} [opts.idsOnly] **只删 ids 里点名的**,忽略上面两条规则。
363
+ * @param {boolean} [opts.testOnly] 只删**本仓库测试造的**临时目录(`tmpdir()/dsh-any-agent-*`)
364
+ * 与桌面 `dsh-any-agent-test-*`。自动清理(测试套件收尾)必须用这条。
365
+ * @param {boolean} [opts.tempOnly] 删除**所有** cwd 在系统临时目录下的会话。
366
+ * 只给显式人工命令用;不要放进自动清理 —— 用户完全可能把真实项目放在临时目录。
367
+ * @param {boolean} [opts.idsOnly] **只删 ids 里点名的**,忽略上面三条规则。
292
368
  * 要精确删某几个会话时用这条(否则一条临时目录规则会把别的也一起带走 —— 真实踩过)。
293
369
  */
294
- export function purgeTestSessions({ dryRun = false, ids = [], tempOnly = false, idsOnly = false } = {}) {
370
+ export function purgeTestSessions({ dryRun = false, ids = [], testOnly = false, tempOnly = false, idsOnly = false } = {}) {
295
371
  const explicit = new Set(ids);
296
372
  const victims = [];
297
373
  if (existsSync(SESSIONS_ROOT)) {
@@ -304,12 +380,15 @@ export function purgeTestSessions({ dryRun = false, ids = [], tempOnly = false,
304
380
  continue;
305
381
  }
306
382
  for (const d of dirs) {
307
- if (!d.isDirectory()) continue;
383
+ // 符号链接一律不碰:rmSync 对链接的行为依赖平台,且链接目标可能不在会话目录内。
384
+ if (!d.isDirectory() || d.isSymbolicLink()) continue;
308
385
  const dir = join(bucketPath, d.name);
386
+ // 兜底:只允许删 SESSIONS_ROOT 下面的目录(结构上已经如此,防未来改动)。
387
+ if (!isInside(SESSIONS_ROOT, dir)) continue;
309
388
  const header = readHeader(join(dir, 'session.v4.jsonl.zstd'));
310
389
  if (!header?.id) continue;
311
390
  const cwd = header.cwd ?? '';
312
- const isTemp = isTempPath(cwd);
391
+ const isTemp = testOnly ? isTestTempPath(cwd) : isTempPath(cwd);
313
392
  const isTestDesktop = !tempOnly && /[\\/]Desktop[\\/]dsh-any-agent-test-[AB]([\\/]|$)/i.test(cwd);
314
393
  const isExplicit = explicit.has(header.id);
315
394
  // idsOnly:只认点名,别的规则全部作废
@@ -318,7 +397,7 @@ export function purgeTestSessions({ dryRun = false, ids = [], tempOnly = false,
318
397
  id: header.id,
319
398
  dir,
320
399
  cwd,
321
- reason: isExplicit ? '显式指定' : isTemp ? '临时目录' : '验收测试目录',
400
+ reason: isExplicit ? '显式指定' : isTestDesktop ? '验收测试目录' : testOnly ? '测试临时目录' : '临时目录',
322
401
  origin: header.origin ?? null,
323
402
  });
324
403
  }
@@ -339,12 +418,14 @@ export function purgeTestSessions({ dryRun = false, ids = [], tempOnly = false,
339
418
  }
340
419
  }
341
420
  // 清掉因删空而残留的空分桶目录(每个工作区一个桶,空桶会一直堆着)
342
- for (const bucket of readdirSync(SESSIONS_ROOT)) {
343
- const p = join(SESSIONS_ROOT, bucket);
344
- try {
345
- if (readdirSync(p).length === 0) rmSync(p, { recursive: true, force: true });
346
- } catch {
347
- /* 桶被占用或权限问题就跳过 */
421
+ if (existsSync(SESSIONS_ROOT)) {
422
+ for (const bucket of readdirSync(SESSIONS_ROOT)) {
423
+ const p = join(SESSIONS_ROOT, bucket);
424
+ try {
425
+ if (readdirSync(p).length === 0) rmSync(p, { recursive: true, force: true });
426
+ } catch {
427
+ /* 桶被占用或权限问题就跳过 */
428
+ }
348
429
  }
349
430
  }
350
431
  // 删完之后把注册表里对应的 id 也摘掉
@@ -355,7 +436,12 @@ export function purgeTestSessions({ dryRun = false, ids = [], tempOnly = false,
355
436
 
356
437
  /** 清掉"没有会话且路径已不存在"的空工作区条目。 */
357
438
  export function pruneEmptyWorkspaces({ dryRun = false } = {}) {
358
- const doc = readDoc();
439
+ let doc;
440
+ try {
441
+ doc = readDoc();
442
+ } catch (e) {
443
+ return { removed: [], error: e.message };
444
+ }
359
445
  const removed = [];
360
446
  for (const id of Object.keys(doc.tables.workspaces)) {
361
447
  const ws = doc.tables.workspaces[id];
@@ -379,13 +465,20 @@ export function pruneEmptyWorkspaces({ dryRun = false } = {}) {
379
465
  * 启发式,实测不一定兜住)。这里按**路径模式**确定性清理,绝不碰真实项目目录。
380
466
  */
381
467
  export function pruneTestWorkspaces({ dryRun = false } = {}) {
382
- const doc = readDoc();
468
+ let doc;
469
+ try {
470
+ doc = readDoc();
471
+ } catch (e) {
472
+ return { removed: [], error: e.message };
473
+ }
383
474
  const removed = [];
384
475
  for (const id of Object.keys(doc.tables.workspaces)) {
385
476
  const ws = doc.tables.workspaces[id];
386
477
  const p = ws?.path ?? '';
387
478
  if (!p) continue;
388
- if (!isTempPath(p) && !isTestDesktopPath(p)) continue;
479
+ // ★ 只删测试自己造的临时目录(dsh-any-agent-*)与桌面测试目录。
480
+ // 以前用 isTempPath(p) 会把用户在 %TEMP% 里的真实项目登记也删掉。
481
+ if (!isTestTempPath(p) && !isTestDesktopPath(p)) continue;
389
482
  removed.push({ id, path: p, sessions: ws?.sessionIds?.length ?? 0 });
390
483
  delete doc.tables.workspaces[id];
391
484
  doc.global.workspaceIds = (doc.global.workspaceIds ?? []).filter((x) => x !== id);
@@ -397,7 +490,12 @@ export function pruneTestWorkspaces({ dryRun = false } = {}) {
397
490
 
398
491
  /** 清理:把系统临时目录下的工作区条目从注册表移除(**不动磁盘上的会话**)。 */
399
492
  export function pruneTempWorkspaces({ dryRun = false } = {}) {
400
- const doc = readDoc();
493
+ let doc;
494
+ try {
495
+ doc = readDoc();
496
+ } catch (e) {
497
+ return { removed: [], error: e.message };
498
+ }
401
499
  const removed = [];
402
500
  for (const id of Object.keys(doc.tables.workspaces)) {
403
501
  const ws = doc.tables.workspaces[id];
@@ -411,7 +509,7 @@ export function pruneTempWorkspaces({ dryRun = false } = {}) {
411
509
  return { removed };
412
510
  }
413
511
 
414
- /** 列出当前注册表里的工作区。 */
512
+ /** 列出当前注册表里的工作区。解析失败时抛错(由 CLI 显示可操作的错误)。 */
415
513
  export function listWorkspaces() {
416
514
  const doc = readDoc();
417
515
  return Object.entries(doc.tables.workspaces).map(([id, w]) => ({