dsh-conversation-recycle-bin 0.3.14 → 0.3.16

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,55 @@
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.16] — 2026-11-03
7
+
8
+ 一次针对源码本身的复查(逐模块读 Host 半边 + 记忆适配器、`client.js` 相关路径)。两条真缺陷、三处健壮性改进、两处文档不一致。
9
+
10
+ ### Fixed
11
+
12
+ - **记忆节点的 `nodeId` 能决定文件落点(路径穿越,已复现)**:`nodeId` 是外部记忆库 JSON 的 key(md_cg 的 `_index.json`,或 `memory.generic.indexFile` 指向的任何 JSON),插件此前直接拼成 `${nodeId}.content` 当作回收站里的文件名。一个 `../../../../escaped-node` 的 key 会把内容文件移出 `trash/<id>/memory/<adapter>/files/`,落到磁盘任意可写目录(文件名后缀固定 `.content`)。现在普通 id 保持历史命名(旧 bundle 仍可恢复),任何不是「纯文件名字符集」的 id 改用 `sha256(id)` 前 32 位命名;恢复时 `entry.storedFile` 还必须满足 `basename(x) === x`,否则抛 `E_TRASH_CONFLICT`。
13
+ - **无 overlay 座位时「反馈」仍是死按钮**:0.3.15 的内联确认框把 `kind: 'feedback'` 排除了,而反馈横幅就在同一个设置面板里,于是在 dsh-go 原生界面上点「反馈」什么都不发生。现在内联渲染按 `kind` 分派 `FeedbackDialog` / `DeleteDialog`,两类对话框共用同一条降级路径。
14
+
15
+ ### Changed
16
+
17
+ - **晚挂载的 overlay 不再导致两个对话框并存**:`useOverlaySeatPresent()` 从「500ms 后查一次」改成「250ms 轮询、最长 10s」,overlay 一旦挂载就让位给真正的宿主。
18
+ - **跨盘移动不再可能留下半个目标**:`moveDirectory()` 的 EXDEV 退路改为「先 `cp` 到目标旁的 `.partial-<pid>-<ts>`,再 `rename` 就位,最后删源」,崩溃时不会出现一个半写目录被后续恢复当成完整日志。
19
+ - **两处读取路径的头部预算统一**:纯文本日志首行从 64 KB 提到与 zstd 路径一致的 256 KB,且「填满预算还没换行」直接判为不可读,不再把一个截断行交给 `JSON.parse`。
20
+ - `writeJsonAtomic()` 改为模块顶部引入 `writeFile`(原来每次调用都 `await import`),rename 失败时清理临时文件。
21
+
22
+ ### Docs
23
+
24
+ - `README.md` 的状态行从 `0.3.14` 更正到 `0.3.16`(0.3.15 发布时漏改,且已随 npm 包外发)。
25
+ - `cordis.patch.yml` 的默认层补上 `requireConnection: true`:该键此前只存在于 `config.schema.json` 与 `lib/core/config.js` 的 `DEFAULTS`,使「默认层 21 项 / schema 22 项」对不上。
26
+
27
+ ### Tests
28
+
29
+ - `test/memory.test.mjs`(13 → 15):恶意 `nodeId` 不能把内容文件搬出 bundle、且事后该文件确实在 `files/` 内;旧式 `<nodeId>.content` bundle 仍可恢复,而 `../../etc/passwd` 形式的 `storedFile` 被拒。
30
+ - `test/client-module.test.mjs`(28 → 29):无 overlay 座位时反馈对话框走内联路径(挂的是 `FeedbackDialog`,不是 `DeleteDialog`)。
31
+ - 合计 192 → **195**。
32
+
33
+ ## [0.3.15] — 2026-10-31
34
+
35
+ dsh-go(Android)适配性审计后的修复。审计结论:Host 半边开箱可用(手机里跑的是真 Node 24 + 原版 DSH 引擎),问题全在**前端座位**与**移动端输入**上。
36
+
37
+ ### Fixed
38
+
39
+ - **原生界面里删除按钮全都没有反应**(dsh-go 侧 `shell.overlay` 是 opt-in 座位,确认框宿主不挂载):新增 `useInlineDialogState()`,检测到 overlay 宿主没有挂载时,把**同一个确认框内联**渲染在设置页「批量删除」标签页里。桌面端/网页端有座位时行为不变(`overlaySeatMounted` 标记 + 500ms 宽限,避免闪一下内联副本)。
40
+ - **`DELETE` 短语在手机上永远对不上**:Android 输入法会把首字母大写,`purgePhrase.trim() !== 'DELETE'` 于是让按钮永久灰掉、没有任何提示。现在 Client 的 `phraseMatches()` 与 Host 的 `deletePlan`/`purgeEntry`/`discardEntry` 三处比对都**忽略大小写并去空格**;三个输入框补 `autoCapitalize`/`autoCorrect`/`spellCheck`(计数短语用 `inputMode: 'numeric'`)。
41
+ - **非 JSON 拒绝被报成「无法解析的响应」**:dsh-go 的 `route-guard.diff` 在插件路由之前返回 `text/plain` 401/503。新增 `httpFallbackError(response)`,按状态码映射成 `E_UNAUTHORIZED`/`E_DISABLED`/`E_BAD_RESPONSE`,并在响应头带 `x-dsh-go-guard: reject` 时把原因写进文案("dsh-go route guard")。
42
+ - **`E_LOCK_BUSY` 是死胡同**:错误信息补上锁文件路径与"持续出现就删掉它"的指引,`details.lockPath` 一并返回(手机被杀进程后残留锁最长阻塞 20 分钟,用户此前没有任何自救手段)。
43
+
44
+ ### Changed
45
+
46
+ - 回收站横幅文案补充「清理只在 Host 运行时发生」:App 关闭期间清扫不执行,下次启动补扫。
47
+ - README 新增 §8.1「dsh-go(Android)适配」:网页模式/原生界面的差异、opt-in 座位导致的降级路径、市场默认隐藏 web-only 插件需点「显示网页版插件」、DSH Remote 无网页版。
48
+
49
+ ### Tests
50
+
51
+ - `test/client-module.test.mjs`(25 → 28):短语忽略大小写与空期望值;非 JSON 拒绝按状态码映射(含 `x-dsh-go-guard` 分支);**没有 overlay 座位时设置页把同一个确认框内联渲染**(无 payload 时不渲染空壳,有 payload 时挂上真正的 `DeleteDialog` 并给出原因提示)。
52
+ - `test/api.test.mjs`(63 → 64):`DELETE` 短语大小写不敏感(`Delete me` 仍 400 `E_CONFIRM_PHRASE`,`delete` 通过)。
53
+ - 合计 188 → **192**(干净树 `rm -rf test/.tmp` 后全绿)。
54
+
6
55
  ## [0.3.14] — 2026-10-30
