dsh-conversation-recycle-bin 0.3.20 → 0.3.22

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,49 @@
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.22] — 2026-11-12
7
+
8
+ 对**当前最新宿���**(`@deepseek-ai/dsh-desktop-runtime` **0.2.2-rc.1**,桌面端与 `dsh web` 共用)的三方只读评审(架构 / 安全 / 验收)之后的修复。评审结论是「宿主契约没有硬断裂,但有一处安装门禁错误、一处标题优先级倒置、一处脱敏缺口」;本版把这四项 P0 与两项 P1 全部闭合,并给出一条可复核的运行态探针。
9
+
10
+ ### Fixed
11
+
12
+ - **`engines.dsh` 匹配不了当前宿主**(评审 H1):按 semver 预发布规则,区间 `>=0.2.1-rc.2 <0.3.0-0` 的比较器元组是 0.2.1 与 0.3.0,而候选是 `0.2.2-rc.1` —— **没有任何 `0.2.2` 元组带预发布**,因此不满足,全新的 `npm i` 会被引擎门禁拒绝(本机只因 `link:` 安装而照跑)。range 现补上 `|| >=0.2.2-0 <0.3.0-0`。
13
+ - **标题来源优先级倒置**(评审 H2):旧整表 `storages/session_projcache.json`(实测 `unit.version: 3`,宿主早已停写)被无条件当作标题源,权威 per-record 检查点只在「标题为空」时兜底。后果是**迁移之后改过名的会话永远显示迁移时的旧标题**。现在 per-record 是唯一权威(对每行都读),旧整表只填空缺,且受 `unit.version === 3` 版本钉约束(未知版本跳过并记诊断)。
14
+ - **`version !== 7` 的一刀切被换成能力探测**(评审 R1/T1):宿主把投影版本从 7 提到 8、但字段形状不变时,旧逻辑让**每一行都丢掉标题**,而 `test/contract.test.mjs` 把「v8 → 空标题」断言成了期望行为,于是真漂移也不会红。现在按实际字段(`rows.title.val` / `rows.sessionListMetadata.val`)探测:形状对就照读并计入 `diagnostics.projection.foreignVersion`,形状不对才降级并计入 `malformed`。
15
+ - **`/state` 把 adapter 原始异常原样回传**(评审 S1):`reason` 直接来自 `error.message`(ENOENT/EPERM 会带绝对路径),而 `/state` 是**成功响应**、从未过 `redactDetails`,且在没有 `connection` 服务时经 `isStateProbe` **免鉴权**返回 —— 与插件自述「路径不跨 HTTP 边界」直接冲突。现在整个 `/state` 载荷在出口过 `redactDetails`。
16
+ - **`verify-host-contract.mjs` 会假通过**(评审 M4):判据是 HTML 里出现包名字符串,而包名本来就是 profile 依赖键;默认端口还写死 3082。现在判据改为 `state.version` + `state.limits` 形状,端口取自参数或 `$DSH_WEB_URL`,并新增一条投影格式诊断断言。
17
+
18
+ ### Added
19
+
20
+ - **`requireOwnerPeer`(默认 false)**:`requestRejection` 只回答「认证与否」,而当前宿主还能认证**非 owner peer**(远程访问授权)。一旦为反向代理/隧道配置了 `allowedHosts`,这种 peer 就能删会话、purge 回收站。开启后插件向 connection 层索取 principal,非 owner 一律 403 `E_FORBIDDEN_PRINCIPAL`,拿不到 principal 也拒绝(`E_PEER_UNKNOWN`,fail-closed);关闭时行为与 0.3.21 完全一致。
21
+ - **`/state.diagnostics`**:`{ projection: { total, foreignVersion, malformed, unreadable }, legacyTitles }`,让「标题为什么没了」变成可观测数据,而不是空白行。
22
+
23
+ ### Docs
24
+
25
+ - README 安全声明补三条红线:`allowedHosts` 只用于**已知**代理且建议同时开 `requireOwnerPeer`;桌面端壳会替 renderer 注入 cookie 并转发任意路径,故 UI 内任意脚本(含第三方插件)与面板同权;`dsh-app://app` origin 豁免在当前宿主已不可达(桌面壳改用 `http://127.0.0.1:port`),保留分支纯属纵深防御。
26
+ - 评审记录:本版未因评审而改变的两个设计取舍 —— hard 模式批量删除不需要短语(既有文档化行为,soft 批量才需要)、`/sessions` 仍返回 `cwd`(Client 用于显示;仅在对外暴露时才有意义)。
27
+
28
+ ### Tests
29
+
30
+ - 用例 221 → **229**:新增 v8-字段不变仍出标题、字段改名降级且计数、旧整表未知版本跳过、per-record 压过旧标题、旧表仍能为「无检查点」的行兜底、`/state` 路径脱敏 + diagnostics、`requireOwnerPeer` 四态(非 owner 403 / owner 200 / 无 principal 503 / 默认关不改变行为)。
31
+
32
+ ## [0.3.21] — 2026-11-09
33
+
34
+ CI 第一次真正跑起来就抓到一条**只在中文机器上通过**的用例。0.3.20 发布后推送仓库,四个矩阵任务(ubuntu/windows × Node 22.15/24)全部在 `npm test` 阶段失败,失败点相同。
35
+
36
+ ### Fixed
37
+
38
+ - **`client-module.test.mjs` 把中文写进了断言**(`the background-sweep notice carries how long ago it ran`):`__formatAgo` 的「刚刚」被硬编码成字面量。`client.js` 的 `currentLocaleId()` 先读宿主的 `locale` 服务、读不到才回退 `navigator.language`,而这个用例没有安装 locale 服务,于是断言跟着**运行机器**的语言走——作者的中文 Windows 通过,所有英文 locale 的 CI 与贡献者机器都失败(`'just now' !== '刚刚'`)。这正是 0.3.17 修过的同一类缺陷:**测试没有锁定 locale,产品行为其实是对的**。
39
+ - 现在这条用例对 `en` 与 `zh-CN` **各跑一遍**,每次通过新加的 `loadClientWithLocale()` 显式安装 locale 服务,并逐段断言「刚刚 / 30 分钟前 / 3 小时前 / 2 天前」与英文文案,顺手把两套字典的相对时间文案一起钉住。
40
+
41
+ ### Added
42
+
43
+ - **CI 把失败摘要发布成 workflow annotation**:失败任务的原始日志需要仓库凭据才能读,只看到 `exit code 1` 无法区分「locale 相关的断言断错」和「夹具缺失」。`test` 步骤现在捕获输出,失败时把失败用例的名称、`expected`/`actual` 与所在文件行号以 `::error::` 注释发出,无需凭据即可读(本条缺陷就是这样定位的)。
44
+
45
+ ### Tests
46
+
47
+ - 用例总数不变(221),`client-module.test.mjs` 32/32。
48
+
6
49
  ## [0.3.20] — 2026-11-09
7
50
 
