dsh-conversation-recycle-bin 0.3.21 → 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 +26 -0
- package/README.md +6 -1
- package/client.js +5 -2
- package/config.schema.json +1 -0
- package/cordis.patch.yml +7 -0
- package/lib/core/config.js +4 -0
- package/lib/core/errors.js +4 -0
- package/lib/core/redact.js +5 -1
- package/lib/host/api.js +72 -2
- package/lib/host/gateway.js +125 -43
- package/lib/version.js +1 -1
- package/package.json +2 -2
- package/scripts/verify-host-contract.mjs +40 -3
- package/test/api.test.mjs +76 -0
- package/test/contract.test.mjs +70 -1
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.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
|
+
|
|
6
32
|
## [0.3.21] — 2026-11-09
|
|
7
33
|
|
|
8
34
|
CI 第一次真正跑起来就抓到一条**只在中文机器上通过**的用例。0.3.20 发布后推送仓库,四个矩阵任务(ubuntu/windows × Node 22.15/24)全部在 `npm test` 阶段失败,失败点相同。
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
给 DeepSeek Harness (DSH) Web 增加「删除对话」:会话列表的「…」菜单与会话详情页头部各有一个删除入口;确认框内可勾选「同时删除关联记忆」(默认不勾选),并实时显示将被影响的记忆条数。删除默认进入插件回收站,可恢复;每次操作写入审计日志;批量删除有上限与限流,批量软删除需输入数量,彻底删除一键执行;事务任一步失败即回滚。
|
|
4
4
|
|
|
5
|
-
> 状态:`0.3.
|
|
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 记忆存储',
|
package/config.schema.json
CHANGED
|
@@ -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.
|
package/lib/core/config.js
CHANGED
|
@@ -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),
|
package/lib/core/errors.js
CHANGED
|
@@ -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,
|
package/lib/core/redact.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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') {
|
package/lib/host/gateway.js
CHANGED
|
@@ -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`).
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
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')
|
|
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, {
|
|
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
|
|
89
|
-
*
|
|
90
|
-
*
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
return
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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 (
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-conversation-recycle-bin",
|
|
3
|
-
"version": "0.3.
|
|
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;
|
|
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
|
-
|
|
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('
|
|
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
|
+
})
|
package/test/contract.test.mjs
CHANGED
|
@@ -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:
|
|
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
|
|