7
56
 
8
57
  第二轮独立审计(架构 / 安全 / 验收各跑 186 条、并做了变异测试与真实探针)之后的修复:一条会**删掉唯一副本**的读盘失败被当成「空目录」,一条会把用户**刚恢复的对话**再删一次,还有一类条目**三条出路全拒**却仍在 UI 上提供「恢复」按钮。
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  给 DeepSeek Harness (DSH) Web 增加「删除对话」:会话列表的「…」菜单与会话详情页头部各有一个删除入口;确认框内可勾选「同时删除关联记忆」(默认不勾选),并实时显示将被影响的记忆条数。删除默认进入插件回收站,可恢复;每次操作写入审计日志;批量删除有上限与限流,批量软删除需输入数量,彻底删除一键执行;事务任一步失败即回滚。
4
4
 
5
- > 状态:`0.3.14`,Host + Web Client 双半,零构建(plain JS ESM),只依赖宿主已发布的公开扩展点。
5
+ > 状态:`0.3.16`,Host + Web Client 双半,零构建(plain JS ESM),只依赖宿主已发布的公开扩展点。
6
6
  > 运行要求:**Node.js ≥ 22.15**(Host 侧用到 `readdir(..., { recursive: true })`、`AbortSignal` 等 22.x 行为),DSH ≥ `0.2.1-rc.2`(`package.json` 的 `engines` 已如实声明,DSH 安装校验会据此拒绝更低版本)。
7
7
  > 包名:`dsh-conversation-recycle-bin`(原 `dsh-delete-conversation` 因 npm 被他人占用而改名,见 CHANGELOG 0.2.0)。