8
51
  一次只改工程效率、不改删除语义的整理:把测试墙钟从 **60.5s 压到 4.6s**,堵住「跑过测试再打包」会把本机测试残留塞进 npm 包的路径,删掉三处零引用导出,并把两个确认前的**读**循环改成有界并发。生产路径的锁预算、超时与策略判定全部不变。
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  给 DeepSeek Harness (DSH) Web 增加「删除对话」:会话列表的「…」菜单与会话详情页头部各有一个删除入口;确认框内可勾选「同时删除关联记忆」(默认不勾选),并实时显示将被影响的记忆条数。删除默认进入插件回收站,可恢复;每次操作写入审计日志;批量删除有上限与限流,批量软删除需输入数量,彻底删除一键执行;事务任一步失败即回滚。
4
4
 
5
- > 状态:`0.3.20`,Host + Web Client 双半,零构建(plain JS ESM),只依赖宿主已发布的公开扩展点。
5
+ > 状态:`0.3.22`,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 <0.3.0-0`(`package.json` 的 `engines.dsh` 已如实声明,DSH 安装校验会据此拒绝范围外版本;上界是刻意的:0.2.2 与 0.3.x 之间没有承诺兼容)。
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)。
@@ -126,6 +126,10 @@ memory → messages → session/catalog
126
126
 
127
127
  反代部署(`dsh web --public-url https://host/ui/`)时,代理会把外部主机名放进 `Host`,插件的回环校验会以 `E_FORBIDDEN_HOST` 拒绝。用 `allowedHosts` 精确放行,而不是把 `allowNonLoopback` 整体打开:
128
128
 
129
+ > **红线(0.3.22 起)**:`allowedHosts` 只用于**已知**的反向代理。当前宿主会认证**非 owner peer**(远程访问授权),而 `requestRejection` 只回答「认证与否」;一旦把本路由经代理/隧道暴露出去,这种 peer 也能删除会话。对外暴露时**同时打开 `requireOwnerPeer: true`**(插件会向 connection 层索取 principal,非 owner 与「取不到 principal」一律拒绝)。
130
+ >
131
+ > 另两条边界:桌面端壳会把宿主 cookie 注入 renderer 并向本路由转发任意路径,因此**UI 内的任意脚本(含第三方 UI 插件)与面板同权**;`dsh-app://app` 的 origin 豁免在当前宿主已不可达(桌面壳改用 `http://127.0.0.1:<port>` 开窗),保留该分支只是纵深防御。
132
+
129
133
  | 配置 | 匹配 | 说明 |
130
134
  |---|---|---|
131
135
  | `allowedHosts: ["app.example"]` | 该主机的任意端口 | 端口在代理侧固定时更宽松 |
@@ -162,6 +166,7 @@ memory → messages → session/catalog
162
166
  | `requireConnection` | `true` | 必须拿到宿主的 `connection.requestRejection` 才接受路由,否则一律 401 `E_UNAUTHORIZED`。见 §4.2 最后一行。 |
163
167
  | `requireSameOrigin` | `true` | 校验 `Origin`/`Sec-Fetch-Site`。 |
164
168
  | `allowNonLoopback` | `false` | 跳过 Host 回环校验。**打开它会让 `allowedHosts` 失效**,只在完全信任本机所有进程时使用。 |
169
+ | `requireOwnerPeer` | `false` | 仅宿主所有者可执行破坏性路由。需要对外暴露(代理/隧道)时打开;开启后非 owner 与「取不到 principal」都会被拒绝(fail-closed)。 |
165
170
  | `allowedHosts` | `[]` | 反代/自定义主机名白名单(`host` 或 `host:port`,最多 32 条)。暴露到 loopback 之外应只配置这一项。 |
166
171
  | `allowOrphanSessions` | `false` | 允许删除未登记到任何工作区的会话(默认按 `E_POLICY_ORPHAN` 拒绝)。 |
167
172
  | `allowSubagentSessions` | `false` | 允许删除子代理会话(默认按 `E_POLICY_SUBAGENT` 拒绝,避免拆掉正在工作的子会话)。 |
