dsh-conversation-recycle-bin 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/CHANGELOG.md CHANGED
@@ -3,6 +3,48 @@
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.3.5] — 2026-10-22
7
+
8
+ 对应 `AUDIT-2026-10-22-desktop-web-compat.md`:把 0.3.4 之后一直只存在于工作树的修复正式发布,并落地该报告 §9 的全部建议。
9
+
10
+ ### Fixed
11
+
12
+ - **C-01 前缀部署下所有 API 丢失路径前缀**:`client.js` 新增 `apiUrl()`,用 `new URL('.', document.baseURI)` 归一化 base 并去掉 `API_PREFIX` 的前导斜杠,改为文档相对解析。`dsh web --public-url https://host/ui/` 下不再 404;桌面 `dsh-app://app/` 行为不变。0.3.4 的 tarball 仍带此缺陷(`new URL('/conversation-recycle-bin/api…', base)` 会替换整个 base 路径)。新增回归用例断言三种 base(根、`…/ui/`、`…/ui/index.html`、`dsh-app://app/`)解析结果。
13
+ - **C-02 回收站写操作超时短于宿主排队**:客户端 `TRASH_MUTATION_TIMEOUT_MS = 195s`,宿主 `TRASH_LOCK_BUDGET_MS = 180s`(此前 15s / 30s)。批量删除进行中再点恢复/彻底删除,不再出现"界面报错但宿主已执行"。
14
+ - **C-08 锁预算用例一直是红的**:该用例用软删除条目测 `discard`,而软删除按设计保留会话日志,`performDiscard` 必然返回 `E_TRASH_CONFLICT`(409)。改为 seed 一个从未提交的 `pending` 条目(无 `session/`、无 `memory/`),api 用例数 40 → 41 全绿。此前 0.3.4 的 `npm test` 因该用例无法通过而不能作为发布门禁。
15
+
16
+ ### Added
17
+
18
+ - **C-03 `allowedHosts`**:反代部署的精确 authority 白名单(`host`、`host:port`、`[v6]:port`)。裸主机放行任意端口,带端口的条目只放行该端口;带 scheme/路径/userinfo/通配符的条目会被丢弃并写入 warnings。默认 `[]` = 仅回环,与 0.3.4 行为一致。这样不必为了让反代可用而整体打开 `allowNonLoopback`。`config.schema.json`、`cordis.patch.yml`、README §4.2 同步。
19
+
20
+ ### Changed / Performance
21
+
22
+ - **P-01 `/trash` 双扫描合并**:`trash.scanAll()` 一次 `readdir` 产出 `{entries, unreadable}`,每条目只读一次 manifest,读取用 8 并发(此前 `listAll` + `scanUnreadable` 各扫一遍,合计 2 次 readdir + 2N 次读,且 `listAll` 串行)。0/50/200/500 条实测 1.2/17.2/63.3/156.3 ms → **1/4.9/14.4/38.1 ms**。
23
+ - **P-02 `/preview` 记忆索引只解析一次**:适配器按 `mtimeMs:size` 缓存已解析快照,`probe()` 与 `countSessions()` 共用;本适配器每次写索引后主动失效,其他进程改写由 stat key 识别。21000 节点(1.9 MB)单条预览 39.2 → **28.9 ms**(剩余部分是单次读盘 + 解析的固有成本)。
24
+ - **P-05 设置页按 tab 懒加载**:挂载/切换只拉当前 tab 需要的接口(回收站 `/trash`、批量 `/sessions`、审计 `/audit`),`/state` 仅在挂载与变更后取;刷新期间面板不再卸载,改为顶部细条指示,滚动位置与输入焦点不再丢失。对应把每次刷新 4 个请求(≈31 KB)降为 1–2 个。
25
+ - **P-04 宽限等待可见化**:彻底删除模式下对话框说明"刚使用过的对话可能多等约 1.5 秒";提交超过 1.2s 后显示"仍在执行:正在确认没有其他 DSH 实例写入该对话"。等待本身(`foreignWriterGraceMs`)是安全设计,未取消。
26
+
27
+ ### Maintenance
28
+
29
+ - 测试 131 → **138**(api 41、client 22、core 15、memory 13、cascade 15、feedback 19、manifest 9、trash-audit 4)。
30
+ - README 状态 0.2.2 → 0.3.5,用例计数与设置页/反代说明同步。
31
+
32
+ ## [0.3.4] — 2026-10-20
33
+
34
+ 对应 `AUDIT-2026-10-20-last3-versions.md` 的四条非阻塞隐患 N1–N4,以及测试驱动下新发现的 P3 回归 N5。
35
+
36
+ ### Fixed
37
+
38
+ - **N1:批量删除中途限流会静默丢尾项**:`deletePlan` 循环里 `!reservation.ok` 分支的 `break` 改为 `continue`。0.3.3 之前当第二 Host 在 preflight 与 take 之间抢走额度、或 `sessions.length > limiter.remaining`(跨进程 / 时间窗滑动)时,第一条被拒之后的所有 session 既不进 `results` 也不进 `failures`——一次 50 项批量 + 20/min 上限下,第 22–50 项从 UI 反馈里凭空消失。`continue` 让每一项各得一条 `E_RATE_LIMITED` 记录;首个项目预算不足直接抛 429 的既有契约不变。新增 `test/api.test.mjs` 用例:monkey-patch limiter 让 take 从第二次起即失败,断言 3 项批量 → 1 结果 + 2 失败。
39
+ - **N2:审计 `tail()` 字节窗口对齐到 `\n` 时丢一条完整记录**:`start > 0` 分支不再无条件 `rawLines.slice(1)`——先读 `start-1` 一字节,`\n` 意味着窗口从行首开始,`rawLines[0]` 是完整 JSON 保留;否则才丢。同时 `CHUNK` 抽为 `createAuditLog({ tailWindowBytes })` 便于回归。新增 `test/trash-audit.test.mjs` 用例:对齐窗口断言三条完整记录全在,偏移 1 字节窗口断言撕裂片段被正确丢弃。
40
+ - **N5(连带发现):`tail()` 从未真正做窗口读**:Node `fs.readFile(path, {encoding,flag,signal})` 不接受 `start/end`,0.3.3 P3「只读尾部 256KB」的实现其实每次仍全量读盘。改用 `fsApi.open(filePath, 'r')` + `FileHandle.read(buffer, offset, length, position)` 的位置化读,`stat` 也走 handle;`fsApi` 类型签名加 `open`。修复后 5MB 审计文件 tail 只吃 256KB 内存,与 P3 原意一致。
41
+ - **N3:HTTP 反馈通道对自定义鉴权头也强加 `Bearer ` 前缀**:`deliverHttp` 现只在 `authHeader === 'Authorization'` 时前缀 `Bearer `(RFC 7235 语法的合法要求),其它如 `X-Api-Key` / `x-goog-api-key` 直接发送裸 token——多数非 `Authorization` 中转拒绝 Bearer 前缀,此前配任何自定义头都会 401。默认 `Authorization` 通道逐字节不变。新增 `test/feedback.test.mjs` 双 case 用例(Bearer vs 裸 token)。
42
+ - **N4:`visitorAsFrom: true` 的 UI 提示未说明与常见发信方冲突**:`feedback.emailHintAsFrom` 词条中英各扩写为完整句子,说明「QQ / 163 / Gmail 一类要求 From 与登录账号一致会拒收,仅允许任意 From 的中继才生效」。行为不变、配置不变。
43
+
44
+ ### Changed / Maintenance
45
+
46
+ - 回归测试:`api` 38 → 39、`feedback` 18 → 19、`trash-audit` 3 → 4;总 128 → **131 全绿**。
47
+
6
48
  ## [0.3.3] — 2026-10-19
7
49
 
8
50
  本次修复对应 `AUDIT-2026-10-19-review.md` 的 S1–S3、P1–P5、R1–R4 与 M 项;每条均带回归测试。
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  给 DeepSeek Harness (DSH) Web 增加「删除对话」:会话列表的「…」菜单与会话详情页头部各有一个删除入口;确认框内可勾选「同时删除关联记忆」(默认不勾选),并实时显示将被影响的记忆条数。删除默认进入插件回收站,可恢复;每次操作写入审计日志;批量删除有上限与限流,批量软删除需输入数量,彻底删除一键执行;事务任一步失败即回滚。
4
4
 
5
- > 状态:`0.2.2`,Host + Web Client 双半,零构建(plain JS ESM),只依赖宿主已发布的公开扩展点。
5
+ > 状态:`0.3.5`,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
 
@@ -74,7 +74,7 @@ memory → messages → session/catalog
74
74
  |---|---|---|
75
75
  | 会话列表「…」 | `sidebar.workspaces.session.menu.item`(order 500) | 点击后关闭菜单,打开全局确认框 |
76
76
  | 会话详情页头部 | `conversation.session.header.actions`(order 100) | 图标按钮打开同一确认框 |
77
- | 设置页 | `settings.section`(order 1300) | 「回收站 / 批量删除 / 审计留痕」三个标签页 |
77
+ | 设置页 | `settings.section`(order 1300) | 「回收站 / 批量删除 / 审计留痕」三个标签页;按 tab 懒加载,刷新时保留旧数据 |
78
78
  | 全局确认框 | `shell.overlay` | 唯一删除发起处;挂在根级,不会随菜单卸载 |