8
8
  > 本 README 同时是设计说明与验收依据;安装/回滚步骤见 [INSTALL.md](INSTALL.md);变更见 [CHANGELOG.md](CHANGELOG.md)。
@@ -247,6 +247,28 @@ npm run check # 语法 + manifest
247
247
 
248
248
  ---
249
249
 
250
+ ## 8.1 dsh-go(Android)适配
251
+
252
+ [dsh-go](https://github.com/ccch1mneyyy/dsh-Go) 的 App 里跑的是**真正的 Node 24 + 原版 DSH 引擎**(PRoot 内 Ubuntu),所以 Host 半边零改动即可运行:`engines.node >= 22.15`、`engines.dsh >= 0.2.1-rc.2` 双向满足,`market/policy.json` 也不拦第三方包。差异全在**前端座位**上:
253
+
254
+ | 界面 | 结果 |
255
+ |---|---|
256
+ | **网页模式**(App 内 DSH 网页版 / SSH 主机) | 完整可用,四个槽位都在 |
257
+ | **原生界面** | 插件能装、设置页有整页岛(回收站可看、可恢复、可彻底清除);会话行菜单与顶栏入口**没有座位**,因此删除走设置页「批量删除」标签页 |
258
+
259
+ 原生界面里 `shell.overlay` 是 opt-in 座位(`slot_catalog.dart` 的 `SeatPolicy.p2Overlay`),确认框的宿主因此不会挂载。0.3.15 起做了两件事:
260
+
261
+ 1. **降级渲染**:检测到 overlay 宿主没有挂载时,设置页把**同一个确认框内联**渲染在「批量删除」标签页里(`client.js` 的 `useInlineDialogState`),不再出现"点了删除没反应"。
262
+ 2. **移动端输入**:短语输入框加 `autoCapitalize/autoCorrect/spellCheck`,并且 Host 与 Client 的短语比对都改成**忽略大小写**(Android 输入法会把 `DELETE` 写成 `Delete`)。
263
+
264
+ 另外两件与手机有关的事,写在文档里而不是藏起来:
265
+
266
+ - **市场默认隐藏 web-only 插件**:安装前要在插件市场点一次「显示网页版插件」。
267
+ - **保留期清理只在 Host 运行时发生**:`trashRetentionDays`(默认 30 天)的清扫由宿主进程驱动,App 被杀掉期间不会执行,下次启动时补扫;`0` 关闭。
268
+ - **DSH Remote(远程桥)没有网页版**,因此插件在那里没有 UI;SSH 主机可以用网页模式。
269
+
270
+ ---
271
+
250
272
  ## 9. 反馈通道(设置页「反馈」按钮)
251
273
 
252
274
  设置页**最上方**新增一条「反馈」横幅(主色按钮,位于标题之上):把问题或建议写成一条消息,由**宿主进程**通过邮件发到维护者邮箱。凭证只在宿主侧读取,页面拿不到、也发不出任何邮件。
package/client.js CHANGED
@@ -112,7 +112,7 @@ window.__ModuleLoader__.load({
112
112
  'trash.title': 'Recycle bin',
113
113
  'trash.empty': 'The recycle bin is empty.',
114
114
  'trash.deletedAt': 'Deleted {time}',
115
- 'trash.retention': 'Entries are purged automatically {days} days after deletion. Set trashRetentionDays to 0 to keep them until you purge them yourself.',
115
+ 'trash.retention': 'Entries are purged automatically {days} days after deletion, but only while the Host is running (a phone with the app closed simply sweeps on the next launch). Set trashRetentionDays to 0 to keep them until you purge them yourself.',
116
116
  'trash.expiresIn': 'auto-purged in {days} d',
117
117
  'trash.expiresSoon': 'auto-purged on the next check',
118
118
  'trash.swept': 'The Host cleaned up {count} expired entries in the background.',
@@ -219,6 +219,7 @@ window.__ModuleLoader__.load({
219
219
  'error.E_QUEUE_TIMEOUT': 'The Host was still busy with another change, so this request was not applied. Retry in a moment.',
220
220
  'error.E_ABANDONED': 'The page gave up waiting before the Host started; nothing was changed. Retry.',
221
221
  'error.E_UNAUTHORIZED': 'This request is not authenticated with the Host.',
222
+ 'dialog.inlineHint': 'This window has no overlay slot (dsh-go native UI), so the confirmation is shown here instead of in a popup.',
222
223
  'adapter.lingshu': 'Lingshu cognitive graph (md_cg)',
223
224
  'adapter.generic-json': 'Generic JSON memory store',
224
225
  'adapter.none': 'No memory store',
@@ -274,7 +275,7 @@ window.__ModuleLoader__.load({
274
275
  'trash.title': '回收站',
275
276
  'trash.empty': '回收站为空。',
276
277
  'trash.deletedAt': '删除于 {time}',
277
- 'trash.retention': '条目在删除 {days} 天后自动清理。把 trashRetentionDays 设为 0 可改为一直保留、由你自己彻底删除。',
278
+ 'trash.retention': '条目在删除 {days} 天后自动清理,但清理只在 Host 运行时发生(手机 App 关闭期间不会执行,下次启动时补扫)。把 trashRetentionDays 设为 0 可改为一直保留、由你自己彻底删除。',
278
279
  'trash.expiresIn': '{days} 天后自动清理',
279
280
  'trash.expiresSoon': '下次检查时自动清理',
280
281
  'trash.swept': 'Host 已在后台自动清理 {count} 条过期条目。',
@@ -381,6 +382,7 @@ window.__ModuleLoader__.load({
381
382
  'error.E_QUEUE_TIMEOUT': '宿主还在处理另一个变更,这次请求没有执行,请稍后重试。',
382
383
  'error.E_ABANDONED': '页面在宿主开始处理前就放弃了等待,本次没有做任何改动,请重试。',
383
384
  'error.E_UNAUTHORIZED': '该请求未通过宿主的鉴权。',
385
+ 'dialog.inlineHint': '当前窗口没有 overlay 槽位(dsh-go 原生界面),确认框直接显示在这里。',
384
386
  'adapter.lingshu': '灵枢认知图 (md_cg)',
385
387
  'adapter.generic-json': '通用 JSON 记忆存储',
386
388
  'adapter.none': '无记忆存储',
@@ -552,6 +554,48 @@ window.__ModuleLoader__.load({
552
554
  return new URL(`${API_PREFIX.replace(/^\//, '')}${path}`, base)
553
555
  }
554
556
 
557
+ /**
558
+ * Does the typed phrase match what the Host asked for?
559
+ *
560
+ * Trimmed and case-insensitive on purpose: Android soft keyboards capitalise
561
+ * the first letter of a word, so `Delete` reached a check that only accepted
562
+ * `DELETE` and the confirm button stayed disabled with no explanation. The
563
+ * phrase exists to make the user think, not to test their keyboard.
564
+ */
565
+ function phraseMatches(input, expected) {
566
+ const want = String(expected ?? '').trim()
567
+ if (want === '') return true
568
+ return String(input ?? '').trim().toUpperCase() === want.toUpperCase()
569
+ }
570
+
571
+ /**
572
+ * Explain a refusal that never reached the plugin route.
573
+ *
574
+ * The dsh-go Host guard (and any reverse proxy in front of DSH) answers with
575
+ * `text/plain`, so `response.json()` threw and the panel showed "unreadable
576
+ * response" where the real answer was "not authenticated".
577
+ */
578
+ function httpFallbackError(response) {
579
+ const status = Number(response?.status)
580
+ let guarded = false
581
+ try {
582
+ guarded = response?.headers?.get?.('x-dsh-go-guard') === 'reject'
583
+ } catch {
584
+ guarded = false
585
+ }
586
+ if (status === 401 || status === 403) {
587
+ return new ApiError('E_UNAUTHORIZED', guarded
588
+ ? 'The Host rejected this request before the plugin saw it (dsh-go route guard): the page is not authenticated.'
589
+ : 'This request is not authenticated with the Host.', { status })
590
+ }
591
+ if (status === 503) {
592
+ return new ApiError('E_DISABLED', guarded
593
+ ? 'The Host route guard has no connection service yet; retry once the app has finished starting.'
594
+ : 'The plugin is disabled, or the Host is not ready yet.', { status })
595
+ }
596
+ return new ApiError('E_BAD_RESPONSE', `The Host answered ${Number.isFinite(status) ? status : 'an unexpected status'} without a JSON body.`, { status })
597
+ }
598
+
555
599
  async function api(path, options = {}) {
556
600
  const method = options.method ?? 'GET'
557
601
  const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : REQUEST_TIMEOUT_MS
@@ -575,7 +619,9 @@ window.__ModuleLoader__.load({
575
619
  payload = null
576
620
  }
577
621
  if (payload === null || typeof payload !== 'object') {
578
- throw new ApiError('E_BAD_RESPONSE', 'The Host returned an unreadable response.')
622
+ // A refusal that never reached the plugin route (Host route guard,
623
+ // reverse proxy, HTML error page) still has a status worth explaining.
624
+ throw httpFallbackError(response)
579
625
  }
580
626
  // A batch that partially succeeded is a RESULT, not a refusal: an older
581
627
  // Host marked it `ok:false` without an `error`, which made the Client
@@ -644,6 +690,56 @@ window.__ModuleLoader__.load({
644
690
  return state
645
691
  }
646
692
 
693
+ /**
694
+ * Whether the `shell.overlay` slot actually gave us a seat.
695
+ *
696
+ * On DSH for desktop/web it always does. On dsh-go's native UI the overlay
697
+ * seat is opt-in (`SeatPolicy.p2Overlay`, `optIn: true` in
698
+ * `slot_catalog.dart`), so the host component never mounts — and with it the
699
+ * only confirmation dialog the delete flow had. Settings still gets a page
700
+ * island, so the panel can render that same dialog inline instead of
701
+ * pretending the button did nothing.
702
+ */
703
+ let overlaySeatMounted = false
704
+
705
+ function useOverlaySeatPresent() {
706
+ const [present, setPresent] = useState(overlaySeatMounted === true)
707
+ useEffect(() => {
708
+ if (overlaySeatMounted === true) {
709
+ setPresent(true)
710
+ return undefined
711
+ }
712
+ // The overlay host mounts with the shell, but nothing guarantees it is
713
+ // already there when this panel first renders: poll briefly so a late
714
+ // mount still wins over the inline copy instead of rendering both.
715
+ let timer = null
716
+ const tick = () => {
717
+ if (overlaySeatMounted !== true) return
718
+ if (timer !== null) clearInterval(timer)
719
+ setPresent(true)
720
+ }
721
+ timer = setInterval(tick, 250)
722
+ const stop = setTimeout(() => {
723
+ if (timer !== null) clearInterval(timer)
724
+ }, 10_000)
725
+ return () => {
726
+ if (timer !== null) clearInterval(timer)
727
+ clearTimeout(stop)
728
+ }
729
+ }, [])
730
+ return present
731
+ }
732
+
733
+ function useInlineDialogState() {
734
+ const payload = useDialogState()
735
+ const overlayPresent = useOverlaySeatPresent()
736
+ if (overlayPresent || payload === null) return null
737
+ // Every dialog this plugin opens has to travel through here, not only the
738
+ // delete one: the feedback form is opened from this very panel, so
739
+ // excluding it left a second dead button on hosts without an overlay seat.
740
+ return payload
741
+ }
742
+
647
743
  /**
648
744
  * Notification channel for "a mutation changed the session list". The
649
745
  * dialog refreshes the shell, but the Settings section owns a second copy
@@ -1090,7 +1186,7 @@ window.__ModuleLoader__.load({
1090
1186
  const blocked = Array.isArray(preview?.blocked) ? preview.blocked : []
1091
1187
  const confirmation = preview?.confirmation ?? null
1092
1188
  const expectedPhrase = confirmation?.phrase ?? ''
1093
- const phraseOk = expectedPhrase === '' || phrase.trim() === expectedPhrase
1189
+ const phraseOk = phraseMatches(phrase, expectedPhrase)
1094
1190
  const memory = preview?.memory ?? null
1095
1191
  const memorySupported = memory !== null && memory.enabled === true
1096
1192
  && Array.isArray(memory.adapters) && memory.adapters.some((adapter) => adapter.available === true)
@@ -1267,6 +1363,12 @@ window.__ModuleLoader__.load({
1267
1363
  disabled: busy,
1268
1364
  autoFocus: true,
1269
1365
  className: 'dc-input',
1366
+ // The phrase is a count on a phone: no autocorrect, no
1367
+ // sentence capitalisation, digits where digits are expected.
1368
+ inputMode: 'numeric',
1369
+ autoCorrect: 'off',
1370
+ autoCapitalize: 'off',
1371
+ spellCheck: false,
1270
1372
  }))
1271
1373
  : null,
1272
1374
 
@@ -1480,6 +1582,12 @@ window.__ModuleLoader__.load({
1480
1582
  function DialogHost() {
1481
1583
  const payload = useDialogState()
1482
1584
  const flash = useFlashMessage()
1585
+ useEffect(() => {
1586
+ overlaySeatMounted = true
1587
+ return () => {
1588
+ overlaySeatMounted = false
1589
+ }
1590
+ }, [])
1483
1591
  // `kind` is optional: every pre-0.2.2 caller opens a delete dialog, so a
1484
1592
  // payload without a kind keeps its old meaning.
1485
1593
  const dialog = payload === null
@@ -1595,10 +1703,16 @@ window.__ModuleLoader__.load({
1595
1703
  placeholder: 'DELETE',
1596
1704
  className: 'dc-input dc-input-small',
1597
1705
  onChange: (event) => onPhrase(event.target.value),
1706
+ // Without this Android capitalises the first letter and
1707
+ // writes `Delete`; the guard above is case-insensitive now,
1708
+ // but the keyboard should stop guessing anyway.
1709
+ autoCapitalize: 'characters',
1710
+ autoCorrect: 'off',
1711
+ spellCheck: false,
1598
1712
  }),
1599
1713
  h(Button, {
1600
1714
  variant: 'danger',
1601
- disabled: discardBusyId === entry.trashId || discardPhrase.trim() !== 'DELETE',
1715
+ disabled: discardBusyId === entry.trashId || phraseMatches(discardPhrase, 'DELETE') !== true,
1602
1716
  onClick: () => onDiscard(entry.trashId),
1603
1717
  }, t('trash.discard')),
1604
1718
  h(Button, { disabled: discardBusyId === entry.trashId, onClick: onCancel }, t('action.cancel')))
@@ -1640,6 +1754,7 @@ window.__ModuleLoader__.load({
1640
1754
  function SettingsSection() {
1641
1755
  useLocaleTick()
1642
1756
  const mutationVersion = useMutationVersion()
1757
+ const inlineDialog = useInlineDialogState()
1643
1758
  const [tab, setTab] = useState('trash')
1644
1759
  const [state, setState] = useState(null)
1645
1760
  const [sessions, setSessions] = useState([])
@@ -1966,10 +2081,13 @@ window.__ModuleLoader__.load({
1966
2081
  h('input', {
1967
2082
  type: 'text', value: purgePhrase, placeholder: 'DELETE', className: 'dc-input dc-input-small',
1968
2083
  onChange: (event) => setPurgePhrase(event.target.value),
2084
+ autoCapitalize: 'characters',
2085
+ autoCorrect: 'off',
2086
+ spellCheck: false,
1969
2087
  }),
1970
2088
  h(Button, {
1971
2089
  variant: 'danger',
1972
- disabled: purgeBusy === true || purgePhrase.trim() !== 'DELETE',
2090
+ disabled: purgeBusy === true || phraseMatches(purgePhrase, 'DELETE') !== true,
1973
2091
  onClick: () => { void purge(entry.trashId, purgePhrase.trim()) },
1974
2092
  }, t('action.purge')),
1975
2093
  h(Button, { disabled: purgeBusy === true, onClick: () => { setPurgeTarget(null); setPurgePhrase('') } }, t('action.cancel')))
@@ -2033,7 +2151,16 @@ window.__ModuleLoader__.load({
2033
2151
  // without the source the two are indistinguishable in the UI.
2034
2152
  + (typeof record.source === 'string' && record.source !== 'web-ui' ? ` · ${record.source}` : '')
2035
2153
  + (record.error?.message ? ` · ${record.error.message}` : ''))),
2036
- h('div', { className: 'dc-row-time' }, record.actor ?? '')))) : null)
2154
+ h('div', { className: 'dc-row-time' }, record.actor ?? '')))) : null,
2155
+
2156
+ // dsh-go's native UI gives `shell.overlay` no seat by default, so the
2157
+ // dialog host never mounts there and every delete button looked dead.
2158
+ // The panel keeps its own seat, so it renders the same dialog inline.
2159
+ inlineDialog === null ? null : h('div', { className: 'dc-inline-dialog' },
2160
+ h('p', { className: 'dc-hint' }, t('dialog.inlineHint')),
2161
+ inlineDialog.kind === 'feedback'
2162
+ ? h(FeedbackDialog, { payload: inlineDialog, key: `inline:${inlineDialog.key ?? 'feedback'}` })
2163
+ : h(DeleteDialog, { payload: inlineDialog, key: `inline:${inlineDialog.key ?? ''}` })))
2037
2164
  }
2038
2165
 
2039
2166
  /* ------------------------------------------------------------------ *
@@ -2044,6 +2171,11 @@ window.__ModuleLoader__.load({
2044
2171
  /* 1300 clears the settings modal (1000) and the host panels that also use
2045
2172
  1100, since this backlog shares document.body with them. */
2046
2173
  .dc-backdrop{position:fixed;inset:0;z-index:1300;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.38);padding:16px}
2174
+ /* Inline copy for hosts without an overlay seat (dsh-go native UI): the
2175
+ same dialog, in the page flow instead of on top of it. */
2176
+ .dc-inline-dialog{margin-top:14px;padding-top:12px;border-top:1px solid var(--dsw-alias-border-l1,#e5e6eb)}
2177
+ .dc-inline-dialog .dc-backdrop{position:static;inset:auto;z-index:auto;background:none;padding:0;display:block}
2178
+ .dc-inline-dialog .dc-dialog{width:100%;max-width:none;max-height:none;box-shadow:none;padding:14px}
2047
2179
  .dc-dialog{box-sizing:border-box;width:min(560px,94vw);max-height:88vh;overflow:auto;background:var(--dsw-alias-bg-overlay,#fff);color:var(--dsw-alias-label-primary,#1f2329);border:1px solid var(--dsw-alias-border-l1,#e5e6eb);border-radius:12px;box-shadow:0 18px 60px rgba(0,0,0,.28);padding:20px 20px 16px;font:inherit}
2048
2180
  .dc-dialog-title{margin:0 0 8px;font-size:16px;font-weight:600;line-height:1.4}
2049
2181
  .dc-dialog-body{margin:0 0 12px;font-size:13px;color:var(--dsw-alias-label-secondary,#646a73);line-height:1.6}
@@ -2232,6 +2364,12 @@ window.__ModuleLoader__.load({
2232
2364
  exports.__adapterLabel = adapterLabel
2233
2365
  exports.__adapterReason = adapterReason
2234
2366
  exports.__loadUiFlags = loadUiFlags
2367
+ // Pure helpers the tests assert on directly: the phrase rule and the
2368
+ // non-JSON fallback are exactly where a mobile keyboard or a route
2369
+ // guard changes the outcome.
2370
+ exports.__phraseMatches = phraseMatches
2371
+ exports.__httpFallbackError = httpFallbackError
2372
+ exports.__overlaySeatState = () => ({ mounted: overlaySeatMounted })
2235
2373
  return module.exports
2236
2374
  },
2237
2375
  })
package/cordis.patch.yml CHANGED
@@ -21,6 +21,12 @@
21
21
  # Refuse every request when the Web server is bound beyond loopback,
22
22
  # unless the operator opts in explicitly below.
23
23
  requireSameOrigin: true
24
+ # Refuse every request when the Host exposes no `connection` service:
25
+ # the loopback/origin fence cannot tell two local processes apart, so
26
+ # without it any local process could delete a conversation. Listed here
27
+ # (and not only in config.schema.json) so the shipped default layer shows
28
+ # every key the contract has.
29
+ requireConnection: true
24
30
  allowNonLoopback: false
25
31
  # Reverse-proxy deployments: list the exact authorities the proxy puts
26
32
  # in `Host` (host, host:port). A bare host admits any port. Listing them
@@ -103,8 +103,13 @@ export function createFileLock({
103
103
  let breakFailures = 0
104
104
 
105
105
  const busyError = () => {
106
- const busy = new Error(`The lock ${lockPath} is held by another process.`)
106
+ // The path is part of the message on purpose: a lock left behind by a
107
+ // killed Host (or a recycled pid that never heartbeats again) blocks every
108
+ // change for up to twenty minutes, and the only fix the user has is
109
+ // deleting that file. Without the path the refusal is a dead end.
110
+ const busy = new Error(`The lock ${lockPath} is held by another process. If this keeps happening, delete that file and retry.`)
107
111
  busy.code = 'E_LOCK_BUSY'
112
+ busy.details = { lockPath }
108
113
  return busy
109
114
  }
110
115
 
@@ -40,7 +40,7 @@
40
40
  */
41
41
  import * as defaultFs from 'node:fs/promises'
42
42
  import { createHash, randomBytes } from 'node:crypto'
43
- import { dirname, isAbsolute, join, resolve, sep } from 'node:path'
43
+ import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path'
44
44
  import { resolveJsonPointer } from '../config.js'
45
45
  import { DeleteError } from '../errors.js'
46
46
  import { createFileLock } from '../file-lock.js'
@@ -78,6 +78,30 @@ function validateRelativeContentPath(rootDir, relative) {
78
78
 
79
79
  const sleepDefault = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
80
80
 
81
+ /**
82
+ * File name a node's content gets inside the restore bundle.
83
+ *
84
+ * The node id is a KEY FROM AN EXTERNAL JSON FILE (md_cg's index, or whatever
85
+ * `memory.generic.indexFile` points at), so it is data, not a path: an id like
86
+ * `../../..` used to move the content file out of the bundle and into any
87
+ * directory the Host can write. Ordinary ids keep their historical name, so
88
+ * bundles written before this change still restore; anything that is not a
89
+ * plain name is replaced by a hash (`bundle.entries[].nodeId` still carries the
90
+ * real id, which is all a restore needs).
91
+ */
92
+ function contentFileName(nodeId) {
93
+ const raw = String(nodeId)
94
+ if (raw !== '.' && raw !== '..' && /^[A-Za-z0-9._-]{1,120}$/.test(raw)) return `${raw}.content`
95
+ return `${createHash('sha256').update(raw).digest('hex').slice(0, 32)}.content`
96
+ }
97
+
98
+ /** A stored file name must stay a name: no separators, no traversal. */
99
+ function isPlainFileName(value) {
100
+ return typeof value === 'string' && value !== '' && value !== '.' && value !== '..' && basename(value) === value
101
+ }
102
+
103
+
104
+
81
105
  /**
82
106
  * @param {object} options
83
107
  * @param {string} options.id stable adapter id
@@ -362,7 +386,7 @@ export function createJsonIndexMemoryAdapter(options) {
362
386
  if (rootDir !== null && typeof relative === 'string') {
363
387
  const source = validateRelativeContentPath(rootDir, relative)
364
388
  if (source !== null) {
365
- const storedName = `${nodeId}.content`
389
+ const storedName = contentFileName(nodeId)
366
390
  try {
367
391
  await fsApi.rename(source, join(filesDir, storedName))
368
392
  movedFiles.push({ from: source, to: join(filesDir, storedName) })
@@ -488,6 +512,12 @@ export function createJsonIndexMemoryAdapter(options) {
488
512
  if (entry.storedFile !== null && entry.originalPath !== null && rootDir !== null) {
489
513
  const destination = validateRelativeContentPath(rootDir, entry.originalPath)
490
514
  if (destination === null) throw new DeleteError('E_TRASH_CONFLICT', 'Memory content path is invalid; restore aborted.', { nodeId: entry.nodeId })
515
+ if (!isPlainFileName(entry.storedFile)) {
516
+ throw new DeleteError('E_TRASH_CONFLICT', 'The restore bundle names a content file outside its own directory; restore aborted.', {
517
+ adapter: id,
518
+ nodeId: entry.nodeId,
519
+ })
520
+ }
491
521
  const stored = join(bundle.bundlePath ? dirname(bundle.bundlePath) : '', 'files', entry.storedFile)
492
522
  await fsApi.mkdir(dirname(destination), { recursive: true })
493
523
  await fsApi.rename(stored, destination)