dsh-conversation-recycle-bin 0.3.4 → 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,32 @@
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
+
6
32
  ## [0.3.4] — 2026-10-20
7
33
 
8
34
  对应 `AUDIT-2026-10-20-last3-versions.md` 的四条非阻塞隐患 N1–N4,以及测试驱动下新发现的 P3 回归 N5。
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/ # 131 个用例,覆盖用户要求的 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(131 个用例)
213
+ npm test # = manifest && client-module && core && cascade && memory && trash-audit && feedback && api(138 个用例)
203
214
  npm run check # 语法 + manifest
204
215
  ```
205
216
 
@@ -288,7 +299,7 @@ node -e "console.log('b64:' + Buffer.from('your-feedback@example.com','utf8').to
288
299
  cd D:\Users\34332\AI\dsh-conversation-recycle-bin
289
300
  npm run check # 语法 + manifest(含 feedback.js)
290
301
  node test/feedback.test.mjs # 19 个反馈用例
291
- npm test # 全量 131 个用例
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}.',
@@ -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} 个。',
@@ -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
@@ -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
  }
@@ -845,7 +880,7 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
845
880
  // invisible and impossible to clean up at all. Directories whose manifest
846
881
  // is missing/corrupt are surfaced as `unreadable` so they are not stranded
847
882
  // on disk; the Client renders both groups separately.
848
- const [all, unreadable] = await Promise.all([trash.listAll(), trash.scanUnreadable()])
883
+ const { entries: all, unreadable } = await trash.scanAll()
849
884
  const result = {
850
885
  entries: all.filter((entry) => entry.status === 'trashed').map(serializeEntry),
851
886
  incomplete: [
@@ -986,17 +1021,17 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
986
1021
  return
987
1022
  }
988
1023
  if (path === '/trash/restore') {
989
- const result = await serialize(() => restoreEntry(body?.trashId))
1024
+ const result = await serialize(() => restoreEntry(body?.trashId), { lockTimeoutMs: TRASH_LOCK_BUDGET_MS })
990
1025
  sendJson(res, 200, { ok: true, ...result })
991
1026
  return
992
1027
  }
993
1028
  if (path === '/trash/purge') {
994
- const result = await serialize(() => purgeEntry(body?.trashId, body?.confirmPhrase))
1029
+ const result = await serialize(() => purgeEntry(body?.trashId, body?.confirmPhrase), { lockTimeoutMs: TRASH_LOCK_BUDGET_MS })
995
1030
  sendJson(res, 200, { ok: true, ...result })
996
1031
  return
997
1032
  }
998
1033
  if (path === '/trash/discard') {
999
- const result = await serialize(() => discardEntry(body?.trashId, body?.confirmPhrase))
1034
+ const result = await serialize(() => discardEntry(body?.trashId, body?.confirmPhrase), { lockTimeoutMs: TRASH_LOCK_BUDGET_MS })
1000
1035
  sendJson(res, 200, { ok: true, ...result })
1001
1036
  return
1002
1037
  }
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.4'
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.4",
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",