package/client.js CHANGED
@@ -222,12 +222,13 @@ window.__ModuleLoader__.load({
222
222
  'error.E_TRASH_CONFLICT': 'The entry is still in use (for example it still holds a conversation log) or its state does not allow this action.',
223
223
  'error.E_CONFIRM_REQUIRED': 'Reload the dialog and confirm again.',
224
224
  'error.E_FORBIDDEN_HOST': 'The Host only accepts deletion requests from loopback; set allowNonLoopback for remote access.',
225
- 'error.E_FORBIDDEN_ORIGIN': 'The Host rejected this page as an untrusted origin.',
226
- 'error.E_POLICY_FOREIGN_WRITER': 'Another DSH instance is still writing this conversation; close it there and retry.',
225
+ 'error.E_FORBIDDEN_ORIGIN': 'The Host rejected this page as an untrusted origin.', 'error.E_POLICY_FOREIGN_WRITER': 'Another DSH instance is still writing this conversation; close it there and retry.',
227
226
  'error.E_LOCK_BUSY': 'Another DSH instance is using this recycle bin; retry in a moment.',
228
227
  'error.E_QUEUE_TIMEOUT': 'The Host was still busy with another change, so this request was not applied. Retry in a moment.',
229
228
  'error.E_ABANDONED': 'The page gave up waiting before the Host started; nothing was changed. Retry.',
230
229
  'error.E_UNAUTHORIZED': 'This request is not authenticated with the Host.',
230
+ 'error.E_FORBIDDEN_PRINCIPAL': 'Only the Host owner may delete conversations on this deployment.',
231
+ 'error.E_PEER_UNKNOWN': 'This Host cannot report who is asking, so owner-only mode refused the request.',
231
232
  'dialog.inlineHint': 'This window has no overlay slot (dsh-go native UI), so the confirmation is shown here instead of in a popup.',
232
233
  'adapter.lingshu': 'Lingshu cognitive graph (md_cg)',
233
234
  'adapter.generic-json': 'Generic JSON memory store',
@@ -400,6 +401,8 @@ window.__ModuleLoader__.load({
400
401
  'error.E_QUEUE_TIMEOUT': '宿主还在处理另一个变更,这次请求没有执行,请稍后重试。',
401
402
  'error.E_ABANDONED': '页面在宿主开始处理前就放弃了等待,本次没有做任何改动,请重试。',
402
403
  'error.E_UNAUTHORIZED': '该请求未通过宿主的鉴权。',
404
+ 'error.E_FORBIDDEN_PRINCIPAL': '该部署只允许宿主所有者删除对话。',
405
+ 'error.E_PEER_UNKNOWN': '宿主无法报告请求方身份,仅所有者模式拒绝了该请求。',
403
406
  'dialog.inlineHint': '当前窗口没有 overlay 槽位(dsh-go 原生界面),确认框直接显示在这里。',
404
407
  'adapter.lingshu': '灵枢认知图 (md_cg)',
405
408
  'adapter.generic-json': '通用 JSON 记忆存储',
@@ -10,6 +10,7 @@
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
11
  "requireConnection": { "type": "boolean", "default": true, "description": "Refuse requests when the Host exposes no `connection` service. The loopback/origin fence cannot tell two local processes apart, so without a connection layer any process on this machine could delete a conversation. Set false only for a trusted composition that has no connection service." },
12
12
  "allowNonLoopback": { "type": "boolean", "default": false, "description": "Allow requests from any authority, not just loopback. Prefer allowedHosts when a reverse proxy presents a known hostname." },
13
+ "requireOwnerPeer": { "type": "boolean", "default": false, "description": "Owner-only mode for destructive routes. `requestRejection` only answers authenticated-or-not; a Host that authenticates non-owner peers (remote-access grants) would otherwise let them delete conversations once this route is exposed through a proxy (allowedHosts). When true, the plugin asks the connection layer for the requesting principal and refuses anything that is not the owner — including an unknown principal (fail-closed). Leave false on a loopback-only deployment." },
13
14
  "allowedHosts": {
14
15
  "type": "array", "items": { "type": "string" }, "maxItems": 32, "default": [],
15
16
  "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."
package/cordis.patch.yml CHANGED
@@ -28,6 +28,13 @@
28
28
  # every key the contract has.
29
29
  requireConnection: true
30
30
  allowNonLoopback: false
31
+ # Owner-only mode. `requestRejection` answers authenticated-or-not only;
32
+ # a Host that authenticates non-owner peers (remote-access grants) would
33
+ # otherwise let them delete conversations once this route is exposed
34
+ # through a proxy. When true the plugin asks the connection layer for the
35
+ # principal and refuses anything that is not the owner — including an
36
+ # unknown principal (fail-closed). Keep false on loopback-only setups.
37
+ requireOwnerPeer: false
31
38
  # Reverse-proxy deployments: list the exact authorities the proxy puts
32
39
  # in `Host` (host, host:port). A bare host admits any port. Listing them
33
40
  # is narrower than turning allowNonLoopback on for every hostname.
@@ -19,6 +19,9 @@ export const DEFAULTS = deepFreeze({
19
19
  // connection service and is trusted end to end.
20
20
  requireConnection: true,
21
21
  allowNonLoopback: false,
22
+ // Only the Host owner may run destructive routes. Opt-in because the peer
23
+ // principal shape is host-version specific; when on, "cannot tell" refuses.
24
+ requireOwnerPeer: false,
22
25
  // Exact authorities a prefix-stripping reverse proxy may present in `Host`
23
26
  // (`app.example`, `app.example:8443`, `[::1]:8443`). Empty = loopback only.
24
27
  // This exists so an operator behind a proxy does not have to switch
@@ -276,6 +279,7 @@ export function normalizeConfig(raw) {
276
279
  requireSameOrigin: readBool(source.requireSameOrigin, DEFAULTS.requireSameOrigin, 'requireSameOrigin', warnings),
277
280
  requireConnection: readBool(source.requireConnection, DEFAULTS.requireConnection, 'requireConnection', warnings),
278
281
  allowNonLoopback: readBool(source.allowNonLoopback, DEFAULTS.allowNonLoopback, 'allowNonLoopback', warnings),
282
+ requireOwnerPeer: readBool(source.requireOwnerPeer, DEFAULTS.requireOwnerPeer, 'requireOwnerPeer', warnings),
279
283
  allowedHosts: readHostList(source.allowedHosts, 'allowedHosts', warnings),
280
284
  allowOrphanSessions: readBool(source.allowOrphanSessions, DEFAULTS.allowOrphanSessions, 'allowOrphanSessions', warnings),
281
285
  allowSubagentSessions: readBool(source.allowSubagentSessions, DEFAULTS.allowSubagentSessions, 'allowSubagentSessions', warnings),
@@ -12,6 +12,10 @@ export const ERROR_STATUS = Object.freeze({
12
12
  E_DISABLED: 503,
13
13
  E_FORBIDDEN_ORIGIN: 403,
14
14
  E_FORBIDDEN_HOST: 403,
15
+ // Owner-only mode (`requireOwnerPeer`): an authenticated peer that is not the
16
+ // Host owner, and a peer whose principal the Host cannot report.
17
+ E_FORBIDDEN_PRINCIPAL: 403,
18
+ E_PEER_UNKNOWN: 503,
15
19
  E_UNAUTHORIZED: 401,
16
20
  E_BAD_REQUEST: 400,
17
21
  E_NOT_FOUND: 404,
@@ -60,7 +60,11 @@ export function redactDetails(value) {
60
60
  result[key] = typeof item === 'string' ? redactText(item) : redactDetails(item)
61
61
  continue
62
62
  }
63
- result[key] = typeof item === 'string' ? item : redactDetails(item)
63
+ // Every other string is redacted too. Only path-like substrings change, so
64
+ // this cannot alter a code, an id or a count — and it is what makes a
65
+ // diagnostic field like `reason` (the adapter's raw error text) safe
66
+ // without every caller having to know the redactor's key list.
67
+ result[key] = typeof item === 'string' ? redactText(item) : redactDetails(item)
64
68
  }
65
69
  return result
66
70
  }
package/lib/host/api.js CHANGED
@@ -1077,8 +1077,17 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
1077
1077
  async function state() {
1078
1078
  // Only wire fields the Client renders; adapter `storePath` is an absolute
1079
1079
  // filesystem handle and must not leave the Host.
1080
+ //
1081
+ // `reason` is the adapter's own diagnostic text and it is the one field
1082
+ // here that can carry a path: an unreadable index surfaces as
1083
+ // `ENOENT … C:\Users\<name>\.dsh\…`, or as EPERM/EBUSY with the file in
1084
+ // the message. `/state` is a SUCCESS response, so it never passed through
1085
+ // `redactDetails` — the plugin's own contract ("Host paths are redacted
1086
+ // before crossing HTTP") was violated exactly on the route that is readable
1087
+ // without a connection fence. Redact the whole payload at the boundary.
1080
1088
  const adapters = (await memoryService.status()).map(({ id, label, available, reason }) => ({ id, label, available, reason }))
1081
- return {
1089
+ const diagnostics = gateway.diagnostics()
1090
+ return redactDetails({
1082
1091
  enabled: config.enabled,
1083
1092
  version: PLUGIN_VERSION,
1084
1093
  // The Client cannot tell a broken deployment from a broken permission
@@ -1102,9 +1111,13 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
1102
1111
  // instead of using its generic request timeout.
1103
1112
  queueTimeoutMs: queueBudgetFor(TRASH_LOCK_BUDGET_MS),
1104
1113
  },
1114
+ // Why a title may be missing, without exposing a path: the counts and the
1115
+ // legacy-table verdict let the Client say "the Host's projection format
1116
+ // changed" instead of silently showing blank rows.
1117
+ diagnostics,
1105
1118
  ui: config.ui,
1106
1119
  feedback: feedbackService.status(),
1107
- }
1120
+ })
1108
1121
  }
1109
1122
 
1110
1123
  async function sessionsForUi() {
@@ -1458,6 +1471,51 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
1458
1471
  return connection !== undefined && typeof connection.requestRejection === 'function'
1459
1472
  }
1460
1473
 
1474
+ /**
1475
+ * Owner-only gate for destructive routes (`requireOwnerPeer: true`).
1476
+ *
1477
+ * `requestRejection` answers authenticated-or-not; it says nothing about
1478
+ * WHICH peer authenticated. Current Hosts can authenticate non-owner peers
1479
+ * (remote-access grants), so a deployment that exposes this route through a
1480
+ * reverse proxy (`allowedHosts`) would let such a peer delete conversations.
1481
+ *
1482
+ * The principal shape is host-version specific and could not be verified on
1483
+ * a live proxied deployment, so this gate is opt-in and fails CLOSED when it
1484
+ * is on: "I cannot tell who you are" is a refusal for a delete, not a pass.
1485
+ * With the flag off (the default) behaviour is exactly as before.
1486
+ * @returns {Promise<{ ok: true } | { ok: false, status: number, code: string, message: string }>}
1487
+ */
1488
+ async function ownerPeerVerdict(connection, req) {
1489
+ const admit = connection?.admit
1490
+ if (typeof admit !== 'function') {
1491
+ return {
1492
+ ok: false,
1493
+ status: 503,
1494
+ code: 'E_PEER_UNKNOWN',
1495
+ message: 'This Host does not report the requesting principal; owner-only mode refuses the request.',
1496
+ }
1497
+ }
1498
+ let peer
1499
+ try {
1500
+ peer = await admit.call(connection, req)
1501
+ } catch (error) {
1502
+ logger.error?.('[conversation-recycle-bin] principal check failed:', error)
1503
+ return { ok: false, status: 503, code: 'E_PEER_UNKNOWN', message: 'The Host could not report the requesting principal.' }
1504
+ }
1505
+ const principal = peer?.principal ?? peer
1506
+ const kind = typeof principal?.kind === 'string' ? principal.kind : typeof principal?.type === 'string' ? principal.type : null
1507
+ if (kind === null) {
1508
+ return { ok: false, status: 503, code: 'E_PEER_UNKNOWN', message: 'The Host returned no principal kind for this request.' }
1509
+ }
1510
+ if (kind === 'owner') return { ok: true }
1511
+ return {
1512
+ ok: false,
1513
+ status: 403,
1514
+ code: 'E_FORBIDDEN_PRINCIPAL',
1515
+ message: 'Only the Host owner may delete conversations on this deployment.',
1516
+ }
1517
+ }
1518
+
1461
1519
  /** `/state` (or the bare prefix) as a GET — the only route readable without a connection fence. */
1462
1520
  function isStateProbe(req) {
1463
1521
  if (String(req.method ?? 'GET').toUpperCase() !== 'GET') return false
@@ -1657,6 +1715,18 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
1657
1715
  sendJson(res, 503, toWireError(new DeleteError('E_INTERNAL', 'The Host could not verify this request.', { verdict: rejection })))
1658
1716
  return
1659
1717
  }
1718
+ // `requestRejection` only answers "authenticated or not". Current Hosts
1719
+ // also authenticate non-owner peers (remote-access grants), which is
1720
+ // enough for a proxy exposing this route through `allowedHosts` to let
1721
+ // such a peer delete conversations. Opt-in, and fail-closed when on.
1722
+ if (config.requireOwnerPeer === true) {
1723
+ const principal = await ownerPeerVerdict(connection, req)
1724
+ if (!principal.ok) {
1725
+ logger.warn?.(`[conversation-recycle-bin] owner-only mode refused ${principal.code}`)
1726
+ sendJson(res, principal.status, toWireError(new DeleteError(principal.code, principal.message, {})))
1727
+ return
1728
+ }
1729
+ }
1660
1730
  }
1661
1731
  const run = () => handleRequest(req, res)
1662
1732
  if (typeof ctx.waterfall === 'function') {
@@ -20,6 +20,10 @@ import { SESSION_ID_PATTERN } from '../core/ids.js'
20
20
 
21
21
  const LIST_TTL_MS = 1_500
22
22
  const STATE_TTL_MS = 1_500
23
+ /** The per-record projection format this plugin was written against. */
24
+ const PROJECTION_VERSION = 7
25
+ /** `unit.version` of the legacy bulk title table that `readTitles` trusts. */
26
+ const LEGACY_TITLES_VERSION = 3
23
27
 
24
28
  export function createSessionGateway({ ctx, paths, now = Date.now, logger = console }) {
25
29
  let listCache = null
@@ -33,6 +37,14 @@ export function createSessionGateway({ ctx, paths, now = Date.now, logger = cons
33
37
  let listGeneration = 0
34
38
  let stateCache = null
35
39
  let stateCachedAt = -Infinity
40
+ /**
41
+ * Why a row has no title must be visible, not silent: the previous
42
+ * `version !== 7 → null` rule turned a Host format bump into "every
43
+ * conversation is untitled" with no signal anywhere. These counters are
44
+ * reset per list build and published through `diagnostics()`.
45
+ */
46
+ let projectionStats = { total: 0, foreignVersion: 0, malformed: 0, unreadable: 0 }
47
+ let legacyTitles = 'unknown'
36
48
 
37
49
  const service = (name) => {
38
50
  try {
@@ -60,54 +72,101 @@ export function createSessionGateway({ ctx, paths, now = Date.now, logger = cons
60
72
  }
61
73
 
62
74
  /**
63
- * Legacy bulk title table (`storages/session_projcache.json`). Host 0.2.2+
64
- * no longer writes it — the per-session files are authoritative — so this is
65
- * a compatibility read for older layouts only and an empty map is the normal
66
- * outcome. Missing titles are filled from the per-session projection in
67
- * `loadList`.
75
+ * Legacy bulk title table (`storages/session_projcache.json`).
76
+ *
77
+ * Host 0.2.2+ stopped writing it when per-record checkpoints arrived, so the
78
+ * per-session files are authoritative and this table is a compatibility read
79
+ * for older layouts. Two rules keep it from becoming a source of stale data:
80
+ *
81
+ * - it is VERSION-PINNED (`unit.version`), because the row semantics are
82
+ * what that number describes. The observed file is `unit.version: 3`;
83
+ * anything else is skipped with a diagnostic instead of trusted;
84
+ * - it never outranks a per-record checkpoint — `buildList` asks for the
85
+ * bulk table only to fill rows the authoritative read could not resolve.
86
+ *
87
+ * Reading it unconditionally (pre-0.3.22) meant a conversation renamed after
88
+ * the migration kept showing its migration-era title forever.
68
89
  */
69
90
  async function readTitles() {
70
91
  try {
71
92
  const document = await readJsonFile(paths.projectionCacheFile)
93
+ const version = document?.unit?.version
94
+ if (version !== LEGACY_TITLES_VERSION) {
95
+ legacyTitles = version === undefined ? 'no-version' : `v${String(version)}`
96
+ return new Map()
97
+ }
72
98
  const sessions = document?.tables?.sessions
73
- if (sessions === null || typeof sessions !== 'object') return new Map()
99
+ if (sessions === null || typeof sessions !== 'object') {
100
+ legacyTitles = 'v3'
101
+ return new Map()
102
+ }
74
103
  const map = new Map()
75
104
  for (const [sessionId, record] of Object.entries(sessions)) {
76
105
  const title = record?.rows?.title?.val
77
106
  const blank = record?.rows?.sessionListMetadata?.val?.blank
78
- map.set(sessionId, { title: typeof title === 'string' ? title : '', blank: blank === true })
107
+ map.set(sessionId, {
108
+ title: typeof title === 'string' ? title : '',
109
+ blank: blank === true,
110
+ createdAt: typeof record?.identity?.createdAt === 'number' ? record.identity.createdAt : null,
111
+ })
79
112
  }
113
+ legacyTitles = 'v3'
80
114
  return map
81
- } catch {
115
+ } catch (error) {
116
+ legacyTitles = error?.code === 'ENOENT' ? 'absent' : 'unreadable'
82
117
  return new Map()
83
118
  }
84
119
  }
85
120
 
86
121
  /**
87
122
  * The authoritative projection checkpoint is the per-session file
88
- * (`storages/session_projcache/sessions/<id>.json`, format version 7); the
89
- * root `session_projcache.json` is a legacy domain dump and is intentionally
90
- * only a bulk fallback.
123
+ * (`storages/session_projcache/sessions/<id>.json`).
124
+ *
125
+ * 0.3.22 replaced the `version === 7` equality with a capability check. The
126
+ * old gate had two failure modes, both silent: a Host that bumped the format
127
+ * while keeping these fields cost every row its title, and the test suite had
128
+ * baked that degradation in as the EXPECTED result (so CI stayed green). The
129
+ * fields the plugin actually reads are self-describing (`rows.title.val`,
130
+ * `rows.sessionListMetadata.val`), so they are checked directly; an
131
+ * unexpected version that STILL satisfies the shape is read and counted in
132
+ * the diagnostics, while a shape mismatch degrades to "no projection".
91
133
  */
92
134
  async function readSessionProjection(sessionId) {
93
135
  const file = join(paths.storagesDir, 'session_projcache', 'sessions', `${sessionId}.json`)
136
+ let document
94
137
  try {
95
- const document = await readJsonFile(file)
96
- // Pin the format. A Host that bumps the projection version usually keeps
97
- // field names that LOOK compatible while meaning something else, and a
98
- // silent misread surfaces as a wrong title or blank flag in the UI.
99
- // Unknown versions degrade to "no projection" instead of trusting shape.
100
- if (document?.version !== 7) return null
101
- const rows = document?.record?.rows
102
- if (rows === null || typeof rows !== 'object') return null
103
- return {
104
- title: typeof rows.title?.val === 'string' ? rows.title.val : '',
105
- blank: rows.sessionListMetadata?.val?.blank === true,
106
- createdAt: document?.record?.identity?.createdAt ?? null,
107
- }
108
- } catch {
138
+ document = await readJsonFile(file)
139
+ } catch (error) {
140
+ if (error?.code !== 'ENOENT') projectionStats.unreadable += 1
141
+ return null
142
+ }
143
+ const rows = document?.record?.rows
144
+ if (rows === null || typeof rows !== 'object') {
145
+ projectionStats.malformed += 1
146
+ return null
147
+ }
148
+ const titleRow = rows.title
149
+ const blankRow = rows.sessionListMetadata
150
+ // At least one of the two rows this build reads must be present: a `rows`
151
+ // object made only of unknown keys is a different layout, not an empty one.
152
+ if (titleRow === undefined && blankRow === undefined) {
153
+ projectionStats.malformed += 1
154
+ return null
155
+ }
156
+ const titleShaped = titleRow === undefined || (titleRow !== null && typeof titleRow === 'object' && 'val' in titleRow)
157
+ const blankShaped = blankRow === undefined || (blankRow !== null && typeof blankRow === 'object' && 'val' in blankRow)
158
+ if (!titleShaped || !blankShaped) {
159
+ projectionStats.malformed += 1
109
160
  return null
110
161
  }
162
+ const version = document?.version
163
+ if (version !== PROJECTION_VERSION) projectionStats.foreignVersion += 1
164
+ return {
165
+ title: typeof titleRow?.val === 'string' ? titleRow.val : '',
166
+ blank: blankRow?.val?.blank === true,
167
+ createdAt: document?.record?.identity?.createdAt ?? null,
168
+ version,
169
+ }
111
170
  }
112
171
 
113
172
  async function workspaceEntities() {
@@ -183,18 +242,20 @@ export function createSessionGateway({ ctx, paths, now = Date.now, logger = cons
183
242
  const member = workspaceBySession.get(item.sessionId) ?? null
184
243
  const byCwd = member === null ? workspaceByCwd(item.cwd) : null
185
244
  const owned = member ?? (byCwd === null ? null : { workspace: byCwd, index: -1 })
186
- const cached = titles.get(item.sessionId) ?? { title: '', blank: item.blank === true }
187
245
  return {
188
246
  id: String(item.sessionId),
189
- title: cached.title,
247
+ // Left empty on purpose: the authoritative per-record projection below
248
+ // fills it, and the legacy bulk table is only a fallback. Seeding this
249
+ // from `titles` here is what made the stale value win.
250
+ title: '',
190
251
  cwd: item.cwd ?? null,
191
252
  origin: item.origin ?? null,
192
- blank: item.blank === true || cached.blank === true,
253
+ blank: item.blank === true,
193
254
  running: item.running === true,
194
255
  live: isLive(item.sessionId),
195
256
  archived: state.archived.has(item.sessionId),
196
257
  pinned: state.pinned.has(item.sessionId),
197
- createdAt: cached.createdAt ?? null,
258
+ createdAt: null,
198
259
  updatedAt: item.updatedAt ?? null,
199
260
  events: null,
200
261
  sizeBytes: null,
@@ -204,26 +265,33 @@ export function createSessionGateway({ ctx, paths, now = Date.now, logger = cons
204
265
  workspaceIndex: owned?.index ?? -1,
205
266
  }
206
267
  })
207
- // Host 0.2.2+ removed the bulk `session_projcache.json`, so `readTitles()`
208
- // above is usually empty and every row would render untitled. Fill the gaps
209
- // from the authoritative per-session projection, in small batches so a long
210
- // history does not open hundreds of descriptors at once.
211
- //
212
- // This is also the only place `createdAt` can come from: the legacy bulk
213
- // table does not carry it. `enrichTitles()` used to run a second pass over
214
- // exactly these rows (re-reading the same projection files) purely to fill
215
- // that field, and was capped at the first 80 rows; doing it here makes the
216
- // pass complete and removes the duplicate reads.
217
- const untitled = rows.filter((row) => row.title === '')
218
- for (let index = 0; index < untitled.length; index += 16) {
219
- await Promise.all(untitled.slice(index, index + 16).map(async (row) => {
268
+ // The per-record checkpoint is the AUTHORITATIVE source, so it is read for
269
+ // every row, not just the rows the legacy table failed to title. Reading it
270
+ // only for untitled rows is what let a migration-era title outlive a rename
271
+ // (the legacy row still carried a non-empty title, so the fresh one was
272
+ // never consulted). Batched so a long history does not open hundreds of
273
+ // descriptors at once.
274
+ projectionStats = { total: rows.length, foreignVersion: 0, malformed: 0, unreadable: 0 }
275
+ const legacyTitlesMap = titles
276
+ for (let index = 0; index < rows.length; index += 16) {
277
+ await Promise.all(rows.slice(index, index + 16).map(async (row) => {
220
278
  const projection = await readSessionProjection(row.id)
221
279
  if (projection === null) return
222
280
  if (projection.title !== '') row.title = projection.title
223
281
  row.blank = row.blank || projection.blank
224
- if (row.createdAt === null) row.createdAt = projection.createdAt ?? null
282
+ if (projection.createdAt !== null) row.createdAt = projection.createdAt
225
283
  }))
226
284
  }
285
+ // Compatibility fill for rows the authoritative read could not resolve
286
+ // (no checkpoint on disk at all, or a shape this build cannot read).
287
+ for (const row of rows) {
288
+ if (row.title !== '') continue
289
+ const cached = legacyTitlesMap.get(row.id)
290
+ if (cached === undefined) continue
291
+ if (cached.title !== '') row.title = cached.title
292
+ row.blank = row.blank || cached.blank === true
293
+ if (row.createdAt === null && cached.createdAt !== null) row.createdAt = cached.createdAt
294
+ }
227
295
  // A build that started before an `invalidate()` describes state the caller
228
296
  // deliberately discarded, so it must not install itself as the cache.
229
297
  if (generation === listGeneration) {
@@ -419,6 +487,20 @@ export function createSessionGateway({ ctx, paths, now = Date.now, logger = cons
419
487
  * inside the 1.5 s window), so its result must neither be installed nor be
420
488
  * what the next `inspect()` awaits.
421
489
  */
490
+ /**
491
+ * Host-contract health, published through `/state`.
492
+ *
493
+ * The point of this record is that a degraded title source must be
494
+ * OBSERVABLE. `foreignVersion` counts checkpoints that were readable but
495
+ * carried an unexpected format version; `malformed` counts shapes this
496
+ * build cannot read; `legacyTitles` says whether the bulk table was used
497
+ * (`v3`), absent, or skipped because its `unit.version` was not the one
498
+ * this build understands.
499
+ */
500
+ diagnostics() {
501
+ return { projection: { ...projectionStats }, legacyTitles }
502
+ },
503
+
422
504
  invalidate() {
423
505
  listGeneration += 1
424
506
  listInFlight = null
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.20'
8
+ export const PLUGIN_VERSION = '0.3.22'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-conversation-recycle-bin",
3
- "version": "0.3.20",
3
+ "version": "0.3.22",
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",
@@ -36,7 +36,7 @@
36
36
  ],
37
37
  "engines": {
38
38
  "node": ">=22.15",
39
- "dsh": ">=0.2.1-rc.2 <0.3.0-0"
39
+ "dsh": ">=0.2.1-rc.2 <0.3.0-0 || >=0.2.2-0 <0.3.0-0"
40
40
  },
41
41
  "optionalDependencies": {
42
42
  "nodemailer": "^10.1.2"
@@ -10,12 +10,17 @@
10
10
  *
11
11
  * Usage:
12
12
  * node scripts/verify-host-contract.mjs [baseUrl] [--token=...]
13
- * (baseUrl defaults to http://127.0.0.1:3082; --token defaults to $DSH_TOKEN)
13
+ * (baseUrl defaults to $DSH_WEB_URL, then http://127.0.0.1:3082;
14
+ * --token defaults to $DSH_TOKEN)
14
15
  *
15
16
  * Exit code: 0 = plugin mounted and answering, 1 = not mounted, 2 = probe error.
16
17
  */
17
18
  const args = process.argv.slice(2)
18
- let base = 'http://127.0.0.1:3082'
19
+ // `dsh web` prints its own URL with a fresh port (11685 on this machine), and a
20
+ // probe that keeps assuming 3082 reports "not mounted" for a perfectly healthy
21
+ // host. Prefer the explicit argument, then the environment, then the old
22
+ // default so existing instructions keep working.
23
+ let base = process.env.DSH_WEB_URL ?? 'http://127.0.0.1:3082'
19
24
  let token = process.env.DSH_TOKEN ?? ''
20
25
  for (const arg of args) {
21
26
  if (arg.startsWith('--token=')) token = arg.slice('--token='.length)
@@ -58,8 +63,11 @@ async function main() {
58
63
 
59
64
  // 2. The plugin's client row must appear in the inline boot data. A mounted
60
65
  // plugin always contributes its row; the 21:26 incident left this at 0.
66
+ // NOTE: the package NAME alone is not proof — it is also the dependency
67
+ // key in the profile's package.json, so it leaks into boot data that lists
68
+ // installed bundles. Step 5 below is the assertion that actually decides.
61
69
  const rowPresent = html.includes('dsh-conversation-recycle-bin')
62
- record('boot graph contains the plugin row', rowPresent, rowPresent ? 'found' : 'string absent from HTML')
70
+ record('package name present in boot data', rowPresent, rowPresent ? 'found' : 'string absent from HTML')
63
71
 
64
72
  // 3. A control prefix route proves the prefix mechanism itself works.
65
73
  const control = await fetch(`${base}/dsh-memes-api`, {
@@ -88,6 +96,35 @@ async function main() {
88
96
  `status ${state.status}, ${body.length} bytes${parsed === null ? ', not JSON' : ''}`,
89
97
  )
90
98
 
99
+ // 5. Mounted-for-real: the payload must carry the plugin's own shape. A 200
100
+ // with `{}` (a proxy, a stale service worker, another plugin's catch-all
101
+ // prefix) used to pass the check above.
102
+ const payload = parsed?.state
103
+ const shaped = payload !== null && typeof payload === 'object'
104
+ && typeof payload.version === 'string'
105
+ && typeof payload.limits === 'object'
106
+ && payload.limits !== null
107
+ record(
108
+ 'state payload carries the plugin contract (version + limits)',
109
+ shaped,
110
+ shaped ? `v${payload.version}` : 'missing state.version / state.limits',
111
+ )
112
+
113
+ // 6. Contract drift that costs the UI its titles is reported here instead of
114
+ // being discovered by a user staring at blank rows.
115
+ const diagnostics = payload?.diagnostics
116
+ if (diagnostics !== undefined && typeof diagnostics === 'object') {
117
+ const foreign = Number(diagnostics.projection?.foreignVersion ?? 0)
118
+ const malformed = Number(diagnostics.projection?.malformed ?? 0)
119
+ record(
120
+ 'projection checkpoints are a format this build reads',
121
+ foreign === 0 && malformed === 0,
122
+ `foreignVersion ${foreign}, malformed ${malformed}, legacyTitles ${String(diagnostics.legacyTitles)}`,
123
+ )
124
+ } else {
125
+ record('state reports projection diagnostics', false, 'state.diagnostics missing (older plugin build?)')
126
+ }
127
+
91
128
  const failed = results.filter((item) => !item.ok)
92
129
  if (failed.length > 0) {
93
130
  console.log(`\n${failed.length} check(s) failed. If the Host was just restarted, confirm the`)
package/test/api.test.mjs CHANGED
@@ -2084,3 +2084,79 @@ test('trash: stranded memory files offer, and accept, the forced discard', async
2084
2084
  await fixture.home.cleanup()
2085
2085
  }
2086
2086
  })
2087
+
2088
+ test('state: adapter diagnostics are redacted before they cross HTTP', async () => {
2089
+ // `/state` is a SUCCESS response, so it never went through `redactDetails`.
2090
+ // An unreadable memory index surfaces the raw ENOENT message, which carries
2091
+ // an absolute path — and `/state` is the one route that can be read without a
2092
+ // connection fence, so that path was reachable unauthenticated.
2093
+ const fixture = await makeFixture({
2094
+ configOverrides: {
2095
+ memory: {
2096
+ enabled: true,
2097
+ adapters: ['generic-json'],
2098
+ generic: { indexFile: 'C:\\Users\\someone\\.dsh\\storages\\nodes.json', rootDir: 'C:\\Users\\someone\\.dsh\\storages' },
2099
+ },
2100
+ },
2101
+ })
2102
+ try {
2103
+ const result = await call(fixture.api, '/state', { method: 'GET' })
2104
+ assert.equal(result.status, 200)
2105
+ const state = result.body.state ?? {}
2106
+ const reason = String(state.memory?.adapters?.[0]?.reason ?? '')
2107
+ // The username segment only survives if the raw ENOENT message reached the
2108
+ // wire; redaction keeps the basename (`nodes.json`) and drops the rest.
2109
+ assert.ok(!reason.includes('someone'), `adapter reason not redacted: ${reason}`)
2110
+ assert.equal(typeof state.diagnostics, 'object', `state keys: ${Object.keys(state).join(',')}`)
2111
+ } finally {
2112
+ await fixture.home.cleanup()
2113
+ }
2114
+ })
2115
+
2116
+ test('security: requireOwnerPeer refuses a non-owner peer', async () => {
2117
+ const nonOwner = { requestRejection: () => undefined, admit: () => ({ principal: { kind: 'guest' } }) }
2118
+ const fixture = await makeFixture({ connection: nonOwner, configOverrides: { requireOwnerPeer: true } })
2119
+ try {
2120
+ const preview = await call(fixture.api, '/preview', { body: { sessionIds: [S1], includeMemory: false, mode: 'soft' } })
2121
+ assert.equal(preview.status, 403)
2122
+ assert.equal(preview.body.error.code, 'E_FORBIDDEN_PRINCIPAL')
2123
+ } finally {
2124
+ await fixture.home.cleanup()
2125
+ }
2126
+ })
2127
+
2128
+ test('security: requireOwnerPeer admits the owner', async () => {
2129
+ const owner = { requestRejection: () => undefined, admit: async () => ({ principal: { kind: 'owner' } }) }
2130
+ const fixture = await makeFixture({ connection: owner, configOverrides: { requireOwnerPeer: true } })
2131
+ try {
2132
+ const result = await call(fixture.api, '/state', { method: 'GET' })
2133
+ assert.equal(result.status, 200)
2134
+ } finally {
2135
+ await fixture.home.cleanup()
2136
+ }
2137
+ })
2138
+
2139
+ test('security: requireOwnerPeer fails closed when the principal is unknown', async () => {
2140
+ // A connection layer that cannot report a principal must refuse: "I cannot
2141
+ // tell who you are" is not permission to delete.
2142
+ const opaque = { requestRejection: () => undefined }
2143
+ const fixture = await makeFixture({ connection: opaque, configOverrides: { requireOwnerPeer: true } })
2144
+ try {
2145
+ const result = await call(fixture.api, '/state', { method: 'GET' })
2146
+ assert.equal(result.status, 503)
2147
+ assert.equal(result.body.error.code, 'E_PEER_UNKNOWN')
2148
+ } finally {
2149
+ await fixture.home.cleanup()
2150
+ }
2151
+ })
2152
+
2153
+ test('security: requireOwnerPeer stays out of the way when it is off (default)', async () => {
2154
+ const nonOwner = { requestRejection: () => undefined, admit: () => ({ principal: { kind: 'guest' } }) }
2155
+ const fixture = await makeFixture({ connection: nonOwner })
2156
+ try {
2157
+ const preview = await call(fixture.api, '/preview', { body: { sessionIds: [S1], includeMemory: false, mode: 'soft' } })
2158
+ assert.equal(preview.status, 200, 'default behaviour must not change')
2159
+ } finally {
2160
+ await fixture.home.cleanup()
2161
+ }
2162
+ })
@@ -81,6 +81,32 @@ async function loadClient({ withReactDom = false } = {}) {
81
81
  return { exported, registrations, style, moduleId, body, portalCalls }
82
82
  }
83
83
 
84
+ /**
85
+ * Load the Client with an explicit active locale.
86
+ *
87
+ * `client.js` resolves the locale from the host's `locale` service and only
88
+ * then falls back to `navigator.language`, so a suite that just calls
89
+ * `loadClient()` silently follows whatever language the MACHINE is set to.
90
+ * That is how a Chinese literal in an assertion passed on a zh-CN laptop and
91
+ * failed on every English-locale CI runner. Anything that asserts user-visible
92
+ * text must pin the locale here instead.
93
+ */
94
+ async function loadClientWithLocale(localeId) {
95
+ const { exported } = await loadClient()
96
+ exported.apply({
97
+ slots: {
98
+ inject(key, callback) { return callback() ?? (() => {}) },
99
+ register() { return () => {} },
100
+ },
101
+ effect(fn) { fn() },
102
+ get(name) {
103
+ if (name === 'locale') return { getSnapshot: () => ({ active: localeId }) }
104
+ return undefined
105
+ },
106
+ })
107
+ return exported
108
+ }
109
+
84
110
  test('client: module id equals the package name the boot graph keys rows by', async () => {
85
111
  const { moduleId } = await loadClient()
86
112
  const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
@@ -1092,11 +1118,22 @@ test('client module: an incomplete entry offers purge when the Host allows it',
1092
1118
  })
1093
1119
 
1094
1120
  test('client module: the background-sweep notice carries how long ago it ran', async () => {
1095
- const { exported } = await loadClient()
1096
1121
  const now = Date.now()
1097
- assert.equal(exported.__formatAgo(now - 5_000, now), '刚刚')
1098
- assert.match(exported.__formatAgo(now - 30 * 60_000, now), /分钟前|min ago/)
1099
- assert.match(exported.__formatAgo(now - 3 * 3_600_000, now), /小时前|h ago/)
1100
- assert.match(exported.__formatAgo(now - 2 * 86_400_000, now), /天前|d ago/)
1101
- assert.equal(exported.__formatAgo(undefined, now), '')
1122
+ // Both languages, each with an explicitly installed locale service: a single
1123
+ // Chinese literal here (`'刚刚'`) made this assertion depend on the machine's
1124
+ // `navigator.language`, so it failed on every English-locale runner while
1125
+ // passing on the author's zh-CN laptop. Pinning both tables also proves the
1126
+ // relative-time wording exists on both sides.
1127
+ const cases = [
1128
+ ['en', { justNow: 'just now', minutes: /^30 min ago$/, hours: /^3 h ago$/, days: /^2 d ago$/ }],
1129
+ ['zh-CN', { justNow: '刚刚', minutes: /^30 分钟前$/, hours: /^3 小时前$/, days: /^2 天前$/ }],
1130
+ ]
1131
+ for (const [localeId, expected] of cases) {
1132
+ const client = await loadClientWithLocale(localeId)
1133
+ assert.equal(client.__formatAgo(undefined, now), '', `${localeId}: no timestamp renders nothing`)
1134
+ assert.equal(client.__formatAgo(now - 5_000, now), expected.justNow, `${localeId}: seconds`)
1135
+ assert.match(client.__formatAgo(now - 30 * 60_000, now), expected.minutes, `${localeId}: minutes`)
1136
+ assert.match(client.__formatAgo(now - 3 * 3_600_000, now), expected.hours, `${localeId}: hours`)
1137
+ assert.match(client.__formatAgo(now - 2 * 86_400_000, now), expected.days, `${localeId}: days`)
1138
+ }
1102
1139
  })
@@ -81,12 +81,81 @@ test('gateway: the per-session projection (v7) supplies the title when the legac
81
81
  })
82
82
  })
83
83
 
84
- test('gateway: an unknown projection version degrades to no title instead of misreading fields', async () => {
84
+ test('gateway: a bumped projection version with the same fields still supplies the title', async () => {
85
+ // 0.3.22 reversed the old `version !== 7 → give up` rule. A Host that bumps
86
+ // the format while keeping `rows.title.val` / `rows.sessionListMetadata.val`
87
+ // must NOT cost every row its title — the previous expectation baked that
88
+ // degradation in, so a real drift would have shipped green.
85
89
  await withFixture(8, async ({ paths, ctx }) => {
86
90
  const gateway = createSessionGateway({ ctx, paths, now: () => 1_700_000_000_200 })
87
91
  const rows = await gateway.list()
88
92
  assert.equal(rows.length, 1)
93
+ assert.equal(rows[0].title, 'Projection title')
94
+ // ...and the foreign version is reported instead of silently accepted.
95
+ assert.equal(gateway.diagnostics().projection.foreignVersion, 1)
96
+ })
97
+ })
98
+
99
+ test('gateway: an unreadable projection shape degrades to no title AND is reported', async () => {
100
+ await withFixture(8, async ({ paths, ctx }) => {
101
+ // Same version, different field names: the capability check must reject it.
102
+ await writeFile(join(paths.storagesDir, 'session_projcache', 'sessions', `${SESSION_ID}.json`),
103
+ JSON.stringify({ version: 8, record: { rows: { headline: { val: 'Renamed field' } } } }), 'utf8')
104
+ const gateway = createSessionGateway({ ctx, paths, now: () => 1_700_000_000_300 })
105
+ const rows = await gateway.list()
89
106
  assert.equal(rows[0].title, '')
107
+ assert.equal(gateway.diagnostics().projection.malformed, 1)
108
+ })
109
+ })
110
+
111
+ function legacyTable(version, title) {
112
+ return JSON.stringify({
113
+ unit: { name: 'session_projcache', version },
114
+ global: null,
115
+ tables: {
116
+ sessions: {
117
+ [SESSION_ID]: {
118
+ identity: { createdAt: 1_600_000_000_000, cwd: '/work/a' },
119
+ rows: { title: { val: title }, sessionListMetadata: { val: { blank: false } } },
120
+ },
121
+ },
122
+ },
123
+ })
124
+ }
125
+
126
+ test('gateway: a legacy bulk table whose unit.version is unknown is skipped, not trusted', async () => {
127
+ await withFixture(7, async ({ paths, ctx }) => {
128
+ // The legacy file is the FALLBACK, so remove the authoritative checkpoint
129
+ // to make the legacy read the only title source.
130
+ await rm(join(paths.storagesDir, 'session_projcache', 'sessions', `${SESSION_ID}.json`), { force: true })
131
+ await writeFile(join(paths.storagesDir, 'session_projcache.json'), legacyTable(99, 'Legacy title'), 'utf8')
132
+ const gateway = createSessionGateway({ ctx, paths, now: () => 1_700_000_000_500 })
133
+ const rows = await gateway.list()
134
+ assert.equal(rows[0].title, '')
135
+ assert.equal(gateway.diagnostics().legacyTitles, 'v99')
136
+ })
137
+ })
138
+
139
+ test('gateway: the per-record checkpoint outranks a stale legacy title', async () => {
140
+ await withFixture(7, async ({ paths, ctx }) => {
141
+ // The legacy dump is frozen at the migration moment; the conversation was
142
+ // renamed afterwards. The authoritative checkpoint must win.
143
+ await writeFile(join(paths.storagesDir, 'session_projcache.json'), legacyTable(3, 'Old name'), 'utf8')
144
+ const gateway = createSessionGateway({ ctx, paths, now: () => 1_700_000_000_600 })
145
+ const rows = await gateway.list()
146
+ assert.equal(rows[0].title, 'Projection title')
147
+ assert.equal(gateway.diagnostics().legacyTitles, 'v3')
148
+ })
149
+ })
150
+
151
+ test('gateway: the legacy bulk table still titles a row with no checkpoint on disk', async () => {
152
+ await withFixture(7, async ({ paths, ctx }) => {
153
+ await rm(join(paths.storagesDir, 'session_projcache', 'sessions', `${SESSION_ID}.json`), { force: true })
154
+ await writeFile(join(paths.storagesDir, 'session_projcache.json'), legacyTable(3, 'Legacy title'), 'utf8')
155
+ const gateway = createSessionGateway({ ctx, paths, now: () => 1_700_000_000_700 })
156
+ const rows = await gateway.list()
157
+ assert.equal(rows[0].title, 'Legacy title')
158
+ assert.equal(rows[0].createdAt, 1_600_000_000_000)
90
159
  })
91
160
  })
92
161