79
79
 
80
80
  确认框内容:
@@ -111,7 +111,7 @@ memory → messages → session/catalog
111
111
  |---|---|
112
112
  | 误删 | 默认软删除;记忆复选框默认关;回收站;彻底删除一键但仍写回收站可恢复;批量软删除需输入数量 |
113
113
  | 批量误操作 | `maxBatchSessions`(实际上限还会被本分钟剩余额度收紧,避免"合法批量必然 429");批量需输入数量;每个会话独立事务 |
114
- | 暴力/脚本调用 | 同源校验(完整 authority:主机名+有效端口)+ `x-conversation-recycle-bin: 1` 私有 header + DSH `connection` 层(`requestRejection` + `connection/request` 瀑布流)+ 限流;默认仅接受 loopback 绑定,`allowNonLoopback=false` |
114
+ | 暴力/脚本调用 | 同源校验(完整 authority:主机名+有效端口)+ `x-conversation-recycle-bin: 1` 私有 header + DSH `connection` 层(`requestRejection` + `connection/request` 瀑布流)+ 限流;默认仅接受 loopback 绑定;反代部署用 `allowedHosts` 精确放行(不要为此整体打开 `allowNonLoopback`) |
115
115
  | 两个 Host 并发写同一回收站 | mutation 串行 + `<dataDir>/mutation.lock` 跨进程文件锁,抢不到锁返回 `E_LOCK_BUSY`(而非写坏 manifest) |
116
116
  | 越权/越界 | 不在工作区登记的会话默认拒绝;可配置允许根目录白名单;受保护 id 列表;子代理会话默认拒绝 |
117
117
  | 活跃会话被写坏 | hard 模式对 live 会话先走 `quiesce`:取消当前轮次 → `archiveSession({stopActivity:true})` → 在 `liveDrainMs` 内等待 `running=false`,之后才移动日志;仍停不下来则整笔回滚并报 `E_POLICY_LIVE`。`stopLiveSessions:false` 可恢复为直接拒绝 |
@@ -120,6 +120,17 @@ memory → messages → session/catalog
120
120
  | 不可追溯 | `<DSH_HOME>/delete-conversation/audit.jsonl` 追加成功/失败/回滚/拒绝/恢复/清理记录;设置页可查看 |
121
121
  | 外部记忆存储损坏 | 灵枢适配器持有其 `_index.json.lock`,先写 restore bundle 再原子改写索引;任何一步失败先还原正文与索引;详情见 §5.3 |
122
122
 
123
+ 反代部署(`dsh web --public-url https://host/ui/`)时,代理会把外部主机名放进 `Host`,插件的回环校验会以 `E_FORBIDDEN_HOST` 拒绝。用 `allowedHosts` 精确放行,而不是把 `allowNonLoopback` 整体打开:
124
+
125
+ | 配置 | 匹配 | 说明 |
126
+ |---|---|---|
127
+ | `allowedHosts: ["app.example"]` | 该主机的任意端口 | 端口在代理侧固定时更宽松 |
128
+ | `allowedHosts: ["app.example:8443"]` | 仅该端口 | 推荐;端口不匹配仍拒绝 |
129
+ | `allowedHosts: []`(默认) | 仅回环 | 与 0.3.4 行为一致 |
130
+
131
+ 条目必须是裸 authority(`host`、`host:port`、`[v6]:port`);带 scheme、路径、userinfo 或通配符的条目会被丢弃并在 `/state` 的 warnings 里报告。**代理读超时仍需单独调整**:nginx `proxy_read_timeout` 默认 60s、Cloudflare 到源站默认 100s,而大批量删除的宿主预算是 `min(900s, 15s + 16s×批量 + drain)`,超时后代理会先断而宿主仍在执行。
132
+
133
+
123
134
  ### 4.3 回收站布局
124
135
 
125
136
  存储目录沿用旧插件的 `delete-conversation`(不是新包名),这样 0.2.0 改名后已有的回收站条目和审计日志仍然可见。
@@ -190,7 +201,7 @@ dsh-conversation-recycle-bin/
190
201
  │ ├─ memory.js # 适配器装配
191
202
  │ ├─ feedback.js # 反馈校验/渲染/投递(http 或 smtp,凭证只读环境变量)
192
203
  │ └─ api.js # 同源 HTTP API、确认 token、限流
193
- └─ test/ # 122 个用例,覆盖用户要求的 5 类场景 + live 删除 + 记忆锁 + 原地刷新 + 部分失败/跨进程/未完成条目 + 反馈通道 + Client 半渲染冒烟
204
+ └─ test/ # 138 个用例,覆盖用户要求的 5 类场景 + live 删除 + 记忆锁 + 原地刷新 + 部分失败/跨进程/未完成条目 + 反馈通道 + Client 半渲染冒烟
194
205
  ```
195
206
 
196
207
  ---
@@ -199,7 +210,7 @@ dsh-conversation-recycle-bin/
199
210
 
200
211
  ```powershell
201
212
  cd D:\Users\34332\AI\dsh-conversation-recycle-bin
202
- npm test # = manifest && client-module && core && cascade && memory && trash-audit && feedback && api(122 个用例)
213
+ npm test # = manifest && client-module && core && cascade && memory && trash-audit && feedback && api(138 个用例)
203
214
  npm run check # 语法 + manifest
204
215
  ```
205
216
 
@@ -258,8 +269,8 @@ npm run check # 语法 + manifest
258
269
  | `DSH_CRB_FEEDBACK_TO` | 收件人(优先于 `feedback.to`) |
259
270
  | `DSH_CRB_FEEDBACK_FROM` | 发件人(优先于 `feedback.from`) |
260
271
  | `DSH_CRB_MAIL_API_URL` | 邮件 HTTP API 地址(Resend 兼容形状,也可指向自建转发网关) |
261
- | `DSH_CRB_MAIL_API_TOKEN` | 该 API 的 Bearer token |
262
- | `DSH_CRB_MAIL_API_AUTH_HEADER` | 可选,鉴权头名,默认 `authorization`,值为 `Bearer <token>` |
272
+ | `DSH_CRB_MAIL_API_TOKEN` | 该 API 的鉴权 token |
273
+ | `DSH_CRB_MAIL_API_AUTH_HEADER` | 可选,鉴权头名,默认 `authorization`;`authorization` 时值形如 `Bearer <token>`(RFC 7235),其它自定义头(如 `x-api-key`)直接发送裸 token |
263
274
  | `DSH_CRB_SMTP_URL` | SMTP 连接串,如 `smtps://user:pass@smtp.example.com:465` |
264
275
 
265
276
  HTTP 通道发出的 JSON 固定为 `{from, to: [收件人], subject, text, reply_to}`,`text` 是纯文本(不生成 HTML);`reply_to` 取自表单里访客填写的邮箱(留空则不填)。SMTP 通道按需 `import('nodemailer')`:它已声明为 `optionalDependencies`(`^10.1.2`),正常安装会随包就位;以 `link:` 方式开发安装时插件从自身真实目录解析,需在**本仓库**跑一次 `npm install`。仍未安装时返回 `E_FEEDBACK_TRANSPORT` + `reason: nodemailer-missing`。
