dsh-conversation-recycle-bin 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.
- package/AUDIT-2026-10-17-desktop-web.md +333 -0
- package/CHANGELOG.md +57 -0
- package/INSTALL.md +2 -1
- package/README.md +16 -12
- package/client.js +197 -26
- package/config.schema.json +1 -1
- package/lib/core/cascade.js +53 -19
- package/lib/core/config.js +4 -1
- package/lib/core/errors.js +5 -2
- package/lib/core/file-lock.js +151 -0
- package/lib/core/memory/generic-json.js +2 -2
- package/lib/core/memory/json-index-adapter.js +182 -87
- package/lib/core/memory/lingshu.js +10 -4
- package/lib/core/memory/none.js +4 -2
- package/lib/core/memory/registry.js +9 -6
- package/lib/core/policy.js +4 -4
- package/lib/core/redact.js +66 -0
- package/lib/host/api.js +252 -55
- package/lib/host/catalog.js +101 -36
- package/lib/host/gateway.js +19 -5
- package/lib/host/paths.js +13 -2
- package/lib/version.js +1 -1
- package/package.json +11 -3
- package/locale/en.json +0 -63
- package/locale/zh.json +0 -63
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
# dsh-conversation-recycle-bin 双环境兼容性审计
|
|
2
|
+
|
|
3
|
+
- 审计对象:`dsh-conversation-recycle-bin` 0.2.0(工作区源码)
|
|
4
|
+
- 宿主:DeepSeek Harness `0.2.1-rc.2`(桌面版 Electron 与 CLI `dsh web` 同版本)
|
|
5
|
+
- 审计日期:2026-10-17
|
|
6
|
+
- 方法:逐文件源码核对 + 与本机 DSH 0.2.1-rc.2 实际实现比对(`resources/app.asar` 与 CLI 安装目录)+ 84 个测试全量运行 + 本机审计/回收站/灵枢锁的只读检查 + 独立临时记忆库上的锁竞争复现。**未**做浏览器 UI 自动化,**未**改动用户的 profile / 记忆库。
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## 0. 结论
|
|
11
|
+
|
|
12
|
+
1. **桌面版:插件已安装且确实可用。** `~/.dsh/profiles/desktop/package.json` 以 `link:` 引入并列入 bundles;`~/.dsh/delete-conversation/` 里保留着今天 21:13–21:18 由 UI 发起的 8 条审计记录(5 成功、**3 条 hard + 勾选记忆的删除全部在 memory 阶段回滚**),说明 Host 路由、4 个槽位、确认框与回收站都在桌面端跑通过,同时也直接暴露了 P0-1。84 个测试全部通过。
|
|
13
|
+
2. **网页版:插件根本没有安装。** `~/.dsh/profiles/web/package.json` 的 dependencies 与 `dsh.profile.bundles` 都没有它,`profiles/web/node_modules` 里也没有链接。因此“网页版运行表现”目前不是异常,而是**功能不存在**;下文关于网页版的结论是“若按同样方式安装后会怎样”的静态兼容性分析。
|
|
14
|
+
3. **即使安装到网页版,仍有三个会直接破坏核心功能的问题**:灵枢记忆锁协议不兼容(可让 `/delete` 永不返回并卡死整个变更队列)、灵枢索引直写不在其写协议内(记忆可能回放复活/被覆盖)、设置页批量删除的确认框被设置弹层遮住(批量删除实际上无法完成)。
|
|
15
|
+
4. 桌面端另有一条**平台特有**缺口:插件路由绕过 DSH 的 `connection` 鉴权与桌面更新/退出门禁,应用更新或退出可能在删除事务中途杀掉 Host。
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## 1. 环境事实(可复核)
|
|
20
|
+
|
|
21
|
+
| 项 | 桌面版 | 网页版 |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| profile 目录 | `~/.dsh/profiles/desktop` | `~/.dsh/profiles/web` |
|
|
24
|
+
| 插件安装 | `link:D:/Users/34332/AI/dsh-conversation-recycle-bin`,已列入 bundles | **未安装**(无依赖、无 bundle、无 node_modules 链接) |
|
|
25
|
+
| UI 承载 | Electron,页面源 `dsh-app://app/` | 浏览器,`http://127.0.0.1:8080`(当前 `dsh web` 进程) |
|
|
26
|
+
| DSH 版本 | 0.2.1-rc.2 | 0.2.1-rc.2 |
|
|
27
|
+
| 数据目录 | `~/.dsh/delete-conversation/` | 同左(共享 DSH_HOME) |
|
|
28
|
+
| 灵枢存储 | `~/.dsh/.dsh-memory/data/mdcg`(schema 2,审计时 37 节点) | 同左 |
|
|
29
|
+
| 本次是否运行 | 审计时为未启动状态 | Host 在 8080 运行,但无插件 |
|
|
30
|
+
|
|
31
|
+
关键证据:
|
|
32
|
+
|
|
33
|
+
- `~/.dsh/delete-conversation/audit.jsonl`:8 条记录,其中 3 条为 `result:"rolled-back"`、`failedStep:"memory"`、`cause:"The memory store is busy; try again later."`(21:14:06、21:14:11、21:14:34 连续三次)。
|
|
34
|
+
- `~/.dsh/.dsh-memory/data/mdcg/_index.json.lock`:0 字节,mtime 21:13,至今仍存在。
|
|
35
|
+
- 桌面 Electron 主进程 `forwardWebRequest`(`app.asar` → `/lib/main.js` 8268 起)在转发前显式删除 `host`、`origin`、`cookie`、`sec-fetch-site` 四个头。
|
|
36
|
+
- 设置弹层 `.VOzbGW_overlay{z-index:1000;position:fixed}` 且 `createPortal(..., document.body)`;插件对话框挂在 `.overlayLayer{z-index:20}` 内。
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## 2. P0:阻断级
|
|
41
|
+
|
|
42
|
+
### P0-1 灵枢锁协议不兼容:记忆导出可能永不返回;Windows 上表现为挂死,POSIX 上表现为互斥失效
|
|
43
|
+
|
|
44
|
+
- **平台**:Host 侧,桌面版与网页版共用同一份代码与同一个记忆库;故障形态随操作系统不同。
|
|
45
|
+
- **复现条件**:
|
|
46
|
+
1. 会话有 ≥1 条关联灵枢节点(即勾选“同时删除关联记忆”且有实际数据);
|
|
47
|
+
2. 灵枢进程正在写索引(`_index.json.lock` 被其 `FileLock` 持有);
|
|
48
|
+
3. 触发 `/delete`(勾选记忆)或 `/trash/purge`/`restore` 走到记忆阶段。
|
|
49
|
+
- **实测**:用 `md_cg.fsutil.FileLock` 在独立临时库上按真实方式持锁,再调用适配器 `exportSession`(默认参数,1 个关联节点):`timeout 8` 与 `timeout 12` 均被杀死(退出码 124),**没有抛出任何错误**;同一库在无关联节点时 2ms 返回(0.1.3 的 fast path 是有效的)。生产审计里对应的失败连续发生了 3 次,错误文案正是 `withLock` 的 “The memory store is busy”。
|
|
50
|
+
- **原因**:
|
|
51
|
+
- 灵枢的 `FileLock` 是 **OS 级建议锁**:`open(path, "a+b")` + Windows `msvcrt.locking` / POSIX `flock`,锁文件**永远是 0 字节且永不删除**(`md_cg/fsutil.py` 124–165)。
|
|
52
|
+
- 插件把“文件是否存在”当锁:`open(lockFile, "wx")`。只要灵枢跑过一次,锁文件就一直在,`wx` 必然 `EEXIST`。
|
|
53
|
+
- 插件的“0 字节且超过 5s 即视为崩溃遗留”其实是灵枢的**正常锁**。判定 stale 后进入: ```js
|
|
54
|
+
if (stale) { await fsApi.rm(lockFile).catch(() => {}); continue }
|
|
55
|
+
```
|
|
56
|
+
这一分支**既没有 sleep,也在超时判断之前**。Windows 上灵枢持有句柄时 `rm` 永远失败,`stale` 永远为真 → 无退出的忙循环(持续 unlink 系统调用),请求永不返回。
|
|
57
|
+
- 后果不止单个请求:`lib/host/api.js` 的 `serialize()` 用一条 `mutationChain` 串行化所有变更;`/delete` 永不 settle 后,后续删除/恢复/清理全部排队等待,直到重启 Host。
|
|
58
|
+
- 即便某次 `rm` 成功(灵枢此刻恰好没持有句柄),插件也只是删掉锁文件后自己建一个同名文件;md_cg 下次 `open("a+b")` 仍会打开同一路径并加自己的 OS 锁,而插件根本不参与那把锁 → 两个写者仍可同时整份读改写索引。**互斥在任何平台上都没有真正建立**;区别只是 Windows 多了一个“持锁期间永挂”的失败形态,POSIX 则是静默丢更新。
|
|
59
|
+
- **对既有结论的更正**:CHANGELOG 0.1.3 与上一轮审计把 `_sustain`/`_index.json.lock` 的 0 字节锁判为“写入异常退出遗留”,并据此加了“5s 后清除无主锁”。这是误判——0 字节是 md_cg `FileLock` 的常态,锁文件也从不删除。0.1.3 的“修复”实际上制造了本次的挂死/互斥失效。
|
|
60
|
+
- **修复方向**:
|
|
61
|
+
1. 不要用“存在性”模拟锁。要么用同一套 OS 锁(Node 侧 `flock`/`msvcrt.locking` 没有内置 API,需原生模块或子进程),要么**放弃直写**(见 P0-2)。
|
|
62
|
+
2. 最低限度先修死循环:stale 分支加 `await sleep(25)`,并在 `rm` 后重新 `stat`/`open` 成功才继续;同时把超时判断放到分支之前。
|
|
63
|
+
3. 永远不要 `rm` 灵枢的 `_index.json.lock`;0 字节不是崩溃信号。
|
|
64
|
+
4. 测试需要按真实协议建模(现在 `test/memory.test.mjs` 把“非空内容的锁”当外部写者、把“0 字节锁”当崩溃遗留,与 md_cg 实际行为相反)。
|
|
65
|
+
|
|
66
|
+
### P0-2 灵枢索引直写不在其写协议内:删除可能被回放复活,或产生幽灵节点
|
|
67
|
+
|
|
68
|
+
- **平台**:桌面版、网页版(同一个 `.dsh-memory`,各自的 md_cg 进程)。
|
|
69
|
+
- **复现条件**:勾选记忆删除一个含有灵枢节点的会话;随后灵枢重新装载索引或执行自身 compact。
|
|
70
|
+
- **原因**(`md_cg/mdcg.py`):
|
|
71
|
+
- 灵枢的持久化不是单一 `_index.json`,而是“快照 + `_index_log` 分片日志”。装载时 `_load_index` 会先读快照,再 `_apply_log(idx["nodes"])` 逐条重放;记录是 dict 就 `nodes[nid] = e`(覆盖),是 `null` 才是 tombstone。
|
|
72
|
+
- 插件只重写 `_index.json`,**不向 `_index_log` 追加 tombstone、也不清空它**;同时 md_cg 进程内存里还有一份快照。只要被删节点的记录还在 `_index_log`(上次 compact 之后创建/更新过),下次装载就会把节点加回来,而正文文件已被移动到回收站 → 典型“索引有条目、文件不存在”的幽灵节点。
|
|
73
|
+
- 插件也不写灵枢的 `_audit.jsonl` / `_forgetting.jsonl`,灵枢自身完全不知道这次删除。
|
|
74
|
+
- README §5.1 的“持 `_index.json.lock`(与 md_cg 写入方同一把锁,避免丢更新)”与实际实现不符(见 P0-1)。
|
|
75
|
+
- **修复方向**:
|
|
76
|
+
1. 首选走灵枢支持的删除入口(其 `mdcg_forget` / bridge 的 forget 语义,带 receipt),而不是直接改索引;
|
|
77
|
+
2. 若必须离线改,至少同时维护 `_index_log`:删除追加 `{"id": nid, "e": null}`,恢复追加节点记录,并在真实 OS 锁内完成;
|
|
78
|
+
3. 在做到之前,把 `memory.deleteMode` 默认改为 `keep`(只统计、不删除),不要让“勾选记忆”成为默认可用选项;
|
|
79
|
+
4. 文档按实际能力降级描述,不要声称“schema 2 的受支持子集”。
|
|
80
|
+
|
|
81
|
+
### P0-3 设置页“批量删除”的确认框被设置弹层遮住,功能实际不可用
|
|
82
|
+
|
|
83
|
+
- **平台**:桌面版与网页版(同一份客户端代码、同一版 ui-settings)。
|
|
84
|
+
- **复现条件**:设置 → “对话删除” → “批量删除” → 勾选任意会话 → 点“删除所选…”。
|
|
85
|
+
- **现象**:确认框不可见;点击原对话框位置会先关掉设置弹层,随后对话框才“突然出现”。批量删除是唯一必须在设置页内发起删除的入口,因此这条路径基本不可用。
|
|
86
|
+
- **原因**:
|
|
87
|
+
- 设置面板是 `createPortal(..., document.body)` 的 `.VOzbGW_overlay{position:fixed;z-index:1000}`;
|
|
88
|
+
- 插件的 `DialogHost` 注册在 `shell.overlay`,实际渲染在布局的 `.pI_x6G_overlayLayer{position:absolute;z-index:20}` 内;`.dc-backdrop` 的 `z-index:1000` 只是**该 stacking context 内**的值,无法越过父级的 20。
|
|
89
|
+
- 设置弹层还通过 `useModalLayer(panel, true, onClose)` 接管模态层(焦点/Escape),插件对话框不在宿主的模态栈里;即使 z-index 修好,焦点也可能仍被设置面板锁住。
|
|
90
|
+
- 同一个原因让 `flashStore` 的 toast(`z-index:1200`,同样在 overlayLayer 内)在设置页里不可见,所以“恢复成功/已清理”的反馈也看不到。
|
|
91
|
+
- **修复方向**:把确认框用 `createPortal` 挂到 `document.body`,z-index 取 1100+(高于设置弹层),并使用宿主 `ui-primitives` 的 `useModalLayer` 注册为真正的模态层;toast 同样 portal 到 body。
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## 3. P1:高
|
|
96
|
+
|
|
97
|
+
### P1-1 桌面端路由绕过 `connection` 鉴权与桌面更新/退出门禁
|
|
98
|
+
|
|
99
|
+
- **平台**:桌面版(网页版同样没有鉴权,但没有更新锁这一层)。
|
|
100
|
+
- **复现条件**:桌面应用内触发“检查更新/安装更新”或退出,同时有一个耗时删除(live 会话、或记忆阶段)在跑。
|
|
101
|
+
- **原因**:
|
|
102
|
+
- 插件用 `ctx.webServer.register({kind:'prefix'})` 直接挂路由,未走 `/api`,因此不经过 `connection.admit()`(浏览器 cookie 鉴权)与 `connection/request` waterfall。
|
|
103
|
+
- 桌面端 `installDesktopUpdateTaskControl` 只在 `connection/request` 上拒绝 503 并等待在途请求;插件的请求不在其中,更新会直接进入 shutdown,删除事务可能被截断。
|
|
104
|
+
- `installDesktopQuitInspection` 只看 `agents`/`jobs` 与 schedule,不知道插件在删会话,退出对话框不会提示。
|
|
105
|
+
- 桌面 `forwardWebRequest` 删除了 `origin` 与 `sec-fetch-site`,所以 `requestOriginVerdict` 的 `requireSameOrigin` 在桌面端**实际不生效**;`dsh-app:` 信任分支在真实链路里是死代码(Origin 已被剥离)。剩下的唯一传输层防线是静态 header `x-conversation-recycle-bin: 1`。
|
|
106
|
+
- **修复方向**:改用 `ctx.connection.fetch.register({ path: '/api/...', methods, requestBody:'buffered', fetch })`(官方精确路由,天然要求已认证的 owner Peer,且受 `/api` 的 `connection/request` 门禁约束);至少也在 handler 开头调用 `ctx.connection.requestRejection(req)`。退出侧可注册 `workspace/session-activity` 提供者,在删除进行中报告活动,让桌面退出检查可见。
|
|
107
|
+
|
|
108
|
+
### P1-2 硬删除 live 会话时跳过 foreign-writer 检查
|
|
109
|
+
|
|
110
|
+
- **平台**:桌面版 + 网页版同时运行(共享 DSH_HOME)时。
|
|
111
|
+
- **复现条件**:同一会话在另一 Host 中仍打开/可写,在当前 Host 执行“彻底删除”(`session.live === true`)。
|
|
112
|
+
- **原因**:`lib/host/catalog.js#assertNoForeignWriter` 第一行就是 `if (grace <= 0 || session.live === true) return`。设计上 live 交给 `quiesce`,但 `quiesce` 只能停**本进程**的 Agent;另一个 Host 的 writer 它看不见。于是最需要保护的场景(另一端正在写)恰好被豁免。README §2.3/§4.2/§8.6 都没有提这个豁免。
|
|
113
|
+
- **修复方向**:live 会话在 `quiesce` 完成后**仍然**做一次 mtime 复检(不要提前 return),或改为检查日志文件的独占句柄;无法独占就中止并报 `E_POLICY_FOREIGN_WRITER`。
|
|
114
|
+
|
|
115
|
+
### P1-3 软删除条目恢复失败时,回滚会把会话从工作区摘掉(并取消归档)
|
|
116
|
+
|
|
117
|
+
- **平台**:两版。
|
|
118
|
+
- **复现条件**:软删除时勾选了记忆(条目带 memory bundle)→ 在回收站点“恢复”→ 记忆恢复失败(P0-1 的 busy/挂死、bundle 缺失、第二个适配器失败均可)。
|
|
119
|
+
- **原因**:`performRestore` 的步骤是 messages(hard)→ session(catalog.restore)→ memory。memory 失败时回滚 session 步骤,而 `catalog.restore` 的 undo 是**无条件**的:
|
|
120
|
+
```js
|
|
121
|
+
if (current !== null && current.sessionIds.includes(snapshot.sessionId)) {
|
|
122
|
+
await current.detachSession(snapshot.sessionId)
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
软删除的会话本来就在 workspace 里(归档只是隐藏),`catalog.restore` 只做了 `unarchiveSession`;回滚却把它 **detach** 了 → 会话从侧栏彻底消失(日志还在磁盘、条目还在回收站)。同时 `snapshot.archived` 是删除前状态(false),undo 也不会重新归档。结果:一次失败的恢复让会话变成“孤儿”,重试恢复会 `attachSession` 重新挂回(位置丢到列表顶部),若记忆一直失败就一直是孤儿。
|
|
126
|
+
- **修复方向**:`catalog.restore` 要记录本次**实际做的变更**(是否 attach、是否 unarchive),undo 只反转自己做过的事;对软删除条目在回滚时重新 `archiveSession`。
|
|
127
|
+
|
|
128
|
+
### P1-4 “未完成条目”在 UI 里没有恢复入口
|
|
129
|
+
|
|
130
|
+
- **平台**:两版。
|
|
131
|
+
- **复现条件**:删除在 messages/catalog 阶段失败(或进程在移动日志后崩溃),`GET /trash` 把它列进 `incomplete`。
|
|
132
|
+
- **原因**:0.2.0 已让 `performRestore` 支持 `pending/failed/restoring/purging`,但 `client.js` 的 `IncompleteEntries` 只渲染“清理”(Discard)按钮,没有“恢复”。而 `performDiscard` 对仍持有 `session/` 的条目会拒绝——UI 提示“请改用恢复或彻底删除”,但界面没有恢复按钮,用户只能手工调 API。README §8.7 把这描述成“需按 INSTALL 排障手工处理”,实际是 UI 缺口。
|
|
133
|
+
- **修复方向**:在 `IncompleteEntries` 增加“恢复”按钮(调用已有的 `/trash/restore`),并按 `hasSessionLog`/`memory` 显示可用动作。
|
|
134
|
+
|
|
135
|
+
### P1-5 记忆导出失败留下的 bundle 目录让条目永久无法 discard
|
|
136
|
+
|
|
137
|
+
- **平台**:两版。
|
|
138
|
+
- **复现条件**:`exportSession` 已写出 `memory/<adapter>/bundle.json`(以及移动的正文),随后 `writeIndexAtomic` 失败(磁盘满/只读/索引被占)。
|
|
139
|
+
- **原因**:`exportSession` 的 catch 只还原 `movedFiles` 与 `pristine` 索引,**不删除已写出的 bundle 目录**;事务失败后 manifest 的 `memory.total` 仍是 0(步骤没返回),于是 `performRestore` 认为没有记忆可恢复、`performDiscard` 又因 `entryDir/memory` 非空而拒绝。这正是 0.2.0 修过的“失败条目卡死”的另一条未覆盖路径。
|
|
140
|
+
- **修复方向**:catch 里在还原文件后 `rm -rf bundleDir`;或让 `discard` 允许删除“manifest 未引用”的 memory 目录(并记审计)。
|
|
141
|
+
|
|
142
|
+
### P1-6 双 Host 共用 `delete-conversation/` 没有跨进程互斥(潜伏)
|
|
143
|
+
|
|
144
|
+
- **平台**:桌面版与网页版都装插件并同时运行时。
|
|
145
|
+
- **复现条件**:两个 Host 同时对同一 `trashId` 操作,或同时写 `audit.jsonl`。
|
|
146
|
+
- **原因**:`api.js` 的 `serialize()` 只在本进程内串行;`trash.write` 用固定的 `manifest.json.tmp`,`audit.append` 的压缩是整文件读改写;`performRestore`/`purge` 的 read-modify-write 没有文件锁。跨进程并发会丢更新/覆盖 manifest,审计行也可能被压缩窗口吞掉。
|
|
147
|
+
- **修复方向**:给 trash manifest 与 audit 加跨进程锁文件(或用 `proper-lockfile` 语义);或在 `/state` 明确“同一 DSH_HOME 只允许一个 Host 使用本插件”,并在检测到第二个 Host 时拒绝变更。
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## 4. P2:中
|
|
152
|
+
|
|
153
|
+
### P2-1 恢复/回滚不恢复会话在工作区中的位置
|
|
154
|
+
|
|
155
|
+
- **平台**:两版。
|
|
156
|
+
- **复现**:彻底删除一个非列表首位的会话 → 回收站恢复;或让 hard 删除在 catalog 之后的步骤失败触发回滚。
|
|
157
|
+
- **原因**:`catalog.restore` 与 `catalog.remove` 的 undo 都先调 `insertSessionBefore(id, anchor)`。但该 API 要求会话**已经在** `sessionIds` 里(否则抛 `WorkspaceMoveInvalidError`);被 detach 的会话不在,于是必然走 catch → `attachSession`,而 `attachSession` 是 `[id, ...sessionIds]` **前插**。`capture` 记录的 `nextSessionId` 实际从未生效。
|
|
158
|
+
- **修复**:先 `attachSession(id)`,再 `insertSessionBefore(id, anchor)`;或在 catch 里先 attach 再 insert。README/catalog 注释里“恢复原位置”的说法需要与实现对齐。
|
|
159
|
+
|
|
160
|
+
### P2-2 软删除条目的 purge 崩溃窗口:日志会“隐身”,且一次恢复可能把它永久删掉
|
|
161
|
+
|
|
162
|
+
- **平台**:两版。
|
|
163
|
+
- **复现**:对软删除条目执行“彻底删除”,在日志已 move 到 `trash/<id>/session`、但 catalog 步骤尚未执行(或 `trash.remove` 尚未执行)时进程被杀。
|
|
164
|
+
- **原因**:`performPurge` 的 soft 分支把日志移进条目目录,却**不把 `sessionLog` 写回 manifest**;崩溃后条目是 `purging`,`entry.sessionLog` 与 `entry.catalog` 都可能是 null。
|
|
165
|
+
- `performRestore` 对 soft 条目没有 messages 步骤,`catalog` 为 null 时只剩 memory 步骤;只要该条目当初勾选了记忆,恢复会在把记忆放回后执行 `trash.remove(entryDir)`,**把仍躺在条目里的 `session/` 日志一并删除** —— 这是真正会丢日志的路径。
|
|
166
|
+
- 若条目没有可恢复的 memory,`performRestore` 报“nothing left to restore”,`performDiscard` 又因 `session/` 存在而拒绝 → 条目卡死,只能手工处理。
|
|
167
|
+
- **修复**:移动成功后立即 `trash.update({sessionLog: moved})`(或先写 `purging` + 位置,再移动);`performRestore`/`performDiscard` 都要先检查磁盘上的 `session/`,不能只信 manifest。
|
|
168
|
+
|
|
169
|
+
### P2-3 多适配器恢复没有 undo,且边恢复边删 bundle
|
|
170
|
+
|
|
171
|
+
- **平台**:两版(配置了 lingshu + generic-json 时)。
|
|
172
|
+
- **原因**:`memoryService.restoreEntry` 逐个适配器 `restoreBundle` 后立刻 `rm(dirname(bundlePath))`;它不返回 undo。第二个适配器失败时,第一个适配器的 bundle 已被删、数据已回到 live store,条目却仍留在回收站,重试会因 bundle 文件缺失永远失败。
|
|
173
|
+
- **修复**:全部适配器成功后再统一删除 bundle;或为每个适配器返回可逆操作并在失败时回滚(至少保留 bundle 文件)。
|
|
174
|
+
|
|
175
|
+
### P2-4 `defaultMode` 是死配置
|
|
176
|
+
|
|
177
|
+
- **平台**:两版。
|
|
178
|
+
- **原因**:`DeleteDialog` 固定 `useState('soft')`,从不读取 `/state` 或 `/preview` 里的 `defaultMode`;`config.schema.json` 却写着“Initial mode selected in the confirmation dialog”。Host 侧 `normalizeMode` 的默认值只在客户端不传 `mode` 时才生效,而客户端每次都传。
|
|
179
|
+
- **修复**:对话框初始值改为 `state?.defaultMode ?? 'soft'`,或在文档/schema 里删掉该承诺。
|
|
180
|
+
|
|
181
|
+
### P2-5 语言检测读错字段,UI 永远跟随浏览器语言而不是应用语言
|
|
182
|
+
|
|
183
|
+
- **平台**:两版。
|
|
184
|
+
- **复现**:在 DSH 设置里把语言切到 English(系统仍是 zh-CN)→ 插件菜单/对话框仍是中文;反向同理。
|
|
185
|
+
- **原因**:`client.js#currentLocaleId` 找 `snapshot.locale ?? snapshot.id`,但 `dsh-client-locale` 的快照结构是 `{ active: '<locale id>', locales, revision }`(`getSnapshot()` 与 `bridge.onChange(locale.getSnapshot().active)` 都证实)。两个字段都不存在 → 回退到 `navigator.language`。
|
|
186
|
+
- **修复**:`const id = snapshot?.active ?? snapshot?.locale ?? snapshot?.id`。
|
|
187
|
+
|
|
188
|
+
### P2-6 `resolveDshHome` 不展开 `~`,与官方 `resolveDshHome` 不一致
|
|
189
|
+
|
|
190
|
+
- **平台**:两版(跨平台配置差异)。
|
|
191
|
+
- **复现**:`DSH_HOME=~/dsh-data` 启动。官方 `resolveRoot` 会 `expandHomePath` 再 `resolve`;插件直接 `join('~/dsh-data','delete-conversation')`,会在 cwd 下创建字面量 `~` 目录,回收站/审计落到错误位置。
|
|
192
|
+
- **修复**:复用 `@deepseek-ai/dsh-home-paths`,或至少实现同样的 `~`/`~/`/`~\` 展开与 `resolve()`。
|
|
193
|
+
|
|
194
|
+
### P2-7 批量“全选”会被单个 subagent/越界会话卡死
|
|
195
|
+
|
|
196
|
+
- **平台**:两版。
|
|
197
|
+
- **复现**:设置 → 批量删除 → 全选(列表含 subagent 会话或不在 `allowedWorkspaceRoots` 的会话)→ 删除。
|
|
198
|
+
- **原因**:`/sessions` 返回全部会话,UI 不显示 `origin`;`/preview` 只要有一个 id 被策略拒绝就**整批**不签发 token,客户端禁用删除按钮且只显示第一条原因。用户既不能删、也很难知道该去掉哪一条。
|
|
199
|
+
- **修复**:`/sessions` 过滤掉策略必然拒绝的会话(或标记不可选);或允许“跳过被拒项,仅删除可删项”,并列出全部拒绝原因。
|
|
200
|
+
|
|
201
|
+
### P2-8 批量变更的超时上限可能小于最坏耗时
|
|
202
|
+
|
|
203
|
+
- **平台**:两版。
|
|
204
|
+
- **原因**:`mutationTimeoutMs = min(180000, 30000 + count*5000)`。最坏情况下每个 live 会话 `liveDrainMs` 2s + 记忆锁最多 6s(修复死循环后)+ 移动日志;50 个会话可超过 180s。客户端先超时,Host 继续执行,token 已消费,用户看到 `E_TIMEOUT` 后重试会与在途事务排队。
|
|
205
|
+
- **修复**:按“每项最坏耗时”估算或改为分批 + 进度;Host 侧提供 operation id 供客户端查询,而不是只靠 HTTP 超时。
|
|
206
|
+
|
|
207
|
+
### P2-9 `readJsonBody` 没有处理请求中止
|
|
208
|
+
|
|
209
|
+
- **平台**:两版。
|
|
210
|
+
- **原因**:只监听 `data`/`end`/`error`;客户端在发 body 期间断开(超时/关页)时 `end` 可能永不触发,Promise 不 settle。`/delete` 在 `serialize()` 里,会让后续变更全部排队。
|
|
211
|
+
- **修复**:监听 `req.on('aborted')`/`close`,以 `E_BAD_REQUEST` reject,并确保 `serialize` 的链能继续。
|
|
212
|
+
|
|
213
|
+
### P2-10 错误详情把宿主绝对路径带到客户端
|
|
214
|
+
|
|
215
|
+
- **平台**:两版(本地应用,敏感度低,但与“客户端只拿计数与状态”的契约冲突)。
|
|
216
|
+
- **原因**:`DeleteError.details` 原样进 `toWireError`;`readIndex`/`withLock`/`E_TRANSACTION_FAILED` 的 details 里带 `indexFile`、`lockFile`、`cause`(ENOENT 路径)。CHANGELOG 0.2.0 只移除了 `/state` 里的 `storage.*`。
|
|
217
|
+
- **修复**:错误 details 里对绝对路径做脱敏(只留 basename/相对 DSH_HOME 的路径)。
|
|
218
|
+
|
|
219
|
+
---
|
|
220
|
+
|
|
221
|
+
## 5. P3:低 / 文档
|
|
222
|
+
|
|
223
|
+
| # | 平台 | 问题 | 原因 / 建议 |
|
|
224
|
+
|---|---|---|---|
|
|
225
|
+
| P3-1 | 两版 | 每次激活打印 3 条假告警 | `cordis.patch.yml` 把可空字段写成 `null`,`readString` 只接受 `undefined`/string → `memory.lingshu.rootDir`、`memory.generic.indexFile/rootDir` 各报 `expected a string`。让 `readString` 接受 `null` 作为“未设置”。 |
|
|
226
|
+
| P3-2 | 两版 | 深色主题下 live 警告框不跟主题 | `--dsw-alias-text-warning` / `--dsw-alias-fill-warning` 在 0.2.1-rc.2 中不存在(实际是 `--dsw-alias-state-warn-label` / `-tertiary`),回退成 `#b26a00`/`#fff7e6` 的浅色块。`--dsw-alias-bg-overlay` 是存在的,对话框本身没有这个问题。 |
|
|
227
|
+
| P3-3 | 两版 | `locale/{en,zh}.json` 是死文件 | 客户端只读内联 `STRINGS`;JSON 的键集与内联表不同(`messages`/`memoryDeleted`/`error` vs `records`/...)。要么加载,要么删除,避免“改了不生效”。 |
|
|
228
|
+
| P3-4 | 网页版 | `sameHostname` 只比主机名,忽略端口与协议 | `http://127.0.0.1:3000` 的 Origin 与 `Host: 127.0.0.1:8080` 会被判同源。自定义 header 让浏览器预检挡住实际利用,但“同源校验”名不副实;应比较完整 authority。 |
|
|
229
|
+
| P3-5 | 桌面版 | `dsh-app:` 信任分支过宽且实际是死代码 | 桌面转发已剥离 Origin;若保留该分支,任何注册 `dsh-app:` scheme 的 Electron 应用都会被视为同源。建议删除或改为校验自定义头/nonce。 |
|
|
230
|
+
| P3-6 | 两版 | 会话 id 正则不接受 `.` | `SESSION_ID_PATTERN=/^[A-Za-z0-9_-]{6,80}$/`;DSH 的 `encodeSegment` 允许 `.`/`_`/`-`。当前 mint 的 id 不会命中,但 fork/自定义 id 会被误拒。 |
|
|
231
|
+
| P3-7 | POSIX | 工作区路径大小写比较不一致 | `gateway.workspaceFor` 对**所有**路径 `toLowerCase()`,Linux 上 `/a/Foo` 与 `/a/foo` 会误配;`policy.normalizePathForCompare` 只对盘符路径小写,且 UNC (`\\server\share`) 未按 Windows 语义处理。 |
|
|
232
|
+
| P3-8 | 两版 | 审计 `source` 固定 `web-ui` | 桌面端发起的操作也记成 `web-ui`,做平台取证时误导;建议按 `ctx` 所在宿主或 UA 区分 `desktop-ui`/`web-ui`。 |
|
|
233
|
+
| P3-9 | 两版 | 批量软删除的硬删除文案分支不可达 | `dialog.hardWarning`(“请输入 DELETE”)在 `expectedPhrase === ''` 时不会渲染;hard 模式永远一键。删掉该文案或改成分支说明。 |
|
|
234
|
+
| P3-10 | 两版 | 记忆适配器标签半本地化 | lingshu 的 label 是中英混合常量,`none` 是英文 reason;英文界面里会出现中文,反之亦然。 |
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
## 6. 平台差异导致的异常(汇总)
|
|
239
|
+
|
|
240
|
+
| 差异点 | 桌面版表现 | 网页版表现 |
|
|
241
|
+
|---|---|---|
|
|
242
|
+
| Origin/Sec-Fetch-Site | Electron `forwardWebRequest` 删除,`requireSameOrigin` 失效,`dsh-app:` 分支不可达 | 浏览器带 Origin,同源校验生效(且比官方实现宽松:忽略端口) |
|
|
243
|
+
| 鉴权 | 插件路由不走 `/api`,不校验 cookie;本地任意进程知道随机端口即可调用 | 同左(8080 固定,更易被本机进程发现) |
|
|
244
|
+
| 更新/退出门禁 | `connection/request` 503 + 等待;插件绕过 → 更新/退出可截断删除 | 无更新锁;Host 常驻,Ctrl+C/重启同样可截断 |
|
|
245
|
+
| 端口 | 每次启动 `--port 0` 随机 | 固定 8080(按启动参数) |
|
|
246
|
+
| Host 生命周期 | 随 app 退出,事务更易被打断 | 常驻,事务完整概率更高 |
|
|
247
|
+
| 远程访问 | 不适用 | `dsh web --host <非回环>` 默认被 `E_FORBIDDEN_HOST` 拒绝;`allowNonLoopback:true` 会放宽到无 cookie 校验的路由 |
|
|
248
|
+
| 另一 Host 同时写 | 共享 DSH_HOME;live 会话跳过 foreign-writer 检查 | 同左 |
|
|
249
|
+
| 插件安装 | 已安装、已跑通 | **未安装** |
|
|
250
|
+
| 记忆库 | 同一个 `.dsh-memory`,各自 md_cg 进程 | 同左;锁问题相同 |
|
|
251
|
+
|
|
252
|
+
---
|
|
253
|
+
|
|
254
|
+
## 7. 已验证正常(避免误报)
|
|
255
|
+
|
|
256
|
+
- 槽位契约与当前 DSH 一致:`sidebar.workspaces.session.menu.item` 的 owner props 是 `{sessionId, displayTitle}` 且提供 `useMenuOpenState`;`conversation.session.header.actions` 的标准 props 确实含 `sessionId`;`settings.section` 的 `label` 支持 thunk(`resolveSlotLabel`);`shell.overlay` 存在且为 root list。
|
|
257
|
+
- Client 半的模块 id、`dsh.client.platform: "web"`、`exports["./client"]` 均符合 `dsh-client-modules` 的校验(桌面与网页都用 `web` 这一平台标识)。
|
|
258
|
+
- Host 依赖的服务在 0.2.1-rc.2 均存在:`webServer`、`sessionController.list/cancel`、`sessionPersistence.stat/list`、`sessions.get`、`workspaceRegistry.archive/unarchive/pin/unpin`、`Workspace.attach/insertSessionBefore/detach`。
|
|
259
|
+
- 会话目录发现同时兼容 `session-<uuid>` 与历史裸 UUID 目录;投影缓存路径/字段(`record.rows.title.val`、`sessionListMetadata.val.blank`)正确。
|
|
260
|
+
- `--dsw-alias-bg-overlay` 在 0.2.1-rc.2 中真实存在(浅色 `neutral-bluish-150`、深色 `neutral-bluish-700`),对话框背景不会在深色主题变白。
|
|
261
|
+
- 84 个测试全部通过(manifest 9、client 10、core 13、cascade 15、memory 9、trash-audit 3、api 25)。
|
|
262
|
+
- 灵枢库审计时为 37 节点、0 个缺失正文;本次审计**没有**改写过用户的记忆库。
|
|
263
|
+
- 记忆导出的“无关联节点”fast path 有效:零节点时 2ms 返回且不碰锁。
|
|
264
|
+
|
|
265
|
+
---
|
|
266
|
+
|
|
267
|
+
## 8. 未能验证 / 推断项
|
|
268
|
+
|
|
269
|
+
1. **没有做浏览器 UI 自动化**:桌面应用审计时未启动;网页版没装插件。P0-3 的“不可见/不可点击”来自 stacking context 与宿主模态层的代码分析,不是截图实证。
|
|
270
|
+
2. **`_index_log` 回放导致节点复活**:来自 `md_cg/mdcg.py` 的装载路径与分片日志格式分析,未在用户的真实库上制造删除来实证(避免破坏数据)。
|
|
271
|
+
3. **Windows 上 live 会话日志目录 rename 是否会被句柄阻塞**:未实测;Node 的 libuv 打开文件通常带 `FILE_SHARE_DELETE`,但 Agent 的写入方是否另有句柄未逐一核对,故未列为确定 bug。
|
|
272
|
+
4. **桌面更新/退出截断删除**:由 `desktop-host` 的门禁实现与插件绕过推出;未在真实更新过程中复现。
|
|
273
|
+
5. **网页版安装后的完整 UI 表现**:未安装、未重启 web Host,只做了组合兼容性分析。
|
|
274
|
+
|
|
275
|
+
---
|
|
276
|
+
|
|
277
|
+
## 9. 建议修复顺序
|
|
278
|
+
|
|
279
|
+
1. **先止血**:修 `withLock` 死循环(加 sleep + 超时前置 + 不删锁文件),并在文档/默认配置里把 `memory.deleteMode` 改为 `keep`,直到记忆写入方案重做(P0-1/P0-2)。
|
|
280
|
+
2. **修批量删除**:对话框 portal 到 `document.body` + 真正的 modal layer(P0-3);顺带修 toast。
|
|
281
|
+
3. **桌面门禁**:改用 `connection.fetch.register`(或至少 `requestRejection`),并让退出检查能看到在途删除(P1-1);去掉 live 的 foreign-writer 豁免(P1-2)。
|
|
282
|
+
4. **事务回滚正确性**:catalog.restore 的 undo 只反转自身变更(P1-3);恢复后位置用 attach→insert(P2-1);未完成条目加恢复按钮(P1-4)。
|
|
283
|
+
5. **收尾**:记忆导出失败清理 bundle(P1-5)、purge 记录 sessionLog(P2-2)、多适配器恢复原子性(P2-3)、跨进程锁(P1-6)。
|
|
284
|
+
6. **配置/文案**:`defaultMode`、locale `active`、`~` 展开、null 告警、README/CHANGELOG 的锁与同源表述。
|
|
285
|
+
|
|
286
|
+
---
|
|
287
|
+
|
|
288
|
+
## 10. 修复状态(2026-10-18,对应 0.2.2)
|
|
289
|
+
|
|
290
|
+
全部 P0–P3 条目已修复并通过 104 个测试回归(0.2.1 的 84 个之上新增 20 个)。下表为每条审计发现对应的修复落点与验证用例:
|
|
291
|
+
|
|
292
|
+
| # | 严重度 | 修复落点 | 验证 |
|
|
293
|
+
|---|---|---|---|
|
|
294
|
+
| P0-1 | 阻断 | `lib/core/file-lock.js`(新建):插件私有锁 `_index.json.conversation-recycle-bin.lock`,pid/nonce 注册 + 心跳 + stale 判定 + 忙等超时;`lib/core/memory/json-index-adapter.js` 改为持有私有锁,**永不触碰/删除** md_cg 永久锁 | `lingshu adapter: the store lock is neither consulted nor deleted`、`a private lock held by a live owner fails cleanly, not forever`、`a stale private lock is broken instead of blocking forever` |
|
|
295
|
+
| P0-2 | 阻断 | `lingshu.js` 删除追加 `{"id, e:null}` 墓碑分片、恢复追加节点记录分片;`_t` 取 `max(now, 最新分片 _t + 1ms)` | `lingshu adapter: export appends tombstones and restored records to the md_cg shard log` |
|
|
296
|
+
| P0-3 | 阻断 | `client.js`:确认框 `createPortal(..., document.body)`、层级 1100;toast 一并 portal | `client: the dialog is portaled into document.body to escape the overlay stacking context`、`a host without react-dom still renders the overlay inline` |
|
|
297
|
+
| P1-1 | 高 | `lib/host/api.js`:变更路由先 `connection.requestRejection`(401/403 短路),再 `ctx.waterfall('connection/request', …)` | `connection: requestRejection short-circuits 401 and 403 before the route runs`、`a waterfall veto (the desktop update lock) returns 503 without touching the transaction` |
|
|
298
|
+
| P1-2 | 高 | `lib/host/catalog.js`:`assertNoForeignWriter` 删除 `session.live === true` 提前 return,所有模式复检 mtime | `hard delete: a log being written by another instance is not moved`、`an idle log is moved after the grace window` |
|
|
299
|
+
| P1-3 | 高 | `catalog.js`:`restore` 的 undo 记录实际变更(attach/unarchive),软删除回滚重新归档 | `hard delete: a catalog step refused halfway puts the archive flag back`、`delete: a failing session step rolls the message restore back` |
|
|
300
|
+
| P1-4 | 高 | `client.js`:`IncompleteEntries` 按 `hasSessionLog` 渲染「恢复」按钮 | `client: an incomplete entry that still holds its log offers a restore action` |
|
|
301
|
+
| P1-5 | 高 | `lingshu.js`:导出 catch 还原后删除未登记的 bundle 目录 | `lingshu adapter: a failed export removes its untracked bundle directory` |
|
|
302
|
+
| P1-6 | 高 | `lib/host/api.js`:mutation 串行链 + `<dataDir>/mutation.lock` 跨进程文件锁,抢不到返回 `E_LOCK_BUSY` | `mutation lock: a busy cross-process lock is E_LOCK_BUSY, not a corrupted manifest` |
|
|
303
|
+
| P2-1 | 中 | `catalog.js`:先 `attachSession` 再 `insertSessionBefore(id, anchor)` | `hard delete: …restore re-attaches it`、`restore: payload first, catalog second, memory last` |
|
|
304
|
+
| P2-2 | 中 | `cascade.js`:purge 移动日志后立即 `trash.update({sessionLog})`;恢复/discard 先查磁盘 `session/` | `purge: a soft entry performs the missing hard stages before removing the bundle`、`purge: a soft entry whose session is already gone only removes the bundle` |
|
|
305
|
+
| P2-3 | 中 | `lib/host/memory.js`:全部适配器恢复成功后才统一删除 bundle | `memory service: a multi-adapter restore keeps every bundle until all adapters succeed` |
|
|
306
|
+
| P2-4 | 中 | `client.js`:对话框初始模式读 `/state` 的 `defaultMode` | `client: ui flags carry the Host defaultMode instead of a hard-coded soft start` |
|
|
307
|
+
| P2-5 | 中 | `client.js`:`currentLocaleId` 读 `snapshot.active` | `client: the active locale is read from the snapshot field the host actually uses` |
|
|
308
|
+
| P2-6 | 中 | `lib/host/paths.js`:`~`/`~/`/`~\` 展开后 resolve | `core.test.mjs` 路径归一用例 + `normalizeConfig` 默认值 |
|
|
309
|
+
| P2-7 | 中 | `lib/host/api.js`:`/sessions` 每行带 `deletable` + `policyError`;`gateway.js` 修复行上 workspace 归属(cwd 回退);`client.js`:`BatchSessionRow` 禁用 + 原因 + `selectableSessionIds` 过滤 | `sessions: each row carries a deletable verdict and the reason when it is not`、`client: the batch picker disables rows the Host refuses and says why` |
|
|
310
|
+
| P2-8 | 中 | `lib/host/api.js`:`/preview` 返回 `planTimeoutMs = min(900s, 15s + 16s×数量 + live数×liveDrainMs)`;客户端按它设超时 | `preview: the plan timeout grows with the batch so a long delete is not aborted client-side`、`client: mutation timeouts grow with the batch instead of a flat 15s` |
|
|
311
|
+
| P2-9 | 中 | `lib/host/api.js`:`readJsonBody` 监听 `aborted`,以 `E_BAD_REQUEST` 拒绝,serialize 链继续 | `body: a client that disconnects mid-body gets a 400, not a parked mutation chain` |
|
|
312
|
+
| P2-10 | 中 | `lib/core/redact.js`(新建):details/cause/message 中的绝对路径降为 basename | `errors: unknown exceptions are normalized without leaking the message` + 安全测试 |
|
|
313
|
+
| P3-1 | 低 | `lib/core/config.js`:`readString` 接受 `null` 作为未设置 | `normalizeConfig: empty input yields the documented defaults` |
|
|
314
|
+
| P3-2 | 低 | `client.js`:改用 `--dsw-alias-state-warn-label` / `--dsw-alias-state-warn-tertiary` | 源码核对(CSS 变量,无单测) |
|
|
315
|
+
| P3-3 | 低 | 删除 `locale/{en,zh}.json` 死文件;`package.json` exports/files 清理;Client 内嵌两套文案 | `manifest: no shipped locale resources — the Client carries both inline tables` |
|
|
316
|
+
| P3-4 | 低 | `lib/host/api.js`:`requestOriginVerdict` 比较完整 authority(主机名 + 有效端口,缺失端口按 Origin scheme 归一) | `security: the full origin authority is compared, not just the hostname` |
|
|
317
|
+
| P3-5 | 低 | `lib/host/api.js`:桌面信任收紧为 `protocol === 'dsh-app:' && hostname === 'app'`(与 DSH 自身口径一致),`dsh-app://evil` 拒绝 | 同上(同用例覆盖两种分支) |
|
|
318
|
+
| P3-6 | 低 | `lib/host/api.js`:`SESSION_ID_PATTERN` 放开 `.` | `api.test.mjs` 会话 id 校验 |
|
|
319
|
+
| P3-7 | 低 | `lib/core/policy.js`:`normalizePathForCompare` 只对盘符/UNC 路径小写,POSIX 保持敏感 | `isPathInside compares Windows paths case-insensitively and by segment` |
|
|
320
|
+
| P3-8 | 低 | `lib/host/api.js`:`auditSource = process.versions.electron ? 'desktop-ui' : 'web-ui'` | `audit: the source records which shell asked (web UI vs the desktop app)` |
|
|
321
|
+
| P3-9 | 低 | `client.js`:删除不可达的 `dialog.hardWarning` 文案 | `client-module` 渲染冒烟 |
|
|
322
|
+
| P3-10 | 低 | `client.js`:`adapterLabel`/`adapterReason` 按语言本地化,未知 key 原样透出 | `client: adapter labels and reasons are localized, not raw Host ids` |
|
|
323
|
+
| — | 本轮新发现 | `lib/host/gateway.js`:`loadList` 行上无 workspace 对象导致 `/sessions` 每行误判 orphan、批量选择器全灰;增加 cwd 包含回退 | `sessions: each row carries a deletable verdict…` |
|
|
324
|
+
|
|
325
|
+
遗留的诚实边界(非缺陷,设计取舍):网页版插件仍未安装到 `profiles/web`(安装动作属于用户操作);`_index_log` 回放复活未在真实库上实证(避免破坏数据,已按 md_cg 装载路径实现分片写入);Windows live 日志 rename 与句柄交互未实测;桌面更新截断删除未在真实更新中复现(已加门禁,机理明确)。
|
|
326
|
+
|
|
327
|
+
---
|
|
328
|
+
|
|
329
|
+
### 附:本次审计直接引用的实现位置
|
|
330
|
+
|
|
331
|
+
- 插件:`lib/host/api.js`(`requestOriginVerdict` 76–116、`serialize` 263–267、handler 648)、`lib/host/catalog.js`(`assertNoForeignWriter` 69–87、`remove` 163–220、`restore` 226–258)、`lib/core/memory/json-index-adapter.js`(`withLock` 128–186)、`lib/core/memory/registry.js`(164–184)、`lib/core/cascade.js`(`performRestore` 266–363、`performPurge` 382–467)、`lib/host/paths.js`(17–20)、`lib/core/config.js`(94–99)、`client.js`(locale 273–285、dialog 685–907、DialogHost 922–928、settings 打开 1217、注册 1359–1385、CSS 1249/1317)。
|
|
332
|
+
- DSH 0.2.1-rc.2:`app.asar` → `/lib/main.js` 8268–8310(`forwardWebRequest`)、`dsh-desktop-host/lib/index.js` 108–182、`dsh-client-connection/lib/index.js` 240–249/840–858/1035–1060、`dsh-client-ui-settings-general/lib/client.js` 60/280–305、`dsh-client-ui-layout/lib/client.js` 73/344–352、`dsh-client-locale/lib/client.js` 1219–1226/1250、`dsh-workspace/lib/index.js` 111–147。
|
|
333
|
+
- 灵枢:`@furongjun1999/dsh-memory/md_cg/fsutil.py` 124–165(FileLock)、`md_cg/mdcg.py` 1508/1513+(`_apply_log`)。
|
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,63 @@
|
|
|
3
3
|
All notable changes to `dsh-conversation-recycle-bin` are documented here.
|
|
4
4
|
The format follows Keep a Changelog; the project uses Semantic Versioning.
|
|
5
5
|
|
|
6
|
+
## [0.2.2] — 2026-10-18
|
|
7
|
+
|
|
8
|
+
对 0.2.1 的双环境(桌面/网页)全面审计修复轮。问题编号对应 `AUDIT-2026-10-17-desktop-web.md`;每个问题在该文档中都有平台、复现条件、原因与修复方向的完整记录。
|
|
9
|
+
|
|
10
|
+
### Security
|
|
11
|
+
|
|
12
|
+
- **删除路由接入 DSH `connection` 门禁(P1-1)**:插件路由此前完全绕过 `connection.admit()` 与 `connection/request` 瀑布流——桌面端安装更新或退出时,更新任务只排空经过 `/api` 的在途请求,一个耗时删除会被直接截断。现在变更路由先过 `ctx.connection.requestRejection(req)`(401/403 短路),再进 `ctx.waterfall('connection/request', …)`:桌面更新门禁借此返回 503 并等待在途变更,删除事务不会被杀在半路。
|
|
13
|
+
- **跨进程 mutation 锁(P1-6)**:桌面端与 `dsh web` 共享同一个 `<DSH_HOME>/delete-conversation/` 时,两个 Host 可能同时读改写 manifest 与审计。所有 mutation(delete/restore/purge/discard)现在在进程内串行链之外再抢 `<dataDir>/mutation.lock` 跨进程文件锁(`{pid,nonce,at}` 注册 + 心跳 + stale 判定,nonce 匹配才删除);抢不到锁返回可读的 `E_LOCK_BUSY`,而不是写坏同一份 manifest。
|
|
14
|
+
- **同源校验比较完整 authority(P3-4)**:原先只比主机名,`http://127.0.0.1:3000` 的 Origin 与 `Host: 127.0.0.1:8080` 会被判同源。现在比较主机名 + 有效端口(缺失端口按 Origin 自身 scheme 归一),「同源校验」名实相符。
|
|
15
|
+
- **错误详情脱敏(P2-10)**:`DeleteError.details` 里的绝对路径(`indexFile`/`lockFile`/`from`/`to`/ENOENT 文案等)不再原样进入客户端响应,只保留 basename 级信息,与「客户端只拿计数与状态、不拿文件句柄」的契约对齐;Host 日志侧不变。
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- **灵枢锁协议不兼容:请求可能永不返回(P0-1)**:适配器把「锁文件存在」当锁(`open(lockPath,'wx')`),而 md_cg 的 `FileLock` 是 OS 级建议锁、锁文件永远是 0 字节且从不删除——只要灵枢跑过一次,`wx` 必然 `EEXIST`;判 stale 后进入一条既无 sleep、又在超时判断之前的忙循环,Windows 上请求永不返回并堵死整个 mutation 队列(本机审计里连续 3 次 hard+记忆删除因此回滚)。现在改用插件私有锁 `_index.json.conversation-recycle-bin.lock`:pid/nonce 注册、心跳续期、stale 判定、忙等超时(`E_LOCK_BUSY`),**永不触碰也永不删除** md_cg 的永久锁文件。0.1.3 的「0 字节无主锁超过 5s 清除」经核实是误判(0 字节是 md_cg 锁的常态),相关表述已从 README/CHANGELOG 更正。
|
|
20
|
+
- **灵枢索引直写不在其写协议内:节点可能回放复活(P0-2)**:md_cg 的持久化是「快照 `_index.json` + `_index_log/` 追加分片,装载时重放」。插件过去只重写快照,被删节点的记录留在分片里,下次装载会把节点加回来而正文已在回收站——典型「索引有条目、文件不存在」的幽灵节点。现在删除追加 `{"id, e:null}` 墓碑分片、恢复追加节点记录分片,`_t` 取「现在」与「最新分片时间 + 1ms」的较大值,保证重放时我们的记录排在最后:已删节点不会复活、已恢复节点不会被旧墓碑覆盖。
|
|
21
|
+
- **设置页批量删除的确认框被设置弹层遮住(P0-3)**:设置弹层是 `createPortal(..., document.body)` 的 `position:fixed; z-index:1000`,插件对话框挂在 `.overlayLayer{z-index:20}` 内,遮罩的 `z-index:1000` 只是该 stacking context 内的值,跨不过父级的 20。确认框现在同样 portal 到 `document.body`、层级取 1100;toast 一并处理,恢复/清理的成功反馈在设置页里也可见了。
|
|
22
|
+
- **live 会话跳过 foreign-writer 检查(P1-2)**:`assertNoForeignWriter` 开头就是 `if (session.live) return`——而 `quiesce` 只能停**本进程**的 Agent,另一端 Host 的 writer 恰好是本进程视角下的 live 会话,最需要保护的场景被豁免。现在所有模式都做 mtime 复检(grace 窗口通常在 `quiesce` 期间已满足,额外成本是一次 mtime 扫描)。
|
|
23
|
+
- **软删除条目恢复失败把会话变成孤儿(P1-3)**:`catalog.restore` 的 undo 无条件 `detachSession`,而软删除的会话本来就在工作区里(归档只是隐藏),回滚把它从侧栏彻底摘掉。现在 undo 只反转本步**实际做过**的变更(是否 attach、是否 unarchive),软删除回滚时重新归档。
|
|
24
|
+
- **未完成条目没有恢复入口(P1-4)**:`IncompleteEntries` 只渲染「清理」,但 `discard` 对仍持有 `session/` 的条目会拒绝,UI 提示「请改用恢复」却没有恢复按钮。现在按 `hasSessionLog` 渲染「恢复」按钮(调用已有的 `/trash/restore`),死条目有了出口。
|
|
25
|
+
- **记忆导出失败留下永久卡死的条目(P1-5)**:`exportSession` 失败后已写出的 bundle 目录不删,manifest 又没记 memory,恢复与 discard 都拒绝。现在 catch 里还原文件后删除未登记的 bundle 目录。
|
|
26
|
+
- **恢复不恢复原位置(P2-1)**:`insertSessionBefore` 要求会话已在 `sessionIds` 里,被 detach 的会话不在,于是必然走 catch 前插到列表顶部,快照的 `nextSessionId` 从未生效。现在先 `attachSession` 再 `insertSessionBefore(id, anchor)`。
|
|
27
|
+
- **软删除 purge 的崩溃窗口(P2-2)**:日志已移进条目但 manifest 未写回 `sessionLog`,崩溃后一次恢复可能把仍躺在条目里的 `session/` 一并删掉。现在移动成功后立即更新 manifest;恢复/discard 都先检查磁盘上的 `session/`,不只信 manifest。
|
|
28
|
+
- **多适配器恢复边恢复边删 bundle(P2-3)**:第一个适配器成功后 bundle 即被删,第二个失败时条目永远无法重试。现在全部适配器成功后才统一删除 bundle。
|
|
29
|
+
- **`defaultMode` 是死配置(P2-4)**:对话框固定 `useState('soft')`,schema 却承诺「Initial mode selected in the confirmation dialog」。现在从 `/state` 的 ui flags 读取 Host 配置的默认模式。
|
|
30
|
+
- **语言检测读错字段(P2-5)**:`dsh-client-locale` 快照结构是 `{active, locales, revision}`,插件找 `snapshot.locale ?? snapshot.id` 恒不中,回退 `navigator.language`——DSH 设置里切语言不生效。现在读 `snapshot.active`。
|
|
31
|
+
- **`resolveDshHome` 不展开 `~`(P2-6)**:`DSH_HOME=~/dsh-data` 会在 cwd 下创建字面量 `~` 目录,回收站/审计落到错误位置。现在按官方口径展开 `~`/`~/`/`~\` 后 resolve。
|
|
32
|
+
- **批量全选被单个不可删会话卡死(P2-7)**:`/preview` 只要一个 id 被策略拒绝就整批不签发 token,客户端禁用按钮且只显示第一条原因。现在 `/sessions` 每行带 `deletable` 裁决与原因,复选框禁用、hint 追加「无法删除: \<原因\>」,全选只选可删项。
|
|
33
|
+
- **批量变更超时上限小于最坏耗时(P2-8)**:客户端 `30s + 5s × 数量` 上限 180s,50 个 live 会话可超时后 Host 继续执行、token 已消费。现在 `/preview` 返回 `planTimeoutMs`,客户端按计划规模设超时。
|
|
34
|
+
- **`readJsonBody` 不处理请求中止(P2-9)**:客户端发 body 期间断开(超时/关页)时 `end` 永不触发,Promise 不 settle 并堵死 mutation 链。现在监听 `aborted`,以 `E_BAD_REQUEST` 拒绝。
|
|
35
|
+
- **每次激活打印 3 条假告警(P3-1)**:`cordis.patch.yml` 把可空字段写成 `null`,`readString` 只接受 `undefined`/string。现在 `null` 作为「未设置」接受。
|
|
36
|
+
- **深色主题 live 警告框不跟主题(P3-2)**:`--dsw-alias-text-warning`/`--dsw-alias-fill-warning` 在 0.2.1-rc.2 不存在(实际是 `--dsw-alias-state-warn-label`/`-tertiary`),回退成浅色块。已改用真实变量。
|
|
37
|
+
- **桌面 `dsh-app:` 信任分支过宽(P3-5)**:桌面转发已剥离 Origin,该分支在真实链路里不触发;若保留则任何注册 `dsh-app:` scheme 的 Electron 应用都会被视同源。现收紧为 `protocol === 'dsh-app:' && hostname === 'app'`(与 DSH 自身 `origin === "dsh-app://app"` 口径一致),作为纵深防御保留。
|
|
38
|
+
- **会话 id 正则不接受 `.`(P3-6)**:DSH `encodeSegment` 允许 `.`/`_`/`-`,fork/自定义 id 会被误拒。已放开。
|
|
39
|
+
- **工作区路径大小写比较不一致(P3-7)**:`workspaceFor` 对**所有**路径 `toLowerCase()`,Linux 上 `/a/Foo` 与 `/a/foo` 误配;UNC 未按 Windows 语义处理。现在只对盘符路径与 UNC 根小写,POSIX 路径保持大小写敏感。
|
|
40
|
+
- **审计 `source` 固定 `web-ui`(P3-8)**:桌面端发起的操作也记成 `web-ui`,平台取证时误导。现在按 `process.versions.electron` 记 `desktop-ui`/`web-ui`。
|
|
41
|
+
- **批量软删除的硬删除文案分支不可达(P3-9)**:`dialog.hardWarning`(「请输入 DELETE」)在 `expectedPhrase === ''` 时不会渲染,hard 模式永远一键。已删除该文案。
|
|
42
|
+
- **记忆适配器标签半本地化(P3-10)**:lingshu 的 label 是中英混合常量、`none` 是英文 reason,英文界面里会出现中文。现在 `adapterLabel`/`adapterReason` 按当前语言本地化,未知 key 原样透出。
|
|
43
|
+
- **`/sessions` 每行误判 orphan**:`loadList` 只按 membership 解析 workspace,行上无 `workspace` 对象,`evaluateSessionPolicy({workspace: undefined})` 恒为 false,批量选择器全灰。现在增加 cwd 包含回退(与 `inspect` 的 `workspaceFor` 一致)并在行上附 workspace 对象。
|
|
44
|
+
|
|
45
|
+
### Removed
|
|
46
|
+
|
|
47
|
+
- `locale/{en,zh}.json`(P3-3):运行时从无加载方,客户端只读内联 `STRINGS`,JSON 的键集与内联表不同(改了不生效)。两个死文件已删除,`package.json` 的 exports/files 同步清理;manifest 测试现在断言「不发布 locale 资源,Client 内嵌两套文案」。
|
|
48
|
+
|
|
49
|
+
### Changed
|
|
50
|
+
|
|
51
|
+
- `BatchSessionRow` 从 `SettingsSection` 的内联 JSX 抽为纯组件并导出,行禁用态、原因文案与 `selectableSessionIds` 过滤因此可被测试直接覆盖(P2-7 的验证基础)。
|
|
52
|
+
|
|
53
|
+
### Tests
|
|
54
|
+
|
|
55
|
+
- 104 checks (was 84); added full-authority origin comparison (含异端口/默认端口归一/`dsh-app://app` 信任与 `dsh-app://evil` 拒绝), connection `requestRejection`/waterfall 两道闸, cross-process mutation lock (`E_LOCK_BUSY`), batch-scaled plan timeout, per-row deletable verdict + reason, audit source (desktop/web), mid-body abort → 400, portal-into-body overlay (有空 payload/打开时两种形态), restore action for incomplete entries, batch row disabling with reasons, `defaultMode`/locale `active` from `/state`, adapter localization (per-locale fresh load), md_cg shard-log tombstones, multi-adapter restore atomicity, untracked bundle cleanup.
|
|
56
|
+
|
|
57
|
+
## [0.2.1] — 2026-10-18
|
|
58
|
+
|
|
59
|
+
### Fixed
|
|
60
|
+
|
|
61
|
+
- **包元数据缺少 `repository`**:`package.json` 里没有 `repository` / `homepage` / `bugs` / `author`,npm 页面因此没有源码链接;更关键的是 awesome-dsh-plugin 的 `scripts/probe-npm.mjs` 只在 npm 包的 `repository` 字段回指同一个 GitHub 仓库时才把包写进 `data/npm-map.json`,缺了它市场拿不到 npm 安装源。四个字段已补齐,代码与运行时行为无变化。
|
|
62
|
+
|
|
6
63
|
## [0.2.0] — 2026-10-17
|
|
7
64
|
|
|
8
65
|
### Changed
|
package/INSTALL.md
CHANGED
|
@@ -64,7 +64,7 @@ cordis_inspect_query platform=client provider=Slots method=listSubTree input={"r
|
|
|
64
64
|
6. 批量删除:勾选 2 个以上会话,批量**软删除**必须输入数量;批量彻底删除无需输入;超过当前有效上限(`min(maxBatchSessions, 本分钟剩余额度)`)时按钮禁用并提示上限,超过限流返回可读的 429 文案;
|
|
65
65
|
7. 无权限:对未登记工作区的会话,确认框显示不可删除原因;
|
|
66
66
|
8. 设置页「审计留痕」能看到成功/失败/回滚记录;
|
|
67
|
-
9.
|
|
67
|
+
9. 若回收站下方出现「未完成条目」:仍持有会话日志的条目会显示「恢复」按钮,点它可直接恢复回会话列表;确实已无日志与记忆的条目才可「清理」(输入 `DELETE`)。两类入口都在设置页内,不需要手工调 API。
|
|
68
68
|
|
|
69
69
|
> 无法控制浏览器时,安装与槽位注册只能证明「入口已挂载」,不能证明视觉效果;请如实记录这一限制。
|
|
70
70
|
|
|
@@ -107,6 +107,7 @@ plugin_manager action=remove_bundle target=dsh-conversation-recycle-bin
|
|
|
107
107
|
| `E_POLICY_ORPHAN` | 会话未登记到任何工作区。确属要删时在配置中 `allowOrphanSessions: true`,或先在工作区里登记 |
|
|
108
108
|
| `E_POLICY_LIVE` | 配置了 `stopLiveSessions: false` 且会话仍活跃;或插件在 `liveDrainMs` 内无法停住该会话(仍在运行)。改为默认配置,或等该会话空闲后重试 |
|
|
109
109
|
| `E_POLICY_FOREIGN_WRITER` | 另一个 DSH Host(桌面端或 `dsh web`)仍在写这个会话日志。先关闭那一端的会话再重试;`foreignWriterGraceMs` 只能降低窗口,不能替代关闭写入方 |
|
|
110
|
+
| `E_LOCK_BUSY` | 另一个 Host 进程正持有 `<DSH_HOME>/delete-conversation/mutation.lock`(桌面端与 `dsh web` 同时运行、或上一次删除仍在跨进程等待)。同一 `DSH_HOME` 下只应有一个 Host 执行删除;确认没有在途删除后重试即可 |
|
|
110
111
|
| `memory.adapters` 全部 unavailable | 本机没有可识别的记忆存储;检查 `<DSH_HOME>/.dsh-memory/data/mdcg/_index.json` 是否存在且 `schema === 2` |
|
|
111
112
|
| 删除返回 `E_TRANSACTION_FAILED` 且 `rolledBack: true` | 事务已回滚,数据未丢。看审计 `rollback.errors` 定位失败阶段(memory/messages/session) |
|
|
112
113
|
| 升级 DSH 后删除失败 | 先跑测试;确认会话目录仍为 `<DSH_HOME>/sessions/<slug>/<id>/`、`_index.json` 仍为 schema 2;必要时在测试会话上验证后再用 |
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
给 DeepSeek Harness (DSH) Web 增加「删除对话」:会话列表的「…」菜单与会话详情页头部各有一个删除入口;确认框内可勾选「同时删除关联记忆」(默认不勾选),并实时显示将被影响的记忆条数。删除默认进入插件回收站,可恢复;每次操作写入审计日志;批量删除有上限与限流,批量软删除需输入数量,彻底删除一键执行;事务任一步失败即回滚。
|
|
4
4
|
|
|
5
|
-
> 状态:`0.2.
|
|
5
|
+
> 状态:`0.2.2`,Host + Web Client 双半,零构建(plain JS ESM),只依赖宿主已发布的公开扩展点。
|
|
6
6
|
> 包名:`dsh-conversation-recycle-bin`(原 `dsh-delete-conversation` 因 npm 被他人占用而改名,见 CHANGELOG 0.2.0)。
|
|
7
7
|
> 本 README 同时是设计说明与验收依据;安装/回滚步骤见 [INSTALL.md](INSTALL.md);变更见 [CHANGELOG.md](CHANGELOG.md)。
|
|
8
8
|
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
| 对话框落点 | 菜单项随下拉菜单卸载;对话框必须挂在根级 overlay | `shell.overlay` 里的 `DialogHost`,菜单项只写模块级 dialog store |
|
|
24
24
|
| Client→Host | 生成式 `ctx.remote.*` 在构建期固定,纯 JS 插件无法新增命名空间;Client `ctx.connection` 没有 `fetch`;推荐 Host `ctx.webServer.register({kind:'prefix', path, handler})` + 同源 `fetch(document.baseURI)` | 自定义路由 `/conversation-recycle-bin/api/*` |
|
|
25
25
|
| 路由鉴权 | Web server 本身不鉴权;官方插件用 `ctx.connection.requestRejection(req)` 兜底。本插件另加:同源校验、loopback 默认、私有无头 header、JSON content-type、体积上限 | 见 `lib/host/api.js#requestOriginVerdict` |
|
|
26
|
+
| 桌面/网页传输差异 | 桌面端页面源是特权 scheme `dsh-app://app`(安装包 `out/lib/main.js` 注册),Renderer 对 Host 的请求经外壳 `forwardWebRequest` 转发:**删掉 `origin`/`sec-fetch-site`/`host` 头**并附上 Host 签发的 cookie;网页端是同源 fetch,三个头都在 | Client 一律用 `document.baseURI` 相对路径:桌面走 `dsh-app://app/...` 由外壳转发,网页走同源直连;Origin 完整性校验(主机名+端口)实际约束的是网页路径,桌路径由外壳自己的 `origin === "dsh-app://app"` 闸门 + cookie 保证 |
|
|
26
27
|
| Agent 工具 | 用户授权类操作应保持 user-only(skill `user-actions`) | **不注册任何 agent tool**,模型无法执行或授权删除 |
|
|
27
28
|
|
|
28
29
|
**操作者模型**:DSH 是本地单用户应用,没有账号/角色系统。因此本插件把「操作者权限」落实在传输层(同源页面、默认仅 loopback、私有请求头)与数据归属策略(工作区登记、允许根目录、受保护 id、子代理来源、live 状态)上,而不是虚构一套用户体系。
|
|
@@ -95,12 +96,13 @@ memory → messages → session/catalog
|
|
|
95
96
|
|
|
96
97
|
1. `POST /preview`:校验 session 存在、工作区归属、是否受保护/子代理/超出允许根;hard 模式下活跃会话不再拒绝,而是给出「会先停止该会话」的 warning(`stopLiveSessions:false` 时仍拒绝)。随后统计日志条数与记忆数,签发**单次使用**确认 token(绑定排序后的 sessionIds + includeMemory + mode,TTL 默认 120s)。
|
|
97
98
|
2. `POST /delete`:
|
|
98
|
-
-
|
|
99
|
+
- 传输层依次过三道闸:本路由自身的 loopback/Origin(比较完整 authority:主机名**和**有效端口,桌面 `dsh-app://app` 外壳源按 DSH 自己的口径豁免)/私有 header 校验 → DSH `connection.requestRejection`(401/403 短路)→ `ctx.waterfall('connection/request', ...)`(桌面更新任务借此在安装更新时返回 503 并排空在途请求,删除事务不会被杀在半路);
|
|
99
100
|
- token(彻底删除无需短语;批量软删除校验数量)、**有效批量上限** `min(maxBatchSessions, 本分钟剩余额度)`、限流(默认 20 次/分钟,滑动窗口)全部通过后才消费 token;
|
|
100
101
|
- 先对**全部**会话做策略预检,任一被拒绝则整批不执行(不会半成功);
|
|
101
102
|
- 逐会话执行 `quiesce(live) → memory → messages → session/catalog` 事务;
|
|
103
|
+
- 所有 mutation(delete/restore/purge/discard)串行经过进程内链 + `<dataDir>/mutation.lock` 跨进程文件锁:桌面端与 `dsh web` 共享一个 `DSH_HOME` 时,第二个实例的并发删除会拿到 `E_LOCK_BUSY` 而不是写坏同一份 manifest;
|
|
102
104
|
- 返回 `{ok: true, partial, results, failures}`:`ok` 表示计划已执行,`partial` 表示有会话失败;只有**全部**失败时才抛首个错误(HTTP 状态来自 `ERROR_STATUS`,错误码保留真实策略码)。
|
|
103
|
-
3. `GET /trash`(返回 `entries`(可恢复)与 `incomplete`(failed/pending/restoring/purging))、`POST /trash/restore`、`POST /trash/purge`(必须输入 `DELETE`)、`POST /trash/discard`(清理未完成条目,必须输入 `DELETE
|
|
105
|
+
3. `GET /trash`(返回 `entries`(可恢复)与 `incomplete`(failed/pending/restoring/purging))、`POST /trash/restore`、`POST /trash/purge`(必须输入 `DELETE`)、`POST /trash/discard`(清理未完成条目,必须输入 `DELETE`;仍保存会话日志的条目应走「恢复」而不是 discard)。未完成条目若还留着会话日志,设置页会给出「恢复」按钮,可以直接把它恢复回会话列表。
|
|
104
106
|
4. `GET /audit?limit=N`。
|
|
105
107
|
|
|
106
108
|
### 4.2 安全清单
|
|
@@ -109,7 +111,8 @@ memory → messages → session/catalog
|
|
|
109
111
|
|---|---|
|
|
110
112
|
| 误删 | 默认软删除;记忆复选框默认关;回收站;彻底删除一键但仍写回收站可恢复;批量软删除需输入数量 |
|
|
111
113
|
| 批量误操作 | `maxBatchSessions`(实际上限还会被本分钟剩余额度收紧,避免"合法批量必然 429");批量需输入数量;每个会话独立事务 |
|
|
112
|
-
| 暴力/脚本调用 |
|
|
114
|
+
| 暴力/脚本调用 | 同源校验(完整 authority:主机名+有效端口)+ `x-conversation-recycle-bin: 1` 私有 header + DSH `connection` 层(`requestRejection` + `connection/request` 瀑布流)+ 限流;默认仅接受 loopback 绑定,`allowNonLoopback=false` |
|
|
115
|
+
| 两个 Host 并发写同一回收站 | mutation 串行 + `<dataDir>/mutation.lock` 跨进程文件锁,抢不到锁返回 `E_LOCK_BUSY`(而非写坏 manifest) |
|
|
113
116
|
| 越权/越界 | 不在工作区登记的会话默认拒绝;可配置允许根目录白名单;受保护 id 列表;子代理会话默认拒绝 |
|
|
114
117
|
| 活跃会话被写坏 | hard 模式对 live 会话先走 `quiesce`:取消当前轮次 → `archiveSession({stopActivity:true})` → 在 `liveDrainMs` 内等待 `running=false`,之后才移动日志;仍停不下来则整笔回滚并报 `E_POLICY_LIVE`。`stopLiveSessions:false` 可恢复为直接拒绝 |
|
|
115
118
|
| 另一个 Host 正在写 | 移动前按 `foreignWriterGraceMs` 复检日志 mtime;仍在增长则报 `E_POLICY_FOREIGN_WRITER`。best-effort:两端同时运行时仍应先关闭另一端的会话 |
|
|
@@ -132,7 +135,7 @@ memory → messages → session/catalog
|
|
|
132
135
|
files/<nodeId>.content
|
|
133
136
|
```
|
|
134
137
|
|
|
135
|
-
`manifest.status` 只允许 `pending → trashed → restoring/restored / purging
|
|
138
|
+
`manifest.status` 只允许 `pending → trashed → restoring/restored / purging`。`GET /trash` 把**所有**条目都返回:`trashed` 进 `entries`(回收站 UI,可恢复/彻底删除),其余状态进 `incomplete`(设置页单独列出,提供「恢复」与「discard」两个出口,不再出现"失败了却没有任何清理入口"的死条目)。恢复顺序是删除的逆序:日志 → catalog → 记忆。
|
|
136
139
|
|
|
137
140
|
---
|
|
138
141
|
|
|
@@ -141,7 +144,8 @@ memory → messages → session/catalog
|
|
|
141
144
|
### 5.1 灵枢(默认,已实机核对格式)
|
|
142
145
|
|
|
143
146
|
- 计数:读 `_index.json`,按 `nodes[*].session === sessionId` 过滤;`protected === true` 或 `immutable === true` 的节点只计数、不删除。
|
|
144
|
-
-
|
|
147
|
+
- 删除:持插件私有锁 `_index.json.conversation-recycle-bin.lock`(**不碰** md_cg 的 `_index.json.lock`——那是 md_cg 的永久 0 字节 OS 字节锁文件,存在与否不代表加锁,删它反而会破坏 md_cg 自己的互斥;本锁只协调本插件的多个 Host 进程),把节点正文移到回收站 bundle,从索引删除节点,并剥离其他节点指向被删节点的 `edges/depends_on/derived_from` 引用;先写 bundle 再改索引,失败时还原。**没有任何可删节点的会话会先读索引直接返回空 bundle,不取锁**,因此记忆库繁忙不会拖住一次本来无记忆可删的彻底删除。
|
|
148
|
+
- 分片日志:md_cg 的索引是「快照 `_index.json` + `_index_log/` 追加分片,装载时重放」。删除/恢复除改写快照外,还会写入一个**新分片**(`e: null` 墓碑 / 恢复后的节点记录),`_t` 取「现在」与「最新分片时间 + 1ms」的较大值,保证重放时我们的记录排在最后,已删节点不会因重放复活、已恢复节点不会被旧墓碑覆盖。
|
|
145
149
|
- 恢复:按 bundle 把正文放回、节点条目与引用补回;若目标 id 已存在则拒绝并报 `E_TRASH_CONFLICT`。
|
|
146
150
|
- **边界**:这是「离线索引适配器」,不经过灵枢的 `forget` 工具,因此不会写它的 `_deletions.jsonl` 墓碑、`trash/` 与 review queue;`_recent.jsonl` 滚动窗口里的旧文本会自然过期。对格式做的是 schema 2 的受支持子集。若要求走灵枢自己的软删/审计语义,请使用 `lingshu_cg op=forget`(可能进入 review queue,需校验 receipt),本插件不冒充该协议。
|
|
147
151
|
|
|
@@ -164,8 +168,8 @@ dsh-conversation-recycle-bin/
|
|
|
164
168
|
├─ package.json # bundle + dsh.client 声明
|
|
165
169
|
├─ cordis.patch.yml # Host 行与默认配置
|
|
166
170
|
├─ config.schema.json # 配置/权限的机器可读声明
|
|
167
|
-
├─ client.js # Web Client:4 个槽位 + 确认框 +
|
|
168
|
-
├─ icon.svg
|
|
171
|
+
├─ client.js # Web Client:4 个槽位 + 确认框 + 设置页(内联中英文案)
|
|
172
|
+
├─ icon.svg
|
|
169
173
|
├─ lib/
|
|
170
174
|
│ ├─ index.js # apply(ctx, config):注册路由
|
|
171
175
|
│ ├─ core/ # 纯逻辑(无 DSH 依赖,可单测)
|
|
@@ -185,7 +189,7 @@ dsh-conversation-recycle-bin/
|
|
|
185
189
|
│ ├─ lifecycle.js # live 会话停机:cancel + archive + drain
|
|
186
190
|
│ ├─ memory.js # 适配器装配
|
|
187
191
|
│ └─ api.js # 同源 HTTP API、确认 token、限流
|
|
188
|
-
└─ test/ #
|
|
192
|
+
└─ test/ # 104 个用例,覆盖用户要求的 5 类场景 + live 删除 + 记忆锁 + 原地刷新 + 部分失败/跨进程/未完成条目 + Client 半渲染冒烟
|
|
189
193
|
```
|
|
190
194
|
|
|
191
195
|
---
|
|
@@ -194,7 +198,7 @@ dsh-conversation-recycle-bin/
|
|
|
194
198
|
|
|
195
199
|
```powershell
|
|
196
200
|
cd D:\Users\34332\AI\dsh-conversation-recycle-bin
|
|
197
|
-
npm test # = manifest && client-module && core && cascade && memory && trash-audit && api(
|
|
201
|
+
npm test # = manifest && client-module && core && cascade && memory && trash-audit && api(104 个用例)
|
|
198
202
|
npm run check # 语法 + manifest
|
|
199
203
|
```
|
|
200
204
|
|
|
@@ -220,5 +224,5 @@ npm run check # 语法 + manifest
|
|
|
220
224
|
3. 灵枢适配器不写其删除台账(§5.1);跨节点引用只处理 `edges/depends_on/derived_from` 三个已知字段。
|
|
221
225
|
4. 投影缓存清理是 best-effort;旧日志/附件按宿主语义保留。
|
|
222
226
|
5. UI 依赖当前版本的 slot 约定;宿主升级若改 slot props,入口会失效但不会破坏宿主。0.1.4 的原地刷新还会调用 `uiWorkspace.clearArchivedCurrent/clearMain`(宿主 `.d.ts` 中标为 private);两者都不存在时会退回整页刷新,不会留下过期视图,但属于对私有方法的依赖,宿主升级需复核。
|
|
223
|
-
6.
|
|
224
|
-
7.
|
|
227
|
+
6. 回收站 manifest 的并发写由 `<dataDir>/mutation.lock` 跨进程锁保护(抢不到锁返回 `E_LOCK_BUSY`);但**会话日志内容**的跨进程写保护仍是 best-effort:`foreignWriterGraceMs` 只能发现"窗口内仍在增长"的日志,另一个 Host 的空闲 live 会话(两次写入之间)无法被本进程识别。桌面端与 `dsh web` 同时运行时,仍应先关闭另一端的会话再彻底删除。
|
|
228
|
+
7. 未完成条目(failed/pending/restoring/purging)若仍持有已移动的会话日志或记忆文件,出于数据安全不允许 `discard`(会报 `E_TRASH_CONFLICT`);设置页对这类条目提供「恢复」按钮,可直接恢复回会话列表,不再出现"失败了却没有出口"的死条目。日志与记忆都已不在磁盘上的条目才可以 discard。
|