@@ -287,8 +298,8 @@ node -e "console.log('b64:' + Buffer.from('your-feedback@example.com','utf8').to
287
298
  ```powershell
288
299
  cd D:\Users\34332\AI\dsh-conversation-recycle-bin
289
300
  npm run check # 语法 + manifest(含 feedback.js)
290
- node test/feedback.test.mjs # 17 个反馈用例
291
- npm test # 全量 122 个用例
301
+ node test/feedback.test.mjs # 19 个反馈用例
302
+ npm test # 全量 138 个用例
292
303
 
293
304
  # 端到端:用本机收件桩观察真实投递路径,不外发邮件
294
305
  $env:DSH_CRB_MAIL_API_URL = 'http://127.0.0.1:9099/emails'
package/client.js CHANGED
@@ -101,6 +101,8 @@ window.__ModuleLoader__.load({
101
101
  'dialog.phraseLabel': 'Confirmation',
102
102
  'dialog.delete': 'Delete',
103
103
  'dialog.working': 'Deleting…',
104
+ 'dialog.stillWorking': 'Still working — confirming no other DSH instance is writing this conversation.',
105
+ 'dialog.hardGraceHint': 'Permanent deletion re-checks the log for a second writer first, so a conversation used moments ago can take about 1.5s longer.',
104
106
  'dialog.loading': 'Reading the plan…',
105
107
  'dialog.done': 'Deleted {count} conversation(s); {memory} memory entries moved to the recycle bin.',
106
108
  'dialog.partial': 'Deleted {done}, failed {failed}.',
@@ -165,7 +167,7 @@ window.__ModuleLoader__.load({
165
167
  'feedback.emailLabel': 'Your email (optional)',
166
168
  'feedback.emailPlaceholder': 'you@example.com',
167
169
  'feedback.emailHint': 'Used as Reply-To so the maintainer can answer you. Leave it empty to stay anonymous.',
168
- 'feedback.emailHintAsFrom': 'Shown as the sender (From) of the mail.',
170
+ 'feedback.emailHintAsFrom': 'Sent as the mail\'s From address. Most relays (QQ, 163, Gmail) reject this unless the mailbox matches the login account — enable only when the relay accepts arbitrary From.',
169
171
  'feedback.contextLabel': 'Attached automatically',
170
172
  'feedback.contextHint': 'Plugin version, platform and language. Conversation content is never attached.',
171
173
  'feedback.contextVersion': 'Plugin version',
@@ -255,6 +257,8 @@ window.__ModuleLoader__.load({
255
257
  'dialog.phraseLabel': '确认',
256
258
  'dialog.delete': '删除',
257
259
  'dialog.working': '正在删除…',
260
+ 'dialog.stillWorking': '仍在执行:正在确认没有其他 DSH 实例写入该对话。',
261
+ 'dialog.hardGraceHint': '彻底删除会先复查日志是否还有第二个写入方;刚使用过的对话可能多等约 1.5 秒。',
258
262
  'dialog.loading': '正在读取删除计划…',
259
263
  'dialog.done': '已删除 {count} 个对话;移入回收站的记忆条目 {memory} 条。',
260
264
  'dialog.partial': '成功 {done} 个,失败 {failed} 个。',
@@ -319,7 +323,7 @@ window.__ModuleLoader__.load({
319
323
  'feedback.emailLabel': '你的邮箱(选填)',
320
324
  'feedback.emailPlaceholder': 'you@example.com',
321
325
  'feedback.emailHint': '作为 Reply-To,便于维护者直接回复你;留空即匿名。',
322
- 'feedback.emailHintAsFrom': '会直接作为这封邮件的发件人。',
326
+ 'feedback.emailHintAsFrom': '会直接作为这封邮件的发件人(From)。QQ / 163 / Gmail 一类会拒收非登录账号的 From,只有允许任意 From 的中继才生效。',
323
327
  'feedback.contextLabel': '自动附带',
324
328
  'feedback.contextHint': '插件版本、平台与语言;不会附带任何对话内容。',
325
329
  'feedback.contextVersion': '插件版本',
@@ -472,6 +476,23 @@ window.__ModuleLoader__.load({
472
476
  const API_PREFIX = '/conversation-recycle-bin/api'
473
477
  const REQUEST_TIMEOUT_MS = 15_000
474
478
  const MUTATION_TIMEOUT_MS = 180_000
479
+ /**
480
+ * Recycle-bin writes wait for the Host's cross-process lock, and that wait
481
+ * is unbounded in wall-clock terms: the request queues behind whatever
482
+ * mutation is already running (`serialize()` chains in-process first). A
483
+ * 15s budget therefore aborted restore / purge / discard while the Host was
484
+ * still working, so the UI reported a failure for a change that did happen.
485
+ * The Host caps its own lock wait at TRASH_LOCK_BUDGET_MS (180s); this sits
486
+ * just above it so the Host's own answer always wins the race.
487
+ */
488
+ const TRASH_MUTATION_TIMEOUT_MS = 195_000
489
+ /**
490
+ * How long a delete may run before the dialog says what it is waiting for.
491
+ * A hard delete of a conversation written moments ago spends
492
+ * `foreignWriterGraceMs` (1.5s) re-checking the log for a second writer;
493
+ * without a hint the danger button looked stuck.
494
+ */
495
+ const SLOW_SUBMIT_HINT_MS = 1_200
475
496
 
476
497
  /**
477
498
  * A batch is one HTTP request that can stop several live sessions (each up
@@ -493,14 +514,39 @@ window.__ModuleLoader__.load({
493
514
  ApiError.prototype = Object.create(Error.prototype)
494
515
  ApiError.prototype.constructor = ApiError
495
516
 
517
+ /**
518
+ * Resolve a plugin route against the PAGE, not against the origin.
519
+ *
520
+ * `dsh web` can be mounted under a path prefix (`--public-url
521
+ * https://host/ui/`, stripped by a front proxy). DSH's own client stays
522
+ * document-relative for exactly this reason: `dsh-client-connection` builds
523
+ * its requests with `` `${channel}/${endpoint}`.slice(1) `` and resolves
524
+ * them against `document.baseURI`. An absolute `/conversation-recycle-bin/…`
525
+ * would replace the whole base path, drop the prefix, and 404 every call.
526
+ * Keeping the same rule means one code path works for `http://127.0.0.1:<p>/`,
527
+ * `https://host/<prefix>/` and the desktop shell's `dsh-app://app/`.
528
+ */
529
+ function apiUrl(path) {
530
+ let base = 'http://127.0.0.1/'
531
+ if (typeof document !== 'undefined' && typeof document.baseURI === 'string' && document.baseURI !== '') {
532
+ try {
533
+ // Normalise to the containing directory so a base without a trailing
534
+ // slash (`…/index.html`) does not swallow the last segment.
535
+ base = new URL('.', document.baseURI).href
536
+ } catch {
537
+ base = 'http://127.0.0.1/'
538
+ }
539
+ }
540
+ return new URL(`${API_PREFIX.replace(/^\//, '')}${path}`, base)
541
+ }
542
+
496
543
  async function api(path, options = {}) {
497
544
  const method = options.method ?? 'GET'
498
545
  const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : REQUEST_TIMEOUT_MS
499
546
  const controller = new AbortController()
500
547
  const timer = setTimeout(() => controller.abort(), timeoutMs)
501
548
  try {
502
- const base = typeof document !== 'undefined' && typeof document.baseURI === 'string' ? document.baseURI : 'http://127.0.0.1/'
503
- const url = new URL(`${API_PREFIX}${path}`, base)
549
+ const url = apiUrl(path)
504
550
  const headers = { 'x-conversation-recycle-bin': '1' }
505
551
  if (options.body !== undefined) headers['content-type'] = 'application/json'
506
552
  const response = await fetch(url, {
@@ -938,6 +984,9 @@ window.__ModuleLoader__.load({
938
984
  const [error, setError] = useState(null)
939
985
  const [phrase, setPhrase] = useState('')
940
986
  const [done, setDone] = useState(null)
987
+ // Set when a submission outlives SLOW_SUBMIT_HINT_MS, so the dialog can
988
+ // explain the wait instead of showing a frozen danger button.
989
+ const [slow, setSlow] = useState(false)
941
990
 
942
991
  // Dismissing the result must also complete a reload the Host armed
943
992
  // because it cannot close the deleted conversation in place; otherwise
@@ -986,6 +1035,15 @@ window.__ModuleLoader__.load({
986
1035
  return () => window.removeEventListener('keydown', onKey)
987
1036
  }, [busy])
988
1037
 
1038
+ useEffect(() => {
1039
+ if (busy !== true) {
1040
+ setSlow(false)
1041
+ return undefined
1042
+ }
1043
+ const timer = setTimeout(() => setSlow(true), SLOW_SUBMIT_HINT_MS)
1044
+ return () => clearTimeout(timer)
1045
+ }, [busy])
1046
+
989
1047
  const firstSession = preview?.sessions?.[0] ?? null
990
1048
  const title = count === 1
991
1049
  ? (firstSession?.title || fallbackTitle || sessionIds[0])
@@ -1120,6 +1178,11 @@ window.__ModuleLoader__.load({
1120
1178
  ? h('div', { className: 'dc-live-warning', role: 'status' }, t('dialog.liveStopHint'))
1121
1179
  : null,
1122
1180
 
1181
+ // Permanent deletion waits out `foreignWriterGraceMs` when the log was
1182
+ // written moments ago (measured: ~1.5s once per batch). Say so before
1183
+ // the click, not after the user thinks the button is stuck.
1184
+ mode === 'hard' ? h('p', { className: 'dc-hint' }, t('dialog.hardGraceHint')) : null,
1185
+
1123
1186
  h('div', { className: 'dc-memory' },
1124
1187
  h(Checkbox, {
1125
1188
  checked: includeMemory,
@@ -1162,6 +1225,10 @@ window.__ModuleLoader__.load({
1162
1225
  }))
1163
1226
  : null,
1164
1227
 
1228
+ slow === true
1229
+ ? h('div', { className: 'dc-live-warning', role: 'status' }, t('dialog.stillWorking'))
1230
+ : null,
1231
+
1165
1232
  h('div', { className: 'dc-actions' },
1166
1233
  h(Button, { onClick: () => { void load(mode, includeMemory) }, disabled: busy || loading, title: t('action.refresh') },
1167
1234
  h(GlyphRefresh, { size: 13 }), ' ', t('action.refresh')),
@@ -1518,7 +1585,10 @@ window.__ModuleLoader__.load({
1518
1585
  const [trash, setTrash] = useState([])
1519
1586
  const [incomplete, setIncomplete] = useState([])
1520
1587
  const [audit, setAudit] = useState([])
1521
- const [loading, setLoading] = useState(true)
1588
+ // Which tab is fetching right now. `null` means idle: the panels stay
1589
+ // mounted while a refresh runs, so the user keeps scroll position and
1590
+ // never sees the whole section blink on every restore/purge.
1591
+ const [tabBusy, setTabBusy] = useState(null)
1522
1592
  const [error, setError] = useState(null)
1523
1593
  const [selected, setSelected] = useState(() => new Set())
1524
1594
  const [purgeTarget, setPurgeTarget] = useState(null)
@@ -1532,54 +1602,100 @@ window.__ModuleLoader__.load({
1532
1602
  // Two refreshes can overlap (mount, mutation broadcast, and the refresh a
1533
1603
  // purge triggers), and the older response must not overwrite the newer.
1534
1604
  const refreshSerial = useRef(0)
1605
+ // The mutation version each tab's data was fetched at. `-1` = never.
1606
+ const loadedRef = useRef({ trash: -1, batch: -1, audit: -1 })
1535
1607
 
1536
- const refresh = useCallback(async () => {
1608
+ const refreshState = useCallback(async () => {
1609
+ const response = await api('/state')
1610
+ setState(response.state)
1611
+ }, [])
1612
+
1613
+ /**
1614
+ * Fetch only what the visible tab renders. The section used to pull
1615
+ * `/state`, `/sessions`, `/trash` and `/audit` on every mount and every
1616
+ * mutation (~31 KB, 4 round trips) even when the user was looking at one
1617
+ * tab. `/sessions` is the expensive one (one projection read per row),
1618
+ * so opening the recycle bin must not pay for it.
1619
+ */
1620
+ const loadTab = useCallback(async (which, { force = false } = {}) => {
1621
+ if (!force && loadedRef.current[which] === mutationVersion) return
1537
1622
  const serial = refreshSerial.current + 1
1538
1623
  refreshSerial.current = serial
1539
1624
  const stale = () => serial !== refreshSerial.current
1540
- setLoading(true)
1625
+ setTabBusy(which)
1541
1626
  setError(null)
1542
1627
  try {
1543
- const [stateResponse, sessionsResponse, trashResponse, auditResponse] = await Promise.all([
1544
- api('/state'),
1545
- api('/sessions'),
1546
- api('/trash'),
1547
- api('/audit?limit=50'),
1548
- ])
1549
- if (stale()) return
1550
- const nextSessions = Array.isArray(sessionsResponse.sessions) ? sessionsResponse.sessions : []
1551
- setState(stateResponse.state)
1552
- setSessions(nextSessions)
1553
- setTrash(Array.isArray(trashResponse.entries) ? trashResponse.entries : [])
1554
- setIncomplete(Array.isArray(trashResponse.incomplete) ? trashResponse.incomplete : [])
1555
- setAudit(Array.isArray(auditResponse.records) ? auditResponse.records : [])
1556
- // Drop selections whose conversation no longer exists.
1557
- setSelected((current) => {
1558
- const alive = new Set(nextSessions.map((session) => session.id))
1559
- const next = new Set([...current].filter((id) => alive.has(id)))
1560
- return next.size === current.size ? current : next
1561
- })
1628
+ if (which === 'trash') {
1629
+ const response = await api('/trash')
1630
+ if (stale()) return
1631
+ setTrash(Array.isArray(response.entries) ? response.entries : [])
1632
+ setIncomplete(Array.isArray(response.incomplete) ? response.incomplete : [])
1633
+ } else if (which === 'batch') {
1634
+ const response = await api('/sessions')
1635
+ if (stale()) return
1636
+ const nextSessions = Array.isArray(response.sessions) ? response.sessions : []
1637
+ setSessions(nextSessions)
1638
+ // Drop selections whose conversation no longer exists.
1639
+ setSelected((current) => {
1640
+ const alive = new Set(nextSessions.map((session) => session.id))
1641
+ const next = new Set([...current].filter((id) => alive.has(id)))
1642
+ return next.size === current.size ? current : next
1643
+ })
1644
+ } else {
1645
+ const response = await api('/audit?limit=50')
1646
+ if (stale()) return
1647
+ setAudit(Array.isArray(response.records) ? response.records : [])
1648
+ }
1649
+ loadedRef.current[which] = mutationVersion
1562
1650
  } catch (caught) {
1563
1651
  if (!stale()) setError(caught)
1564
1652
  } finally {
1565
- if (!stale()) setLoading(false)
1653
+ if (!stale()) setTabBusy(null)
1566
1654
  }
1567
- }, [])
1655
+ }, [mutationVersion])
1568
1656
 
1569
- useEffect(() => { void refresh() }, [refresh])
1657
+ // `/state` drives the capability block and the limits; refetch it on
1658
+ // mount and whenever a mutation happened, not on every tab switch.
1659
+ useEffect(() => {
1660
+ refreshState().catch((caught) => setError(caught))
1661
+ }, [mutationVersion, refreshState])
1570
1662
 
1571
- // A deletion or restore performed from the dialog must also update this
1572
- // section's own session/trash copies.
1663
+ // The active tab's own slice, on mount, on tab switch, and after a
1664
+ // mutation (which changes `loadTab`'s identity through the version).
1573
1665
  useEffect(() => {
1574
- if (mutationVersion > 0) void refresh()
1575
- }, [mutationVersion, refresh])
1666
+ void loadTab(tab)
1667
+ }, [tab, loadTab])
1668
+
1669
+ /** Header "refresh" button: re-read state and the visible tab. */
1670
+ const refresh = useCallback(async () => {
1671
+ await Promise.all([
1672
+ refreshState().catch((caught) => setError(caught)),
1673
+ loadTab(tab, { force: true }),
1674
+ ])
1675
+ }, [tab, refreshState, loadTab])
1676
+
1677
+ /**
1678
+ * Mark a slice stale without fetching it. Recycle-bin actions call
1679
+ * `refreshAfterMutation({ notify: false })`, so no version bump happens:
1680
+ * the visible tab is re-read by `refresh()`, and any other tab must be
1681
+ * re-read the next time the user opens it instead of showing rows that
1682
+ * were deleted a moment ago.
1683
+ */
1684
+ const markStale = useCallback((...which) => {
1685
+ for (const name of which) loadedRef.current[name] = -1
1686
+ }, [])
1576
1687
 
1577
1688
  async function restore(trashId) {
1578
1689
  setError(null)
1579
1690
  const entry = trash.find((item) => item.trashId === trashId)
1580
1691
  try {
1581
- await api('/trash/restore', { method: 'POST', body: { trashId } })
1692
+ await api('/trash/restore', {
1693
+ method: 'POST',
1694
+ body: { trashId },
1695
+ timeoutMs: TRASH_MUTATION_TIMEOUT_MS,
1696
+ })
1582
1697
  flashStore.show(t('trash.restored'))
1698
+ markStale('trash', 'batch')
1583
1699
  await refresh()
1584
1700
  await refreshAfterMutation({
1585
1701
  sessionIds: typeof entry?.sessionId === 'string' ? [entry.sessionId] : [],
@@ -1601,8 +1717,13 @@ window.__ModuleLoader__.load({
1601
1717
  setError(null)
1602
1718
  setRestoreBusyId(trashId)
1603
1719
  try {
1604
- await api('/trash/restore', { method: 'POST', body: { trashId } })
1720
+ await api('/trash/restore', {
1721
+ method: 'POST',
1722
+ body: { trashId },
1723
+ timeoutMs: TRASH_MUTATION_TIMEOUT_MS,
1724
+ })
1605
1725
  flashStore.show(t('trash.restored'))
1726
+ markStale('trash', 'batch')
1606
1727
  await refresh()
1607
1728
  await refreshAfterMutation({
1608
1729
  sessionIds: incomplete.find((item) => item.trashId === trashId)?.sessionId
@@ -1624,10 +1745,15 @@ window.__ModuleLoader__.load({
1624
1745
  setError(null)
1625
1746
  const entry = trash.find((item) => item.trashId === trashId)
1626
1747
  try {
1627
- await api('/trash/purge', { method: 'POST', body: { trashId, confirmPhrase: phrase } })
1748
+ await api('/trash/purge', {
1749
+ method: 'POST',
1750
+ body: { trashId, confirmPhrase: phrase },
1751
+ timeoutMs: TRASH_MUTATION_TIMEOUT_MS,
1752
+ })
1628
1753
  flashStore.show(t('trash.purged'))
1629
1754
  setPurgeTarget(null)
1630
1755
  setPurgePhrase('')
1756
+ markStale('trash', 'batch')
1631
1757
  await refresh()
1632
1758
  // Purging a soft entry detaches the session from its workspace.
1633
1759
  await refreshAfterMutation({
@@ -1647,10 +1773,15 @@ window.__ModuleLoader__.load({
1647
1773
  setDiscardBusyId(trashId)
1648
1774
  setError(null)
1649
1775
  try {
1650
- await api('/trash/discard', { method: 'POST', body: { trashId, confirmPhrase: phrase } })
1776
+ await api('/trash/discard', {
1777
+ method: 'POST',
1778
+ body: { trashId, confirmPhrase: phrase },
1779
+ timeoutMs: TRASH_MUTATION_TIMEOUT_MS,
1780
+ })
1651
1781
  flashStore.show(t('trash.discarded'))
1652
1782
  setDiscardTarget(null)
1653
1783
  setDiscardPhrase('')
1784
+ markStale('trash')
1654
1785
  await refresh()
1655
1786
  } catch (caught) {
1656
1787
  setError(caught)
@@ -1700,7 +1831,7 @@ window.__ModuleLoader__.load({
1700
1831
  h('h1', { className: 'dc-title' }, t('section.title')),
1701
1832
  h('p', { className: 'dc-hint' }, t('section.subtitle'))),
1702
1833
  h('div', { className: 'dc-header-actions' },
1703
- h(Button, { onClick: () => { void refresh() }, disabled: loading, title: t('action.refresh') },
1834
+ h(Button, { onClick: () => { void refresh() }, disabled: tabBusy !== null, title: t('action.refresh') },
1704
1835
  h(GlyphRefresh, { size: 13 }), ' ', t('action.refresh')))),
1705
1836
 
1706
1837
  h(ErrorBanner, { error }),
@@ -1729,9 +1860,14 @@ window.__ModuleLoader__.load({
1729
1860
  h('button', { id: 'dc-tab-batch', type: 'button', role: 'tab', 'aria-selected': tab === 'batch', 'aria-controls': 'dc-panel-batch', className: `dc-tab${tab === 'batch' ? ' dc-tab-active' : ''}`, onClick: () => setTab('batch') }, t('batch.title')),
1730
1861
  h('button', { id: 'dc-tab-audit', type: 'button', role: 'tab', 'aria-selected': tab === 'audit', 'aria-controls': 'dc-panel-audit', className: `dc-tab${tab === 'audit' ? ' dc-tab-active' : ''}`, onClick: () => setTab('audit') }, t('audit.title'))),
1731
1862
 
1732
- loading ? h(Spinner, { label: t('dialog.loading') }) : null,
1863
+ // First paint only: once anything has been rendered, a refresh keeps the
1864
+ // panels mounted and shows the inline indicator below instead.
1865
+ state === null && tabBusy !== null ? h(Spinner, { label: t('dialog.loading') }) : null,
1866
+ state !== null && tabBusy !== null
1867
+ ? h('div', { className: 'dc-loading' }, h('span', { className: 'dc-spinner' }), ' ', t('dialog.loading'))
1868
+ : null,
1733
1869
 
1734
- tab === 'trash' && !loading ? h('div', { id: 'dc-panel-trash', role: 'tabpanel', 'aria-labelledby': 'dc-tab-trash', className: 'dc-panel' },
1870
+ tab === 'trash' ? h('div', { id: 'dc-panel-trash', role: 'tabpanel', 'aria-labelledby': 'dc-tab-trash', className: 'dc-panel' },
1735
1871
  trash.length === 0 ? h('div', { className: 'dc-empty' }, t('trash.empty')) : null,
1736
1872
  ...trash.map((entry) => h('div', { key: entry.trashId, className: 'dc-row' },
1737
1873
  h('div', { className: 'dc-row-main' },
@@ -1754,7 +1890,7 @@ window.__ModuleLoader__.load({
1754
1890
  h(Button, { disabled: purgeBusy === true, onClick: () => { setPurgeTarget(null); setPurgePhrase('') } }, t('action.cancel')))
1755
1891
  : h(Button, { variant: 'danger', onClick: () => { setPurgeTarget(entry.trashId); setPurgePhrase('') } }, t('action.purge')))))) : null,
1756
1892
 
1757
- tab === 'trash' && !loading && incomplete.length > 0 ? h(IncompleteEntries, {
1893
+ tab === 'trash' && incomplete.length > 0 ? h(IncompleteEntries, {
1758
1894
  entries: incomplete,
1759
1895
  discardTarget,
1760
1896
  discardPhrase,
@@ -1767,7 +1903,7 @@ window.__ModuleLoader__.load({
1767
1903
  discardBusyId,
1768
1904
  }) : null,
1769
1905
 
1770
- tab === 'batch' && !loading ? h('div', { id: 'dc-panel-batch', role: 'tabpanel', 'aria-labelledby': 'dc-tab-batch', className: 'dc-panel' },
1906
+ tab === 'batch' ? h('div', { id: 'dc-panel-batch', role: 'tabpanel', 'aria-labelledby': 'dc-tab-batch', className: 'dc-panel' },
1771
1907
  h('div', { className: 'dc-batch-bar' },
1772
1908
  h('span', { className: 'dc-hint' }, t('batch.selected', { count: selectedIds.length })),
1773
1909
  // "Select all" only marks deletable rows: a batch containing one
@@ -1793,7 +1929,7 @@ window.__ModuleLoader__.load({
1793
1929
  onToggle: toggle,
1794
1930
  }))) : null,
1795
1931
 
1796
- tab === 'audit' && !loading ? h('div', { id: 'dc-panel-audit', role: 'tabpanel', 'aria-labelledby': 'dc-tab-audit', className: 'dc-panel' },
1932
+ tab === 'audit' ? h('div', { id: 'dc-panel-audit', role: 'tabpanel', 'aria-labelledby': 'dc-tab-audit', className: 'dc-panel' },
1797
1933
  audit.length === 0 ? h('div', { className: 'dc-empty' }, t('audit.empty')) : null,
1798
1934
  ...audit.map((record) => h('div', { key: record.id, className: 'dc-row' },
1799
1935
  h('div', { className: 'dc-row-main' },
@@ -1974,6 +2110,15 @@ window.__ModuleLoader__.load({
1974
2110
  exports.__store = { dialogStore, flashStore, mutationStore, uiFlagsStore }
1975
2111
  exports.__refreshAfterMutation = refreshAfterMutation
1976
2112
  exports.__mutationTimeoutMs = mutationTimeoutMs
2113
+ // Exposed so the harness can assert the client budget stays ABOVE the
2114
+ // Host's own TRASH_LOCK_BUDGET_MS: whichever side gives up first decides
2115
+ // whether the user sees "failed" for work that actually completed.
2116
+ exports.__requestBudgets = {
2117
+ request: REQUEST_TIMEOUT_MS,
2118
+ mutation: MUTATION_TIMEOUT_MS,
2119
+ trashMutation: TRASH_MUTATION_TIMEOUT_MS,
2120
+ }
2121
+ exports.__apiUrl = apiUrl
1977
2122
  exports.__api = api
1978
2123
  // Direct component handles keep the hook-level behavior testable without a
1979
2124
  // DOM renderer (the registered wrapper alone only builds an element).
@@ -8,7 +8,11 @@
8
8
  "properties": {
9
9
  "enabled": { "type": "boolean", "default": true, "description": "Master switch. When false the route answers 503 and the UI hides its entries." },
10
10
  "requireSameOrigin": { "type": "boolean", "default": true, "description": "Reject requests whose Origin does not match the request's full authority (hostname AND effective port; a missing port defaults by the Origin's own scheme). The desktop shell source dsh-app://app is trusted, matching DSH's own fence." },
11
- "allowNonLoopback": { "type": "boolean", "default": false, "description": "Allow requests when the Web server is bound beyond loopback." },
11
+ "allowNonLoopback": { "type": "boolean", "default": false, "description": "Allow requests from any authority, not just loopback. Prefer allowedHosts when a reverse proxy presents a known hostname." },
12
+ "allowedHosts": {
13
+ "type": "array", "items": { "type": "string" }, "maxItems": 32, "default": [],
14
+ "description": "Authorities a prefix-stripping reverse proxy may present in Host (host, host:port, or [v6]:port). A bare host admits any port; an entry with a port admits only that port. Entries with a scheme, path, userinfo or wildcard are dropped. Empty = loopback only."
15
+ },
12
16
  "allowOrphanSessions": { "type": "boolean", "default": false, "description": "Allow deleting sessions that no registered workspace owns." },
13
17
  "allowSubagentSessions": { "type": "boolean", "default": false, "description": "Allow deleting sessions whose header origin is 'subagent'." },
14
18
  "stopLiveSessions": { "type": "boolean", "default": true, "description": "Hard deletion first stops a live session (cancel turn + archive stopActivity + bounded drain). Set false to refuse live sessions instead." },
package/cordis.patch.yml CHANGED
@@ -22,6 +22,10 @@
22
22
  # unless the operator opts in explicitly below.
23
23
  requireSameOrigin: true
24
24
  allowNonLoopback: false
25
+ # Reverse-proxy deployments: list the exact authorities the proxy puts
26
+ # in `Host` (host, host:port). A bare host admits any port. Listing them
27
+ # is narrower than turning allowNonLoopback on for every hostname.
28
+ allowedHosts: []
25
29
  # Session ownership policy.
26
30
  allowOrphanSessions: false # sessions with no registered workspace
27
31
  allowSubagentSessions: false # sessions created by subagents
package/lib/core/audit.js CHANGED
@@ -11,7 +11,7 @@
11
11
  * actor (the local Web UI), session ids, mode, the memory checkbox, per-adapter
12
12
  * counts, and whether a failed transaction was rolled back.
13
13
  */
14
- import { appendFile, mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises'
14
+ import { appendFile, mkdir, open, readFile, rename, stat, writeFile } from 'node:fs/promises'
15
15
  import { dirname } from 'node:path'
16
16
 
17
17
  /**
@@ -19,10 +19,11 @@ import { dirname } from 'node:path'
19
19
  * @param {string} options.filePath absolute path of the JSONL file
20
20
  * @param {number} [options.maxBytes] compact threshold
21
21
  * @param {number} [options.retentionLines] lines kept after compaction
22
+ * @param {number} [options.tailWindowBytes] byte window tail() reads from the end (default 256KB)
22
23
  * @param {() => number} [options.now]
23
- * @param {Pick<typeof import('node:fs/promises'), 'readFile'|'writeFile'|'mkdir'|'appendFile'|'rename'|'stat'>} [options.fsApi]
24
+ * @param {Pick<typeof import('node:fs/promises'), 'readFile'|'writeFile'|'mkdir'|'appendFile'|'rename'|'stat'|'open'>} [options.fsApi]
24
25
  */
25
- export function createAuditLog({ filePath, maxBytes = 5 * 1024 * 1024, retentionLines = 5_000, now = Date.now, fsApi = { readFile, writeFile, mkdir, appendFile, rename, stat } }) {
26
+ export function createAuditLog({ filePath, maxBytes = 5 * 1024 * 1024, retentionLines = 5_000, tailWindowBytes = 256 * 1024, now = Date.now, fsApi = { readFile, writeFile, mkdir, appendFile, rename, stat, open } }) {
26
27
  let pending = Promise.resolve()
27
28
 
28
29
  function serialize(operation) {
@@ -77,48 +78,59 @@ export function createAuditLog({ filePath, maxBytes = 5 * 1024 * 1024, retention
77
78
 
78
79
  /**
79
80
  * Read the most recent `limit` records; malformed lines are counted, not
80
- * thrown. Only the tail window (≤256KB) is read and parsed: the settings
81
- * page asks for the last N rows on every open, and a full up-to-5MB parse
82
- * on each request scaled with the whole trail, not with `limit`. Malformed
83
- * lines outside the window are not counted (the metric describes the
84
- * displayed window).
81
+ * thrown. Only the tail window (default 256KB) is read and parsed: the
82
+ * settings page asks for the last N rows on every open, and a full
83
+ * up-to-5MB parse on each request scaled with the whole trail, not with
84
+ * `limit`. `fs.readFile` does not honour `{start,end}`, so this uses a
85
+ * positional `FileHandle.read`; earlier revisions silently read the whole
86
+ * file while appearing to window. Malformed lines outside the window are
87
+ * not counted (the metric describes the displayed window).
85
88
  */
86
89
  async tail(limit = 50) {
87
90
  const maxRecords = Math.max(1, Math.floor(limit))
88
- const CHUNK = 256 * 1024
89
- let size = 0
91
+ const CHUNK = Math.max(1, Math.floor(tailWindowBytes))
92
+ let handle
90
93
  try {
91
- size = (await fsApi.stat(filePath)).size
94
+ handle = await fsApi.open(filePath, 'r')
92
95
  } catch (error) {
93
96
  if (error?.code === 'ENOENT') return { records: [], malformed: 0 }
94
97
  throw error
95
98
  }
96
- const start = Math.max(0, size - CHUNK)
97
- let buffer
98
99
  try {
99
- buffer = start > 0
100
- ? await fsApi.readFile(filePath, { start, end: size })
101
- : await fsApi.readFile(filePath, 'utf8')
102
- } catch (error) {
103
- if (error?.code === 'ENOENT') return { records: [], malformed: 0 }
104
- throw error
105
- }
106
- const text = Buffer.isBuffer(buffer) ? buffer.toString('utf8') : String(buffer)
107
- const rawLines = text.split('\n')
108
- // A chunk starts mid-line; drop the partial first line instead of
109
- // parsing a torn fragment as malformed.
110
- const lines = (start > 0 ? rawLines.slice(1) : rawLines)
111
- .filter((line) => line.trim() !== '')
112
- const records = []
113
- let malformed = 0
114
- for (const line of lines) {
115
- try {
116
- records.push(JSON.parse(line))
117
- } catch {
118
- malformed += 1
100
+ const statInfo = await handle.stat()
101
+ const size = statInfo.size
102
+ const start = Math.max(0, size - CHUNK)
103
+ const windowLength = size - start
104
+ const buffer = Buffer.alloc(windowLength)
105
+ const windowRead = await handle.read(buffer, 0, windowLength, start)
106
+ const text = buffer.subarray(0, windowRead.bytesRead).toString('utf8')
107
+ // `rawLines[0]` is only a torn fragment when the window starts
108
+ // *inside* a line. Read the byte at `start-1`: `\n` means the window
109
+ // begins on a line boundary and rawLines[0] is a complete record;
110
+ // unconditionally dropping it (pre-0.3.4) silently lost one valid
111
+ // record whenever the byte window happened to align with a newline.
112
+ let dropFirst = false
113
+ if (start > 0) {
114
+ const peek = Buffer.alloc(1)
115
+ const peekRead = await handle.read(peek, 0, 1, start - 1)
116
+ dropFirst = !(peekRead.bytesRead === 1 && peek[0] === 0x0a)
117
+ }
118
+ const rawLines = text.split('\n')
119
+ const lines = (dropFirst ? rawLines.slice(1) : rawLines)
120
+ .filter((line) => line.trim() !== '')
121
+ const records = []
122
+ let malformed = 0
123
+ for (const line of lines) {
124
+ try {
125
+ records.push(JSON.parse(line))
126
+ } catch {
127
+ malformed += 1
128
+ }
119
129
  }
130
+ return { records: records.slice(-maxRecords), malformed }
131
+ } finally {
132
+ await handle.close()
120
133
  }
121
- return { records: records.slice(-maxRecords), malformed }
122
134
  },
123
135
  }
124
136
  }
@@ -13,6 +13,11 @@ export const DEFAULTS = deepFreeze({
13
13
  enabled: true,
14
14
  requireSameOrigin: true,
15
15
  allowNonLoopback: false,
16
+ // Exact authorities a prefix-stripping reverse proxy may present in `Host`
17
+ // (`app.example`, `app.example:8443`, `[::1]:8443`). Empty = loopback only.
18
+ // This exists so an operator behind a proxy does not have to switch
19
+ // `allowNonLoopback` on for every hostname the machine can be reached by.
20
+ allowedHosts: [],
16
21
  allowOrphanSessions: false,
17
22
  allowSubagentSessions: false,
18
23
  // Hard deletion of a live session first stops it (cancel turn + archive
@@ -138,6 +143,45 @@ function readStringArray(raw, fallback, field, warnings) {
138
143
  return raw.map((item) => item.trim()).filter((item) => item !== '')
139
144
  }
140
145
 
146
+ /** `host`, `host:port` or `[v6]:port` — the only shapes a `Host` header may take. */
147
+ const HOST_AUTHORITY_PATTERN = /^(?:\[[0-9a-f:.]+\]|[a-z0-9._-]+)(?::\d{1,5})?$/i
148
+ const MAX_ALLOWED_HOSTS = 32
149
+
150
+ /**
151
+ * Normalize the reverse-proxy authority allowlist.
152
+ *
153
+ * Entries are compared case-insensitively as complete authorities. A bare host
154
+ * (`app.example`) matches any port, an entry with a port (`app.example:8443`)
155
+ * matches only that port. Anything that is not a bare authority — a scheme, a
156
+ * path, userinfo, a wildcard — is dropped with a warning: this list is a
157
+ * security boundary, so a typo must not silently widen it.
158
+ */
159
+ function readHostList(raw, field, warnings) {
160
+ if (raw === undefined) return []
161
+ if (!Array.isArray(raw)) {
162
+ warnings.push(`${field}: expected an array of authorities, using the default`)
163
+ return []
164
+ }
165
+ const out = []
166
+ for (const item of raw) {
167
+ if (typeof item !== 'string') {
168
+ warnings.push(`${field}: dropped a non-string entry`)
169
+ continue
170
+ }
171
+ const value = item.trim().toLowerCase().replace(/\.$/, '')
172
+ if (value === '') continue
173
+ if (!HOST_AUTHORITY_PATTERN.test(value)) {
174
+ warnings.push(`${field}: dropped "${item}", expected host or host:port without a scheme or path`)
175
+ continue
176
+ }
177
+ if (!out.includes(value)) out.push(value)
178
+ }
179
+ if (out.length > MAX_ALLOWED_HOSTS) {
180
+ warnings.push(`${field}: only the first ${MAX_ALLOWED_HOSTS} entries are used`)
181
+ }
182
+ return out.slice(0, MAX_ALLOWED_HOSTS)
183
+ }
184
+
141
185
  /**
142
186
  * Normalize one raw row config into the exact object the plugin reads.
143
187
  *
@@ -197,6 +241,7 @@ export function normalizeConfig(raw) {
197
241
  enabled: readBool(source.enabled, DEFAULTS.enabled, 'enabled', warnings),
198
242
  requireSameOrigin: readBool(source.requireSameOrigin, DEFAULTS.requireSameOrigin, 'requireSameOrigin', warnings),
199
243
  allowNonLoopback: readBool(source.allowNonLoopback, DEFAULTS.allowNonLoopback, 'allowNonLoopback', warnings),
244
+ allowedHosts: readHostList(source.allowedHosts, 'allowedHosts', warnings),
200
245
  allowOrphanSessions: readBool(source.allowOrphanSessions, DEFAULTS.allowOrphanSessions, 'allowOrphanSessions', warnings),
201
246
  allowSubagentSessions: readBool(source.allowSubagentSessions, DEFAULTS.allowSubagentSessions, 'allowSubagentSessions', warnings),
202
247
  stopLiveSessions: readBool(source.stopLiveSessions, DEFAULTS.stopLiveSessions, 'stopLiveSessions', warnings),
@@ -137,7 +137,27 @@ export function createJsonIndexMemoryAdapter(options) {
137
137
  })
138
138
  }
139
139
 
140
+ /**
141
+ * Last parsed snapshot, keyed by the file's identity (mtime + size).
142
+ *
143
+ * A single `/preview` probes the adapter and then counts nodes, and both used
144
+ * to `readFile` + `JSON.parse` the whole index: 2 full reads of a 1.9 MB
145
+ * index cost ~39 ms. The stat key catches a concurrent md_cg write without a
146
+ * TTL, and every write this adapter performs clears the entry.
147
+ */
148
+ let snapshotCache = null
149
+
140
150
  async function readSnapshot() {
151
+ let key = null
152
+ if (typeof fsApi.stat === 'function') {
153
+ try {
154
+ const info = await fsApi.stat(indexFile)
155
+ key = `${info.mtimeMs}:${info.size}`
156
+ } catch {
157
+ // Fall through: readFile below reports the real error (ENOENT etc.).
158
+ }
159
+ }
160
+ if (key !== null && snapshotCache !== null && snapshotCache.key === key) return snapshotCache.snapshot
141
161
  const raw = await fsApi.readFile(indexFile, 'utf8')
142
162
  let index
143
163
  try {
@@ -158,7 +178,9 @@ export function createJsonIndexMemoryAdapter(options) {
158
178
  if (nodes === null || typeof nodes !== 'object') {
159
179
  throw new DeleteError('E_MEMORY_FAILED', `Memory index has no node map at ${nodesPointer}`, { indexFile, nodesPointer })
160
180
  }
161
- return { index, nodes, sig: createHash('sha256').update(raw).digest('hex') }
181
+ const snapshot = { index, nodes, sig: createHash('sha256').update(raw).digest('hex') }
182
+ if (key !== null) snapshotCache = { key, snapshot }
183
+ return snapshot
162
184
  }
163
185
 
164
186
  async function readIndex() {
@@ -170,6 +192,9 @@ export function createJsonIndexMemoryAdapter(options) {
170
192
  const temporary = `${indexFile}.delete-conversation.tmp`
171
193
  await fsApi.writeFile(temporary, `${JSON.stringify(index)}\n`, 'utf8')
172
194
  await fsApi.rename(temporary, indexFile)
195
+ // The next reader must not see the pre-write snapshot, even if the rename
196
+ // happened within the filesystem's mtime granularity.
197
+ snapshotCache = null
173
198
  }
174
199
 
175
200
  async function newestLogMtimeMs() {
package/lib/core/trash.js CHANGED
@@ -19,6 +19,29 @@ import { join } from 'node:path'
19
19
 
20
20
  const MANIFEST = 'manifest.json'
21
21
  const TRASH_ID_PATTERN = /^[0-9a-z]{2,40}-[0-9a-z]{2,20}$/
22
+ /**
23
+ * Manifest reads in flight during one directory scan. The work is dominated by
24
+ * syscalls on many small files, so a small fixed pool removes the serial
25
+ * round-trip chain without flooding the FS with hundreds of concurrent opens.
26
+ */
27
+ const SCAN_CONCURRENCY = 8
28
+
29
+ /** Ordered `map` with at most `limit` promises in flight. */
30
+ async function mapLimit(items, limit, fn) {
31
+ const out = new Array(items.length)
32
+ let next = 0
33
+ const workers = Array.from({ length: Math.min(Math.max(1, limit), items.length) }, async () => {
34
+ while (true) {
35
+ const index = next
36
+ next += 1
37
+ if (index >= items.length) return
38
+ out[index] = await fn(items[index], index)
39
+ }
40
+ })
41
+ await Promise.all(workers)
42
+ return out
43
+ }
44
+
22
45
 
23
46
  /** Statuses a manifest may carry. `pending` and `failed` are never listed. */
24
47
  export const TRASH_STATUS = Object.freeze(['pending', 'trashed', 'failed', 'restoring', 'restored', 'purging'])
@@ -102,17 +125,64 @@ export function createTrashStore({ rootDir, now = Date.now, newId, fsApi = { mkd
102
125
  return parsed
103
126
  },
104
127
 
105
- /** Every readable manifest, newest first, regardless of status. */
106
- async listAll() {
128
+ /**
129
+ * One directory pass over the recycle bin.
130
+ *
131
+ * `listAll()` and `scanUnreadable()` used to be called together by `/trash`
132
+ * and each did its own `readdir` plus one `readFile` per entry, so every
133
+ * listing cost 2N manifest reads (measured at ~0.31 ms per entry, 156 ms at
134
+ * 500 entries). They now share this single pass: one `readdir`, one read per
135
+ * entry, bounded concurrency instead of a serial `for await`.
136
+ *
137
+ * A missing or unreadable root yields empty lists rather than throwing: the
138
+ * settings page must still render on a fresh install with no trash yet.
139
+ * @returns {Promise<{ entries: object[], unreadable: Array<{ trashId: string, reason: 'missing-manifest'|'corrupt-manifest'|'unreadable-manifest' }> }>}
140
+ */
141
+ async scanAll() {
107
142
  await ensureRoot()
108
- const dirents = await fsApi.readdir(rootDir, { withFileTypes: true })
109
- const entries = []
143
+ let dirents = []
144
+ try {
145
+ dirents = await fsApi.readdir(rootDir, { withFileTypes: true })
146
+ } catch {
147
+ return { entries: [], unreadable: [] }
148
+ }
149
+ const names = []
110
150
  for (const dirent of dirents) {
111
- if (!dirent.isDirectory() || !isTrashId(dirent.name)) continue
112
- const entry = await this.read(dirent.name)
113
- if (entry !== null) entries.push(entry)
151
+ if (dirent.isDirectory() && isTrashId(dirent.name)) names.push(dirent.name)
152
+ }
153
+ const scanned = await mapLimit(names, SCAN_CONCURRENCY, async (trashId) => {
154
+ let raw
155
+ try {
156
+ raw = await fsApi.readFile(join(rootDir, trashId, MANIFEST), 'utf8')
157
+ } catch (error) {
158
+ return { trashId, reason: error?.code === 'ENOENT' ? 'missing-manifest' : 'unreadable-manifest' }
159
+ }
160
+ let parsed
161
+ try {
162
+ parsed = JSON.parse(raw)
163
+ } catch {
164
+ return { trashId, reason: 'corrupt-manifest' }
165
+ }
166
+ if (parsed === null || typeof parsed !== 'object' || parsed.format !== 1 || !TRASH_STATUS.includes(parsed.status)) {
167
+ return { trashId, reason: 'corrupt-manifest' }
168
+ }
169
+ return { entry: parsed }
170
+ })
171
+ const entries = []
172
+ const unreadable = []
173
+ for (const item of scanned) {
174
+ if (item.entry !== undefined) entries.push(item.entry)
175
+ else unreadable.push({ trashId: item.trashId, reason: item.reason })
114
176
  }
177
+ // Newest first. `Array.prototype.sort` is stable, so equal timestamps keep
178
+ // the directory order the scan produced.
115
179
  entries.sort((left, right) => (right.deletedAt ?? 0) - (left.deletedAt ?? 0))
180
+ return { entries, unreadable }
181
+ },
182
+
183
+ /** Every readable manifest, newest first, regardless of status. */
184
+ async listAll() {
185
+ const { entries } = await this.scanAll()
116
186
  return entries
117
187
  },
118
188
 
@@ -128,37 +198,11 @@ export function createTrashStore({ rootDir, now = Date.now, newId, fsApi = { mkd
128
198
  * are invisible to restore/purge but must still be listable and
129
199
  * discardable, otherwise a single bad manifest strands the directory on
130
200
  * disk forever.
131
- * @returns {Array<{ trashId: string, reason: 'missing-manifest'|'corrupt-manifest'|'unreadable-manifest' }>}
201
+ * @returns {Promise<Array<{ trashId: string, reason: 'missing-manifest'|'corrupt-manifest'|'unreadable-manifest' }>>}
132
202
  */
133
203
  async scanUnreadable() {
134
- await ensureRoot()
135
- const out = []
136
- let dirents = []
137
- try {
138
- dirents = await fsApi.readdir(rootDir, { withFileTypes: true })
139
- } catch {
140
- return out
141
- }
142
- for (const dirent of dirents) {
143
- if (!dirent.isDirectory() || !isTrashId(dirent.name)) continue
144
- let raw
145
- let reason = 'missing-manifest'
146
- try {
147
- raw = await fsApi.readFile(join(rootDir, dirent.name, MANIFEST), 'utf8')
148
- } catch (error) {
149
- if (error?.code !== 'ENOENT') reason = 'unreadable-manifest'
150
- out.push({ trashId: dirent.name, reason })
151
- continue
152
- }
153
- try {
154
- const parsed = JSON.parse(raw)
155
- const readable = parsed !== null && typeof parsed === 'object' && parsed.format === 1 && TRASH_STATUS.includes(parsed.status)
156
- if (!readable) out.push({ trashId: dirent.name, reason: 'corrupt-manifest' })
157
- } catch {
158
- out.push({ trashId: dirent.name, reason: 'corrupt-manifest' })
159
- }
160
- }
161
- return out
204
+ const { unreadable } = await this.scanAll()
205
+ return unreadable
162
206
  },
163
207
 
164
208
  async remove(trashId) {
package/lib/host/api.js CHANGED
@@ -60,6 +60,18 @@ import { SESSION_ID_PATTERN } from '../core/ids.js'
60
60
  export const ROUTE_PREFIX = '/conversation-recycle-bin/api'
61
61
  export const REQUIRED_HEADER = 'x-conversation-recycle-bin'
62
62
  const MAX_BODY_BYTES = 256 * 1024
63
+ /**
64
+ * Lock wait budget for the single-entry trash mutations.
65
+ *
66
+ * `serialize()` chains every mutation in-process and then takes the
67
+ * cross-process file lock, so restore / purge / discard can sit behind a whole
68
+ * batch delete. They used to fall back to the generic 30s lock timeout, which
69
+ * is shorter than one batch's legitimate worst case: the Host answered
70
+ * E_LOCK_BUSY for a request that only needed to wait. The Client's own budget
71
+ * (TRASH_MUTATION_TIMEOUT_MS = 195s) is deliberately slightly larger, so this
72
+ * budget — not the browser's AbortController — decides the outcome.
73
+ */
74
+ export const TRASH_LOCK_BUDGET_MS = 180_000
63
75
 
64
76
  function isLoopbackHostname(hostname) {
65
77
  const value = String(hostname ?? '').trim().toLowerCase().replace(/^\[|\]$/g, '')
@@ -99,6 +111,28 @@ function portOf(hostHeader) {
99
111
  return colon > 0 && /^\d+$/.test(value.slice(colon + 1)) ? value.slice(colon + 1) : ''
100
112
  }
101
113
 
114
+ /**
115
+ * Whether a request authority matches the operator's `allowedHosts` list.
116
+ *
117
+ * A bare entry (`app.example`) admits any port on that host; an entry with a
118
+ * port admits only that port. Comparison is hostname-normalized (IPv6 brackets
119
+ * and `::ffff:` mapped addresses included) so `[::1]:8443` and `[::1]:8443`
120
+ * agree regardless of case.
121
+ */
122
+ function authorityAllowed(hostHeader, allowedHosts) {
123
+ if (!Array.isArray(allowedHosts) || allowedHosts.length === 0) return false
124
+ const hostname = hostnameOf(hostHeader)
125
+ if (hostname === '') return false
126
+ const port = portOf(hostHeader)
127
+ for (const entry of allowedHosts) {
128
+ if (typeof entry !== 'string') continue
129
+ if (!sameHostname(hostnameOf(entry), hostname)) continue
130
+ const entryPort = portOf(entry)
131
+ if (entryPort === '' || entryPort === port) return true
132
+ }
133
+ return false
134
+ }
135
+
102
136
  /** The port a URL implies when it omits one. */
103
137
  function defaultPortFor(protocol) {
104
138
  if (protocol === 'https:' || protocol === 'wss:') return '443'
@@ -116,7 +150,7 @@ function defaultPortFor(protocol) {
116
150
  * both mean 443).
117
151
  * @returns {{ ok: true } | { ok: false, error: DeleteError }}
118
152
  */
119
- export function requestOriginVerdict(headers, { requireSameOrigin, allowNonLoopback }) {
153
+ export function requestOriginVerdict(headers, { requireSameOrigin, allowNonLoopback, allowedHosts = [] }) {
120
154
  const read = (name) => {
121
155
  if (headers === null || typeof headers !== 'object') return ''
122
156
  const value = headers[name]
@@ -124,11 +158,12 @@ export function requestOriginVerdict(headers, { requireSameOrigin, allowNonLoopb
124
158
  }
125
159
  const requestHostname = hostnameOf(read('host'))
126
160
 
127
- if (!allowNonLoopback && !isLoopbackHostname(requestHostname)) {
161
+ if (!allowNonLoopback && !isLoopbackHostname(requestHostname) && !authorityAllowed(read('host'), allowedHosts)) {
128
162
  return {
129
163
  ok: false,
130
- error: new DeleteError('E_FORBIDDEN_HOST', 'This route only accepts loopback requests unless allowNonLoopback is enabled.', {
164
+ error: new DeleteError('E_FORBIDDEN_HOST', 'This route only accepts loopback requests unless the authority is listed in allowedHosts (or allowNonLoopback is enabled).', {
131
165
  host: read('host') || null,
166
+ allowedHosts: Array.isArray(allowedHosts) ? allowedHosts : [],
132
167
  }),
133
168
  }
134
169
  }
@@ -634,7 +669,13 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
634
669
  // Budget is consumed per actually-attempted deletion: a failed item must
635
670
  // not silently spend the minute quota it never used. The very first item
636
671
  // still refuses outright with 429 (the existing contract); once a plan
637
- // has started, later exhausted items are recorded as failures instead.
672
+ // has started, later exhausted items are recorded as failures.
673
+ //
674
+ // A refusal inside the loop uses `continue`, never `break`: `break` used
675
+ // to drop every session after the exhausted one from both `results` and
676
+ // `failures`, so a batch of 50 with a 20/min budget left items 22-50
677
+ // looking like the plan never mentioned them. Under the per-plan
678
+ // serialize this is still visible via `maxBatchSessions` > budget.
638
679
  const request = requestId()
639
680
  const results = []
640
681
  const failures = []
@@ -647,7 +688,7 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
647
688
  })
648
689
  if (results.length === 0 && failures.length === 0) throw rateError
649
690
  failures.push({ sessionId: session.id, error: rateError.toJSON() })
650
- break
691
+ continue
651
692
  }
652
693
  if (!validated.record.used) markConfirmationUsed(validated)
653
694
  try {
@@ -839,7 +880,7 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
839
880
  // invisible and impossible to clean up at all. Directories whose manifest
840
881
  // is missing/corrupt are surfaced as `unreadable` so they are not stranded
841
882
  // on disk; the Client renders both groups separately.
842
- const [all, unreadable] = await Promise.all([trash.listAll(), trash.scanUnreadable()])
883
+ const { entries: all, unreadable } = await trash.scanAll()
843
884
  const result = {
844
885
  entries: all.filter((entry) => entry.status === 'trashed').map(serializeEntry),
845
886
  incomplete: [
@@ -980,17 +1021,17 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
980
1021
  return
981
1022
  }
982
1023
  if (path === '/trash/restore') {
983
- const result = await serialize(() => restoreEntry(body?.trashId))
1024
+ const result = await serialize(() => restoreEntry(body?.trashId), { lockTimeoutMs: TRASH_LOCK_BUDGET_MS })
984
1025
  sendJson(res, 200, { ok: true, ...result })
985
1026
  return
986
1027
  }
987
1028
  if (path === '/trash/purge') {
988
- const result = await serialize(() => purgeEntry(body?.trashId, body?.confirmPhrase))
1029
+ const result = await serialize(() => purgeEntry(body?.trashId, body?.confirmPhrase), { lockTimeoutMs: TRASH_LOCK_BUDGET_MS })
989
1030
  sendJson(res, 200, { ok: true, ...result })
990
1031
  return
991
1032
  }
992
1033
  if (path === '/trash/discard') {
993
- const result = await serialize(() => discardEntry(body?.trashId, body?.confirmPhrase))
1034
+ const result = await serialize(() => discardEntry(body?.trashId, body?.confirmPhrase), { lockTimeoutMs: TRASH_LOCK_BUDGET_MS })
994
1035
  sendJson(res, 200, { ok: true, ...result })
995
1036
  return
996
1037
  }
@@ -338,7 +338,14 @@ export function createFeedbackService({ config, env = process.env, now = Date.no
338
338
  throw new DeleteError('E_FEEDBACK_TRANSPORT', 'This Host runtime provides no fetch() to deliver feedback.', { transport: 'http' })
339
339
  }
340
340
  const headers = { 'content-type': 'application/json' }
341
- if (settings.apiToken !== '') headers[settings.authHeader.toLowerCase()] = `Bearer ${settings.apiToken}`
341
+ if (settings.apiToken !== '') {
342
+ // `Authorization` is the one header whose RFC 7235 grammar mandates an
343
+ // auth-scheme token; a custom header (X-Api-Key, x-goog-api-key, …)
344
+ // expects the bare secret. Prefixing `Bearer` unconditionally 401'd
345
+ // every non-Authorization relay the user pointed us at.
346
+ const scheme = settings.authHeader.toLowerCase() === 'authorization' ? `Bearer ${settings.apiToken}` : settings.apiToken
347
+ headers[settings.authHeader.toLowerCase()] = scheme
348
+ }
342
349
  const body = {
343
350
  from: (settings.visitorAsFrom === true && submission.email) ? submission.email : (settings.from === '' ? undefined : settings.from),
344
351
  to: [settings.to],
package/lib/version.js CHANGED
@@ -5,4 +5,4 @@
5
5
  * asserted against it by `test/manifest.test.mjs` so the two can never drift
6
6
  * (they did: the constant stayed at 0.1.0 while the package reached 0.1.4).
7
7
  */
8
- export const PLUGIN_VERSION = '0.3.3'
8
+ export const PLUGIN_VERSION = '0.3.5'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-conversation-recycle-bin",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "DSH conversation deletion with an optional, previewed cascade into conversation-associated memory: soft delete + recycle bin, audit trail, batch rate limits and per-session rollback. 删除对话(可选一并删除关联记忆):软删除与回收站、审计留痕、批量限流、失败回滚。",
5
5
  "repository": {
6
6
  "type": "git",