@xrn1997/dsh-session-delete 0.5.0 → 0.6.1

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.
Files changed (47) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +165 -141
  3. package/cordis.patch.yml +7 -3
  4. package/icon.svg +18 -18
  5. package/lib/client.js +749 -430
  6. package/lib/index.js +850 -484
  7. package/lib/types/api/attachment-orphans.d.ts +9 -3
  8. package/lib/types/api/attachment.d.ts +17 -7
  9. package/lib/types/api/connection-route.d.ts +23 -0
  10. package/lib/types/api/dispatch.d.ts +24 -17
  11. package/lib/types/api/route-table.d.ts +25 -9
  12. package/lib/types/client/api-base.d.ts +19 -0
  13. package/lib/types/client/batch-bar.d.ts +1 -1
  14. package/lib/types/client/batch-pick.d.ts +19 -0
  15. package/lib/types/client/batch-select.d.ts +7 -5
  16. package/lib/types/client/context-menu-actions.d.ts +47 -0
  17. package/lib/types/client/copy.d.ts +8 -0
  18. package/lib/types/client/delete-confirm.d.ts +2 -2
  19. package/lib/types/client/host-rows.d.ts +27 -32
  20. package/lib/types/client/index.d.ts +2 -0
  21. package/lib/types/client/main-view.d.ts +2 -1
  22. package/lib/types/client/maintenance-settings.d.ts +3 -1
  23. package/lib/types/client/pending-open.d.ts +17 -0
  24. package/lib/types/client/remote.d.ts +22 -19
  25. package/lib/types/client/store.d.ts +1 -7
  26. package/lib/types/client/trash-nav.d.ts +5 -1
  27. package/lib/types/client/trash-view.d.ts +3 -0
  28. package/lib/types/client/ui/MenuItemButton.d.ts +1 -1
  29. package/lib/types/client/ui/Toast.d.ts +4 -1
  30. package/lib/types/client/use-trash-panel-controller.d.ts +1 -1
  31. package/lib/types/config.d.ts +45 -0
  32. package/lib/types/deps.d.ts +8 -3
  33. package/lib/types/domain/closure.d.ts +3 -0
  34. package/lib/types/index.d.ts +18 -16
  35. package/lib/types/ports/attachments-port.d.ts +40 -0
  36. package/lib/types/ports/fs-port.d.ts +10 -0
  37. package/lib/types/ports/host-port.d.ts +4 -2
  38. package/lib/types/shared/wire.d.ts +18 -5
  39. package/lib/types/trash/attachments.d.ts +17 -2
  40. package/lib/types/verbs/errors.d.ts +3 -1
  41. package/lib/types/verbs/orphan-subagents.d.ts +3 -2
  42. package/lib/types/verbs/permanent-removal.d.ts +10 -5
  43. package/locale/en.json +6 -6
  44. package/locale/zh.json +6 -6
  45. package/package.json +122 -117
  46. package/lib/types/api/request-trust.d.ts +0 -45
  47. package/lib/types/ports/connection-port.d.ts +0 -33
package/lib/index.js CHANGED
@@ -1,7 +1,6 @@
1
1
  import path, { join, resolve } from "node:path";
2
- import zlib from "node:zlib";
3
- import { existsSync } from "node:fs";
4
2
  import { mkdir, readFile, readdir, realpath, rename, rm, stat } from "node:fs/promises";
3
+ import zlib from "node:zlib";
5
4
  import { homedir } from "node:os";
6
5
  //#region src/shared/wire.ts
7
6
  /**
@@ -12,13 +11,18 @@ import { homedir } from "node:os";
12
11
  * 信封 {ok:true,value} / {ok:false,error:{code,message,details?}} 也是两半的共同契约。
13
12
  */
14
13
  /**
15
- * 插件专属前缀。
14
+ * 插件专属前缀(九段相对它命名)。
16
15
  *
17
- * 为什么必须带插件名:宿主 web server 的 `register({kind:'prefix', path})` 对**重复的 (kind, path)
18
- * 直接抛错**(`dsh-host-webserver` 原文 `webserver: duplicate ${route.kind} route "${route.path}"`),
19
- * 撞了就是整棵插件树 boot 失败——所以这条路径是与生态的硬边界,不是显示好不好看的问题。
16
+ * 为什么必须带插件名:宿主 `connection.fetch.register` 对**重复的精确路径**直接抛错
17
+ * (`connection: exact Fetch route "…" is already registered`),撞了就是整棵插件树 boot 失败——
18
+ * 所以这条路径是与生态的硬边界,不是显示好不好看的问题。
20
19
  */
21
20
  const SESSION_DELETE_API_PREFIX = "/dsh-session-delete-api";
21
+ /**
22
+ * 唯一的取数基础路径:宿主 `/api` + 插件前缀。九段 exact Fetch 路由、客户端探测与附件 URL
23
+ * 都从它起;`SESSION_DELETE_API_PREFIX` 只作为段名的相对前缀与错误原文。
24
+ */
25
+ const OFFICIAL_SESSION_DELETE_API_PREFIX = `/api${SESSION_DELETE_API_PREFIX}`;
22
26
  /** 九个段名(相对前缀)。**每项 `{ path, method }` 两键**:某段用什么方法也是线上形状的一部分
23
27
  * (ADR-0003),家就在这一张表——路由记录(`src/api/route-table.ts`)逐行投影(`ROUTES.x.method`),
24
28
  * 浏览器半的 GET 段同样投影、写段把 `ROUTES.x.method` 交给 `src/client/remote.ts` 的 `post()`,
@@ -91,7 +95,14 @@ const PARAMS = {
91
95
  ids: "ids",
92
96
  attachmentId: "id",
93
97
  attachmentKind: "kind",
94
- attachmentName: "name"
98
+ attachmentName: "name",
99
+ /** 图片引用的媒体类型 / 宽高:只给宿主 `attachments.readImage` 做引用校验,不参与路径拼接。 */
100
+ attachmentMediaType: "mediaType",
101
+ /** 字节数:图片支与文件支都带——它是宿主读面的引用一部分,并先过 `maxAttachmentBytes` 闸
102
+ * (别为一张已知超限的图让宿主整份读出来)。 */
103
+ attachmentBytes: "bytes",
104
+ attachmentWidth: "width",
105
+ attachmentHeight: "height"
95
106
  };
96
107
  /**
97
108
  * 进程内判别「这已经是一次失败」——**按标记位认,不按 `instanceof`**(跨 realm / 跨 bundle 都不成立)。
@@ -146,6 +157,57 @@ function verbCodeOf(error) {
146
157
  /** 回收站目录名(相对宿主数据根)。Node 半拼路径,浏览器半在页脚念它——同一个字面量。 */
147
158
  const TRASH_DIR_NAME = "session-trash";
148
159
  //#endregion
160
+ //#region src/verbs/errors.ts
161
+ /** 失败的**结构与措辞**。码本身、码的前缀读法与动词词表都住在 `shared/wire.ts`(ADR-0003):
162
+ * 浏览器半也要认它们,它们属于两半共同的契约,不属于 Node 半。这里只做动词抛错与回滚措辞。 */
163
+ function verbError(code, message, cause, details) {
164
+ const e = new Error(message);
165
+ e.code = SESSION_DELETE_CODES[code];
166
+ if (cause !== void 0) e.cause = cause;
167
+ if (details !== void 0) e.details = details;
168
+ return e;
169
+ }
170
+ /** `trash-empty` 的原文:有具体条目就点名那个 id,拿不到时只报通用那句。 */
171
+ function trashEmpty(entryId) {
172
+ return verbError("trash-empty", entryId === void 0 ? "回收站里没有这一条" : `回收站里没有这一条:${entryId}`);
173
+ }
174
+ /** 路由自检用的失败:带 HTTP 状态语义,**不**冒充五个动词码。 */
175
+ function routeError(code, httpStatus, message) {
176
+ const e = verbError("io", message);
177
+ e.code = SESSION_DELETE_CODES[code];
178
+ e.httpStatus = httpStatus;
179
+ return e;
180
+ }
181
+ /** 一条回滚步骤失败的**标签句**:label 说的是"这一步在退什么",原文带出**那一步抛的东西**
182
+ * (`Error` 取 message,其余一律 `String()`;对象带 `message` 的差异归 `map-error.messageOf` 管)。
183
+ * 调用方不许另抄这份格式——那样 details 里的句子会随各处 catch 变形,界面上就变成"同一个失败三个说法"。 */
184
+ function rollbackReason(label, error) {
185
+ return `${label}:${error instanceof Error ? error.message : String(error)}`;
186
+ }
187
+ /** 内层错误已经挂着的回滚失败(成员自己的就地回滚失败时会有)。组级 catch 再包一次时不能丢。 */
188
+ function carriedRollbackFailures(error) {
189
+ const raw = error?.details?.rollbackFailures;
190
+ return Array.isArray(raw) ? raw.filter((item) => typeof item === "string") : [];
191
+ }
192
+ /** 原错误保留,回滚失败作为 `details.rollbackFailures` 上线;码仍按原错误的动词码,
193
+ * 没有可继承的就落 io。**内层的回滚失败要并进来**:一个成员自己退失败、兄弟也退失败时,
194
+ * 两份失败都得在 details 里,消息也不能出现第二个「回滚未完成」。
195
+ * **原错误 details 的其余键原样保留**:孤儿清理的部分回执(`removed` / `freedBytes` /
196
+ * `failedSessionId`)就挂在它上面,整份替换会把已经发生的删除报成 0。 */
197
+ function withRollbackFailures(error, failures) {
198
+ const code = verbCodeOf(error) ?? "io";
199
+ const carried = carriedRollbackFailures(error);
200
+ const merged = [.../* @__PURE__ */ new Set([...carried, ...failures])];
201
+ const base = error instanceof Error ? error.message : String(error);
202
+ const extra = failures.filter((failure) => !carried.includes(failure));
203
+ const message = base.includes(";回滚未完成:") ? extra.length > 0 ? `${base};${extra.join(";")}` : base : `${base};回滚未完成:${merged.join(";")}`;
204
+ const priorDetails = error?.details;
205
+ return verbError(code, message, error, {
206
+ ...priorDetails,
207
+ rollbackFailures: merged
208
+ });
209
+ }
210
+ //#endregion
149
211
  //#region src/remote/map-error.ts
150
212
  /** 宿主两条类型化错误 → 我们的码(本文件的两行映射)。
151
213
  * 类不可 import(跨插件值 import 是红线;且宿主包与运行中的宿主不同代)⇒ 按官方构造器写死的 `this.name` 认。
@@ -266,134 +328,6 @@ function toRemoteFailure(error) {
266
328
  };
267
329
  }
268
330
  //#endregion
269
- //#region src/verbs/errors.ts
270
- /** 失败的**结构与措辞**。码本身、码的前缀读法与动词词表都住在 `shared/wire.ts`(ADR-0003):
271
- * 浏览器半也要认它们,它们属于两半共同的契约,不属于 Node 半。这里只做动词抛错与回滚措辞。 */
272
- function verbError(code, message, cause, details) {
273
- const e = new Error(message);
274
- e.code = SESSION_DELETE_CODES[code];
275
- if (cause !== void 0) e.cause = cause;
276
- if (details !== void 0) e.details = details;
277
- return e;
278
- }
279
- /** `trash-empty` 的原文:有具体条目就点名那个 id,拿不到时只报通用那句。 */
280
- function trashEmpty(entryId) {
281
- return verbError("trash-empty", entryId === void 0 ? "回收站里没有这一条" : `回收站里没有这一条:${entryId}`);
282
- }
283
- /** 路由自检用的失败:带 HTTP 状态语义,**不**冒充五个动词码。 */
284
- function routeError(code, httpStatus, message) {
285
- const e = verbError("io", message);
286
- e.code = SESSION_DELETE_CODES[code];
287
- e.httpStatus = httpStatus;
288
- return e;
289
- }
290
- /** 一条回滚步骤失败的**标签句**:label 说的是"这一步在退什么",原文带出**那一步抛的东西**
291
- * (`Error` 取 message,其余一律 `String()`;对象带 `message` 的差异归 `map-error.messageOf` 管)。
292
- * 调用方不许另抄这份格式——那样 details 里的句子会随各处 catch 变形,界面上就变成"同一个失败三个说法"。 */
293
- function rollbackReason(label, error) {
294
- return `${label}:${error instanceof Error ? error.message : String(error)}`;
295
- }
296
- /** 内层错误已经挂着的回滚失败(成员自己的就地回滚失败时会有)。组级 catch 再包一次时不能丢。 */
297
- function carriedRollbackFailures(error) {
298
- const raw = error?.details?.rollbackFailures;
299
- return Array.isArray(raw) ? raw.filter((item) => typeof item === "string") : [];
300
- }
301
- /** 原错误保留,回滚失败作为 `details.rollbackFailures` 上线;码仍按原错误的动词码,
302
- * 没有可继承的就落 io。**内层的回滚失败要并进来**:一个成员自己退失败、兄弟也退失败时,
303
- * 两份失败都得在 details 里,消息也不能出现第二个「回滚未完成」。 */
304
- function withRollbackFailures(error, failures) {
305
- const code = verbCodeOf(error) ?? "io";
306
- const carried = carriedRollbackFailures(error);
307
- const merged = [.../* @__PURE__ */ new Set([...carried, ...failures])];
308
- const base = error instanceof Error ? error.message : String(error);
309
- const extra = failures.filter((failure) => !carried.includes(failure));
310
- return verbError(code, base.includes(";回滚未完成:") ? extra.length > 0 ? `${base};${extra.join(";")}` : base : `${base};回滚未完成:${merged.join(";")}`, error, { rollbackFailures: merged });
311
- }
312
- //#endregion
313
- //#region src/api/request-trust.ts
314
- /** 桌面壳那条路(asar `/lib/main.js` 的 `forwardWebRequest`):渲染进程从 `dsh-app://app/` 加载,
315
- * 转发前把 `host` / `origin` / `cookie` / `sec-fetch-site` 删掉、换上一枚宿主自己的 cookie,
316
- * `Referer` 保留。判据按 `protocol` + `host` 比,**不能比 `URL.origin`**(`dsh-app:` 不是 WHATWG
317
- * special scheme,`new URL('dsh-app://app/').origin` 是字符串 `"null"`)。 */
318
- const DESKTOP_APP_SCHEME = "dsh-app:";
319
- const DESKTOP_APP_HOST = "app";
320
- /** 回环 socket 的三种写法(IPv4 / IPv6 / IPv4-mapped IPv6)。 */
321
- const LOOPBACK_SOCKETS = [
322
- "127.0.0.1",
323
- "::1",
324
- "::ffff:127.0.0.1"
325
- ];
326
- function isDesktopShellUrl(value) {
327
- try {
328
- const url = new URL(value);
329
- return url.protocol === DESKTOP_APP_SCHEME && url.host === DESKTOP_APP_HOST;
330
- } catch {
331
- return false;
332
- }
333
- }
334
- /** 裸 authority(`host` / `host:port`)按给定 scheme 解析;解析不了回 `undefined`。 */
335
- function parseAuthority(authority, scheme = "http:") {
336
- try {
337
- return new URL(`${scheme}//${authority}`);
338
- } catch {
339
- return;
340
- }
341
- }
342
- /** 与宿主 `isLoopbackHostname` 同口径:`localhost`、`[::1]`、任意 127/8。 */
343
- function isLoopbackHostname(hostname) {
344
- if (hostname === "localhost" || hostname === "[::1]") return true;
345
- const parts = hostname.split(".");
346
- return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
347
- }
348
- /** Host 是不是本部署的 bind 字面量:忽略端口与 `%zone`(zone 是接口选择器,不是地址文本)。 */
349
- function isBindAddress(hostUrl, bindHost) {
350
- if (bindHost === void 0) return false;
351
- const zoneAt = bindHost.indexOf("%");
352
- const address = zoneAt === -1 ? bindHost : bindHost.slice(0, zoneAt);
353
- const bindUrl = parseAuthority(address.includes(":") ? `[${address}]` : address);
354
- return bindUrl !== void 0 && bindUrl.hostname === hostUrl.hostname;
355
- }
356
- /** 携带 authority 的头(Origin / Referer)是否与 Host 同 authority:按这个头自己的 scheme 归一
357
- * 默认端口(`http://x:80` 与 Host `x` 是同一个 authority)。 */
358
- function isSameAuthority(value, hostHeader) {
359
- let url;
360
- try {
361
- url = new URL(value);
362
- } catch {
363
- return false;
364
- }
365
- const scheme = url.protocol === "https:" ? "https:" : url.protocol === "http:" ? "http:" : void 0;
366
- if (scheme === void 0) return false;
367
- const hostUrl = parseAuthority(hostHeader, scheme);
368
- return hostUrl !== void 0 && url.host === hostUrl.host;
369
- }
370
- /** 头栅栏:`sec-fetch-site` / Origin / Referer 三条,任何客户端都要过。 */
371
- function passesHeaderFence(headers, host) {
372
- if (headers["sec-fetch-site"] === "cross-site") return false;
373
- const origin = headers.origin;
374
- if (origin !== void 0 && !isDesktopShellUrl(origin) && !isSameAuthority(origin, host)) return false;
375
- const referer = headers.referer;
376
- if (referer !== void 0 && !isDesktopShellUrl(referer) && !isSameAuthority(referer, host)) return false;
377
- return true;
378
- }
379
- /**
380
- * 这条请求归不归本插件办。`untrusted` = 归属不对(跨源 / 重绑 / 头不成形);`unauthenticated` =
381
- * 归属对但远程客户端没带宿主身份(原文会提示用 `dsh web` 打印的 URL 打开)。
382
- *
383
- * 远程支的两个失败裁决由端口翻译宿主的区分(401 = 缺身份、403 = 归属不对)。身份层缺席(端口报
384
- * `absent`)一律按归属不对处理:那时没有可换的身份,不能让原文提示用户去补一个不存在的东西。
385
- */
386
- function judgeRequest(req, facts) {
387
- const host = req.headers.host;
388
- if (host === void 0 || !passesHeaderFence(req.headers, host)) return "untrusted";
389
- const hostUrl = parseAuthority(host);
390
- if (hostUrl === void 0) return "untrusted";
391
- if ((isLoopbackHostname(hostUrl.hostname) || isBindAddress(hostUrl, facts.bindHost)) && LOOPBACK_SOCKETS.includes(req.socket.remoteAddress ?? "")) return "allowed";
392
- const verdict = facts.connection.verdictFor(req);
393
- if (verdict.kind === "allowed") return "allowed";
394
- return verdict.kind === "unauthenticated" ? "unauthenticated" : "untrusted";
395
- }
396
- //#endregion
397
331
  //#region src/domain/closure.ts
398
332
  /** 会话 id 去掉 `session-` 前缀后的形状:同一个会话在两代宿主里写法不同(v3 代 id 不带前缀、
399
333
  * 现行代带),归堆、成组、去重一律按这个形状比——两边都归一,不赌调用方传的是哪一代写法。 */
@@ -452,6 +386,9 @@ function firstByBare(members) {
452
386
  * 已被前一组收走的那条,连同它整支子树都不再出现在这一组里——后代在那一组已被全部认领,所以
453
387
  * 「丢掉已认领的」与「不走进已认领的那支」交回的是同一个集合。环上的代价是「少数一条」,
454
388
  * 不是把一次扫描挂死。组序与成员序都按语料原序。
389
+ *
390
+ * 后代也必须是 subagent 起源:普通 fork 的 `parentSession` 只是来源记录,它不进组、也不穿过它收孩子
391
+ * (否则从 subagent 分出去的分支会被当成孤儿永久删掉)。
455
392
  */
456
393
  function orphanRootsOf(corpus, known) {
457
394
  const knownBares = /* @__PURE__ */ new Set();
@@ -460,6 +397,7 @@ function orphanRootsOf(corpus, known) {
460
397
  for (const session of corpus) {
461
398
  const parent = session.parentSessionId;
462
399
  if (parent === void 0) continue;
400
+ if (!session.subagent) continue;
463
401
  const key = bareSessionId(parent);
464
402
  const list = children.get(key);
465
403
  if (list === void 0) children.set(key, [session]);
@@ -990,6 +928,92 @@ async function memberMove(deps, presence, member, deletedAt, parentEntryId) {
990
928
  };
991
929
  }
992
930
  //#endregion
931
+ //#region src/ports/fs-port.ts
932
+ /**
933
+ * **缺席**只认 ENOENT 与 ENOTDIR("路径中间有一截不是目录"与"不存在"是同一种回答)。
934
+ *
935
+ * EACCES / EIO / ELOOP / 断挂载**不是缺席**:这些读数说明"我们看不见",不是"那里没有"。
936
+ * 破坏性动作(删目录、删附件对象、按"没有引用"清共享图)据此做决定,把读不出折成没有
937
+ * 就是数据消失;ADR-0039 是这条判据的裁决。
938
+ */
939
+ function isAbsentPath(error) {
940
+ const code = error?.code;
941
+ return code === "ENOENT" || code === "ENOTDIR";
942
+ }
943
+ /** 模块级递归:端口方法**解构出来单独调**也不能靠 `this`(`{ sizeOfDir } = port` 会让方法体的
944
+ * `this` 变成 undefined)。所以端口只做转发,递归在模块作用域里自调。 */
945
+ async function sizeOfDir(p) {
946
+ let total = 0;
947
+ for (const e of await readdir(p, { withFileTypes: true })) {
948
+ const child = join(p, e.name);
949
+ total += e.isDirectory() ? await sizeOfDir(child) : (await stat(child)).size;
950
+ }
951
+ return total;
952
+ }
953
+ /** 路径在不在:只有缺席两种错误回 `false`,其余读错误照抛。`exists` 与 `rename` 的目标检查共用。 */
954
+ async function pathPresent(p) {
955
+ try {
956
+ await stat(p);
957
+ return true;
958
+ } catch (error) {
959
+ if (isAbsentPath(error)) return false;
960
+ throw error;
961
+ }
962
+ }
963
+ function createFsPort() {
964
+ return {
965
+ exists: pathPresent,
966
+ async ensureDir(p) {
967
+ await mkdir(p, { recursive: true });
968
+ },
969
+ async rename(from, to) {
970
+ if (await pathPresent(to)) throw new Error(`目标已存在,拒绝覆盖:${to}`);
971
+ try {
972
+ await rename(from, to);
973
+ } catch (error) {
974
+ if (error.code === "EXDEV") throw new Error(`跨文件系统搬不动(sessions 与 session-trash 需在同一卷):${from} → ${to}`, { cause: error });
975
+ throw error;
976
+ }
977
+ },
978
+ async removeDir(p) {
979
+ await rm(p, {
980
+ recursive: true,
981
+ force: true
982
+ });
983
+ },
984
+ sizeOfDir: (p) => sizeOfDir(p),
985
+ async listFiles(p) {
986
+ return readdir(p);
987
+ },
988
+ async listDirs(p) {
989
+ const dirs = [];
990
+ for (const entry of await readdir(p, { withFileTypes: true })) {
991
+ if (entry.isDirectory()) {
992
+ dirs.push(entry.name);
993
+ continue;
994
+ }
995
+ if (!entry.isSymbolicLink()) continue;
996
+ try {
997
+ if ((await stat(join(p, entry.name))).isDirectory()) dirs.push(entry.name);
998
+ } catch {}
999
+ }
1000
+ return dirs;
1001
+ },
1002
+ async realpath(p) {
1003
+ return realpath(p);
1004
+ },
1005
+ async readFile(p) {
1006
+ return readFile(p);
1007
+ },
1008
+ async statFile(p) {
1009
+ return { size: (await stat(p)).size };
1010
+ },
1011
+ async removeFile(p) {
1012
+ await rm(p, { force: true });
1013
+ }
1014
+ };
1015
+ }
1016
+ //#endregion
993
1017
  //#region src/log/zstd-frames.ts
994
1018
  const MAGIC = [
995
1019
  40,
@@ -1234,10 +1258,10 @@ function isRecord(value) {
1234
1258
  function numberOf(value) {
1235
1259
  return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
1236
1260
  }
1237
- /** `statFile` / `readFile` / `sizeOfDir` 里只有 ENOENT 能当"不在";权限、盘挂、I/O 错误都必须露成读不出。 */
1238
- function isMissing(error) {
1239
- return error?.code === "ENOENT";
1240
- }
1261
+ /** 路径缺席的判据只有一处:`src/ports/fs-port.ts` 的 `isAbsentPath`(ENOENT / ENOTDIR)。
1262
+ * `statFile` / `readFile` / `sizeOfDir` 的读错误里,"不在"只认这两枚;权限、盘挂、I/O 错误
1263
+ * 都必须露成读不出(ADR-0039),别在这里另写一份只有 ENOENT 的抄本。 */
1264
+ const isMissing = isAbsentPath;
1241
1265
  /** 合法附件 ID → 宿主附件库里的绝对路径。`name` 不参与拼接。 */
1242
1266
  function attachmentPathOf(home, kind, id) {
1243
1267
  if (!isAttachmentId(id)) throw new Error(`附件 id 形状不对:${id}`);
@@ -1415,8 +1439,6 @@ async function imageIdsOfDir(deps, dir) {
1415
1439
  };
1416
1440
  }
1417
1441
  }
1418
- /** 单个 JSON 引用源的扫描上限;超过就记成不可读(fail closed),**不许当作"没有引用"跳过**。 */
1419
- const MAX_JSON_REF_BYTES = 10485760;
1420
1442
  /** 读全部剩余引用源;任何一处读不出都记进 `unreadable`。 */
1421
1443
  async function collectRemainingImageRefs(deps, input) {
1422
1444
  const exclude = new Set(input.excludeDirs);
@@ -1439,6 +1461,7 @@ async function collectRemainingImageRefs(deps, input) {
1439
1461
  if (await deps.fs.exists(trashRoot)) for (const name of await deps.fs.listDirs(trashRoot)) await visit(`${trashRoot}/${name}`);
1440
1462
  const scanJson = async (root) => {
1441
1463
  if (!await deps.fs.exists(root)) return;
1464
+ const maxJsonRefBytes = deps.config.maxJsonRefBytes;
1442
1465
  const stack = [root];
1443
1466
  const seen = /* @__PURE__ */ new Set();
1444
1467
  while (stack.length > 0) {
@@ -1452,8 +1475,8 @@ async function collectRemainingImageRefs(deps, input) {
1452
1475
  if (!name.endsWith(".json")) continue;
1453
1476
  const file = `${dir}/${name}`;
1454
1477
  const stat = await deps.fs.statFile(file);
1455
- if (stat.size > MAX_JSON_REF_BYTES) {
1456
- unreadable.push(`${file}: JSON 引用源 ${stat.size} 字节超过扫描上限 ${MAX_JSON_REF_BYTES} 字节,未扫描`);
1478
+ if (stat.size > maxJsonRefBytes) {
1479
+ unreadable.push(`${file}: JSON 引用源 ${stat.size} 字节超过扫描上限 ${maxJsonRefBytes} 字节,未扫描`);
1457
1480
  continue;
1458
1481
  }
1459
1482
  const text = (await deps.fs.readFile(file)).toString("utf8");
@@ -1472,6 +1495,32 @@ async function collectRemainingImageRefs(deps, input) {
1472
1495
  };
1473
1496
  }
1474
1497
  /**
1498
+ * 剩余引用扫描的 fail-closed 收口(独占计划 / 执行期复核 / 孤儿扫描三处共用同一形状):扫描抛错或
1499
+ * 任一引用源读不出都回 `reason`,读得出才交引用集——「读不出」绝不折成「没有引用」(ADR-0020;
1500
+ * 缺席判据见 ADR-0039)。`reasonLabel` 只补"这次是为什么扫"的前半句,后半句是读数原文。
1501
+ */
1502
+ async function remainingRefsFailClosed(deps, excludeDirs, reasonLabel) {
1503
+ try {
1504
+ const refs = await collectRemainingImageRefs(deps, {
1505
+ sessionRoot: `${deps.home}/sessions`,
1506
+ excludeDirs
1507
+ });
1508
+ if (refs.unreadable.length > 0) return {
1509
+ ok: false,
1510
+ reason: `${reasonLabel}:${refs.unreadable[0]}`
1511
+ };
1512
+ return {
1513
+ ok: true,
1514
+ refs
1515
+ };
1516
+ } catch (error) {
1517
+ return {
1518
+ ok: false,
1519
+ reason: `${reasonLabel}:${String(error)}`
1520
+ };
1521
+ }
1522
+ }
1523
+ /**
1475
1524
  * 独占计划:候选 = 本次被删会话日志里的图片 id;剩余引用 = 活动会话 + 全部未删 `session-trash`
1476
1525
  * 目录 + storages/cache。
1477
1526
  * 剩余引用源读不出 ⇒ 整体跳过;候选日志读不出 / 附件对象 stat 失败 ⇒ 只跳过那一部分,其余照常。
@@ -1494,24 +1543,13 @@ async function planImageAttachmentRemoval(deps, removedDirs) {
1494
1543
  bytes: 0,
1495
1544
  ...skipReasons.length > 0 ? { skipped: skipReasons.join(";") } : {}
1496
1545
  };
1497
- let remaining;
1498
- try {
1499
- remaining = await collectRemainingImageRefs(deps, {
1500
- sessionRoot: `${deps.home}/sessions`,
1501
- excludeDirs: removedDirs
1502
- });
1503
- } catch (error) {
1504
- return {
1505
- entries: [],
1506
- bytes: 0,
1507
- skipped: `读不到剩余引用源,无法证明独占:${String(error)}`
1508
- };
1509
- }
1510
- if (remaining.unreadable.length > 0) return {
1546
+ const scanned = await remainingRefsFailClosed(deps, removedDirs, "读不到剩余引用源,无法证明独占");
1547
+ if (!scanned.ok) return {
1511
1548
  entries: [],
1512
1549
  bytes: 0,
1513
- skipped: `读不到剩余引用源,无法证明独占:${remaining.unreadable[0]}`
1550
+ skipped: scanned.reason
1514
1551
  };
1552
+ const remaining = scanned.refs;
1515
1553
  const entries = [];
1516
1554
  let bytes = 0;
1517
1555
  let unreadableObjects = 0;
@@ -1538,6 +1576,31 @@ async function planImageAttachmentRemoval(deps, removedDirs) {
1538
1576
  };
1539
1577
  }
1540
1578
  /**
1579
+ * 执行期复核:计划与真删对象之间隔着一整趟目录删除(秒级),这期间别的会话可能把同一张图
1580
+ * (内容寻址 ⇒ 同一个 sha256 id)再引用一次,或派生存储新写一条引用。计划是数据、会过期;
1581
+ * 删对象之前用同一套剩余引用扫描再判一次,**只删这一刻仍无引用的那些**(ADR-0041)。
1582
+ *
1583
+ * 调用时机:会话 / 回收站目录已经删掉之后、`executeImageAttachmentRemoval` 之前——那时被删的
1584
+ * 引用源已经不在了,扫到的就是真正的"剩余"。读不出照旧 fail closed:回空计划 + `skipped`,
1585
+ * 宁可留下孤儿也不删共享。空计划直接返回(不为零删付一次全 home 扫描)。
1586
+ */
1587
+ async function recheckImageRemovalExclusivity(deps, plan) {
1588
+ if (plan.entries.length === 0) return plan;
1589
+ const scanned = await remainingRefsFailClosed(deps, [], "读不到剩余引用源,无法复核独占");
1590
+ if (!scanned.ok) return {
1591
+ entries: [],
1592
+ bytes: 0,
1593
+ skipped: plan.skipped === void 0 ? scanned.reason : `${plan.skipped};${scanned.reason}`
1594
+ };
1595
+ const remaining = scanned.refs;
1596
+ const entries = plan.entries.filter((entry) => !remaining.ids.has(entry.id));
1597
+ return {
1598
+ entries,
1599
+ bytes: entries.reduce((total, entry) => total + entry.bytes, 0),
1600
+ ...plan.skipped === void 0 ? {} : { skipped: plan.skipped }
1601
+ };
1602
+ }
1603
+ /**
1541
1604
  * 逐文件删;ENOENT 由端口的 `force` 吞掉;其他错误计数并继续。
1542
1605
  *
1543
1606
  * 删除动作只认「`home` + `id` 自己推导出来的那一条路径」:计划是数据,手搭的/被改过的条目
@@ -1590,24 +1653,13 @@ const OBJECT_NAME = /^[0-9a-f]{64}$/u;
1590
1653
  * 对象列表或 stat 读不出同样整份跳过:报告宁可失败,也不给一份"少算了一些"的清理计划。
1591
1654
  */
1592
1655
  async function scanOrphanImageAttachments(deps) {
1593
- let remaining;
1594
- try {
1595
- remaining = await collectRemainingImageRefs(deps, {
1596
- sessionRoot: `${deps.home}/sessions`,
1597
- excludeDirs: []
1598
- });
1599
- } catch (error) {
1600
- return {
1601
- entries: [],
1602
- bytes: 0,
1603
- skipped: `读不到引用源,无法判定孤儿附件:${String(error)}`
1604
- };
1605
- }
1606
- if (remaining.unreadable.length > 0) return {
1656
+ const scanned = await remainingRefsFailClosed(deps, [], "读不到引用源,无法判定孤儿附件");
1657
+ if (!scanned.ok) return {
1607
1658
  entries: [],
1608
1659
  bytes: 0,
1609
- skipped: `读不到引用源,无法判定孤儿附件:${remaining.unreadable[0]}`
1660
+ skipped: scanned.reason
1610
1661
  };
1662
+ const remaining = scanned.refs;
1611
1663
  const objectsRoot = `${deps.home}/attachments/v1/objects`;
1612
1664
  const entries = [];
1613
1665
  let bytes = 0;
@@ -1666,9 +1718,9 @@ async function cleanOrphanImageAttachments(deps) {
1666
1718
  * purge / orphan-clean 的删除计划({@link planMemberRemovals})才付——executor 摘账本要用它。
1667
1719
  */
1668
1720
  /** `exists → sizeOfDir` 的唯一一条度量口径(成员目录与回收站目录共用):先问在不在,再量字节;
1669
- * ENOENT 是唯一一种当"不在"的失败,别的错误照旧响亮。别再在调用点各写一遍这段 try/catch。
1721
+ * 缺席(ENOENT / ENOTDIR)才是"不在",别的错误照旧响亮(ADR-0039)。别再在调用点各写一遍这段 try/catch。
1670
1722
  *
1671
- * **已知粒度**:`sizeOfDir` 递归里撞到的 ENOENT 与"目录本身没了"是同一个码,所以目录里某个文件
1723
+ * **已知粒度**:`sizeOfDir` 递归里撞到的缺席与"目录本身没了"是同一个码,所以目录里某个文件
1672
1724
  * 恰好在这一拍消失时,整条目录会被记成「不在」(`/list` 少算这条、删除计划里没有可删的字节,
1673
1725
  * 而清册行照旧摘——盘上留下目录等下一次 `reconcile` 重建成行,自愈、不丢数据)。复核一次
1674
1726
  * `exists(dir)` 能把两者分开,却会让「宿主正在写、文件刚被挪走」这类正常竞态把 `/list` 整个打红——
@@ -1880,15 +1932,20 @@ async function planOrphanRemoval(deps, members) {
1880
1932
  *
1881
1933
  * 顺序:整批摘归档集 → 摘有账本的成员的账本 → 逐条回收站行
1882
1934
  * (在才删目录 → 摘清册行 → 通告成员离场)→ 逐条盘上成员(删目录 → 通告成员离场)→ 逐条附件对象。
1883
- * 目录删掉就没有"退回去"这回事,中途失败**不回滚**;已经删掉的由下面的计数器如实交回。
1884
- *
1885
- * 附件清理排在**所有会话目录删完之后**:对象只在会话真的没了之后才动;单文件失败只进
1886
- * `attachments.failed`,不影响(也不回滚)会话删除的回执。
1935
+ * 目录删掉就没有"退回去"这回事,中途失败**不回滚目录**;已经删掉的由下面的计数器如实交回。
1936
+ * **归档态回滚**:`unarchive` 排在所有删除之前(免得留下"会话没了、归档集还挂着"的幽灵 id),
1937
+ * 所以中途失败时目录还在、归档位却已经摘了。失败出口把**目录还在**的那些(这一趟没删掉、也不是计划时
1938
+ * 就缺席)按删除前的读数重新归档(`isArchived` 先读过一次,不是按 `wasArchived` 猜);目录不在的跳过
1939
+ * ——那时恢复归档只会造幽灵 id。
1940
+ * 回滚自己的失败走 `details.rollbackFailures`,不让它把原错误吞掉(ADR-0040)。
1941
+ * **附件计划在执行期复核**:计划到删对象之间的窗口里新增的引用会让计划过期;删对象前用同一套
1942
+ * 剩余引用扫描重判一次,只删这一刻仍无引用的对象(ADR-0041)。
1887
1943
  *
1888
1944
  * 失败形状:
1889
1945
  * - `kind:'orphan'`(orphan-clean)⇒ `io` + `{removed, freedBytes, failedSessionId}`,不伪装成整组没动;
1890
1946
  * - `kind:'trash'`(purge)⇒ 同一个标签、不带 details;
1891
- * - orphan-clean 的 unarchive / detach 原样抛出。
1947
+ * - orphan-clean 的归档实况预读 / unarchive / detach 原样抛出;
1948
+ * - 归档态回滚不干净时,原错误带 `details.rollbackFailures`(码仍按原错误的动词码)。
1892
1949
  *
1893
1950
  * 通告只经 `member-presence`(摘活体三态 + `api-session/removed`),不在这里另写一份。
1894
1951
  */
@@ -1910,7 +1967,7 @@ async function executeRemoval(deps, plan) {
1910
1967
  } : void 0;
1911
1968
  throw verbError("io", `${label}:${String(error)}`, error, details);
1912
1969
  };
1913
- /** 退归档集 / 摘账本这一步的失败形状:purge 折成带标签的 `io`;orphan-clean 原样抛。 */
1970
+ /** 归档实况预读 / 退归档集 / 摘账本这一步的失败形状:purge 折成带标签的 `io`;orphan-clean 原样抛。 */
1914
1971
  const step = async (label, sessionId, run) => {
1915
1972
  if (plan.kind === "orphan") {
1916
1973
  await run();
@@ -1922,49 +1979,79 @@ async function executeRemoval(deps, plan) {
1922
1979
  fail(label, error, sessionId);
1923
1980
  }
1924
1981
  };
1925
- for (const sessionId of plan.unarchive) await step("取消归档失败", sessionId, () => deps.host.unarchive(sessionId));
1926
- for (const member of plan.members) {
1927
- const workspaceId = member.workspaceId;
1928
- if (workspaceId === void 0) continue;
1929
- await step("摘账本失败", member.sessionId, () => deps.host.detach(member.sessionId, workspaceId));
1930
- }
1931
- for (const row of plan.trash) {
1932
- if (row.present) {
1982
+ const archivedBefore = /* @__PURE__ */ new Set();
1983
+ for (const sessionId of plan.unarchive) await step("读取归档状态失败", sessionId, async () => {
1984
+ if (await deps.host.isArchived(sessionId)) archivedBefore.add(sessionId);
1985
+ });
1986
+ /** 走到过 `unarchive` 这一步的 id(含失败那一条):失败时按它回滚。 */
1987
+ const unarchived = [];
1988
+ /** 计划时目录还在的 id:只有这些会话还有本体可挂归档。计划时就缺席的行 / 成员也在 `unarchive`
1989
+ * 名单里(那一步顺带清幽灵 id),但没有目录可回滚。 */
1990
+ const onDisk = /* @__PURE__ */ new Set([...plan.trash.filter((row) => row.present).map((row) => row.entry.sessionId), ...plan.members.map((member) => member.sessionId)]);
1991
+ /** 目录已经真删掉的 id:没有归档态可放回去,跳过——否则会在 registry 里留下幽灵 id。 */
1992
+ const deleted = /* @__PURE__ */ new Set();
1993
+ try {
1994
+ for (const sessionId of plan.unarchive) {
1995
+ unarchived.push(sessionId);
1996
+ await step("取消归档失败", sessionId, () => deps.host.unarchive(sessionId));
1997
+ }
1998
+ for (const member of plan.members) {
1999
+ const workspaceId = member.workspaceId;
2000
+ if (workspaceId === void 0) continue;
2001
+ await step("摘账本失败", member.sessionId, () => deps.host.detach(member.sessionId, workspaceId));
2002
+ }
2003
+ for (const row of plan.trash) {
2004
+ if (row.present) {
2005
+ try {
2006
+ await deps.fs.removeDir(row.dir);
2007
+ } catch (error) {
2008
+ fail("删除回收站目录失败", error, row.entry.sessionId);
2009
+ }
2010
+ deleted.add(row.entry.sessionId);
2011
+ freedSoFar += row.bytes;
2012
+ }
1933
2013
  try {
1934
- await deps.fs.removeDir(row.dir);
2014
+ await deps.ledger.remove(row.entry.id);
1935
2015
  } catch (error) {
1936
- fail("删除回收站目录失败", error, row.entry.sessionId);
2016
+ fail("摘除回收站条目失败", error, row.entry.sessionId);
1937
2017
  }
1938
- freedSoFar += row.bytes;
2018
+ removed += 1;
2019
+ await presence.memberGone(row.entry.sessionId);
1939
2020
  }
1940
- try {
1941
- await deps.ledger.remove(row.entry.id);
1942
- } catch (error) {
1943
- fail("摘除回收站条目失败", error, row.entry.sessionId);
2021
+ const memberLabel = plan.kind === "trash" ? "删除盘上后代目录失败" : "删除会话目录失败";
2022
+ for (const member of plan.members) {
2023
+ try {
2024
+ await deps.fs.removeDir(member.dir);
2025
+ } catch (error) {
2026
+ fail(memberLabel, error, member.sessionId);
2027
+ }
2028
+ deleted.add(member.sessionId);
2029
+ removed += 1;
2030
+ freedSoFar += member.bytes;
2031
+ await presence.memberGone(member.sessionId);
1944
2032
  }
1945
- removed += 1;
1946
- await presence.memberGone(row.entry.sessionId);
1947
- }
1948
- const memberLabel = plan.kind === "trash" ? "删除盘上后代目录失败" : "删除会话目录失败";
1949
- for (const member of plan.members) {
1950
- try {
1951
- await deps.fs.removeDir(member.dir);
1952
- } catch (error) {
1953
- fail(memberLabel, error, member.sessionId);
2033
+ const attachmentPlan = await recheckImageRemovalExclusivity(deps, plan.attachments);
2034
+ const attachments = await executeImageAttachmentRemoval(deps, attachmentPlan);
2035
+ return {
2036
+ removed,
2037
+ freedBytes: plan.bytes,
2038
+ attachments: {
2039
+ ...attachments,
2040
+ ...attachmentPlan.skipped === void 0 ? {} : { skipped: attachmentPlan.skipped }
2041
+ }
2042
+ };
2043
+ } catch (error) {
2044
+ const rollbackFailures = [];
2045
+ for (const sessionId of [...unarchived].reverse()) {
2046
+ if (!archivedBefore.has(sessionId) || deleted.has(sessionId) || !onDisk.has(sessionId)) continue;
2047
+ try {
2048
+ await deps.host.archive(sessionId);
2049
+ } catch (rollbackError) {
2050
+ rollbackFailures.push(rollbackReason("恢复归档失败", rollbackError));
2051
+ }
1954
2052
  }
1955
- removed += 1;
1956
- freedSoFar += member.bytes;
1957
- await presence.memberGone(member.sessionId);
2053
+ throw rollbackFailures.length === 0 ? error : withRollbackFailures(error, rollbackFailures);
1958
2054
  }
1959
- const attachments = await executeImageAttachmentRemoval(deps, plan.attachments);
1960
- return {
1961
- removed,
1962
- freedBytes: plan.bytes,
1963
- attachments: {
1964
- ...attachments,
1965
- ...plan.attachments.skipped === void 0 ? {} : { skipped: plan.attachments.skipped }
1966
- }
1967
- };
1968
2055
  }
1969
2056
  //#endregion
1970
2057
  //#region src/verbs/orphan-subagents.ts
@@ -1975,8 +2062,9 @@ async function executeRemoval(deps, plan) {
1975
2062
  * 彻底删除(组语义见 `src/trash/groups.ts`),把它的根当孤儿删掉就是**替用户做了一个没人点过的决定**。孤儿只
1976
2063
  * 有一种来路:父被彻底删掉了(或被手工从盘上清掉了),清册里再也没有这一行。
1977
2064
  *
1978
- * 根按**传递闭包**收全部后代(与 `HostCorpusPort.subagentMembersOf` 同一实现:闭包核心住
1979
- * `src/domain/closure.ts` 的 `orphanRootsOf`,孩子与孙代都算,深度不设上限),同一条记录只归一组
2065
+ * 根按**传递闭包**收全部 subagent 后代(与 `HostCorpusPort.subagentMembersOf` 同一实现:闭包核心住
2066
+ * `src/domain/closure.ts` 的 `orphanRootsOf`,孩子与孙代都算,深度不设上限;普通 fork 不是成员、
2067
+ * 也不穿过它收后代),同一条记录只归一组
1980
2068
  * (`claimed`)——数据里真出现环时结果是"少数一条",不是把一个会话删两遍。
1981
2069
  * 归堆与去重**两边都去 `session-` 前缀**:同一个父在两代宿主里写法不同(见 `bareSessionId` 的来历)。
1982
2070
  */
@@ -2147,7 +2235,25 @@ function liveSessionsOf(ctx) {
2147
2235
  if (typeof store.get !== "function" || typeof store.liveEntryFor !== "function") return void 0;
2148
2236
  return store;
2149
2237
  }
2150
- /** 官方的「这些父的孩子」那一支:`kind:'parent'` 的判据逐字是 `values.includes(record.header.parentSession ?? null)`。 */
2238
+ /**
2239
+ * 会话 header 的**公开**读面:live `sessions.get(id).header` 优先(保留宿主原来的优先级),
2240
+ * 否则 `sessionPersistence.stat(id).header`。未知会话回 `undefined`;需要落到持久化时服务缺席 /
2241
+ * 形状不符抛错,由调用方决定是响亮失败(projectDirOf)还是降级(titleOf 的空串)。
2242
+ */
2243
+ async function headerOf(host, sessionId) {
2244
+ const store = liveSessionsOf(host);
2245
+ if (store !== void 0) try {
2246
+ const live = store.get(sessionId);
2247
+ if (live?.header !== void 0) return live.header;
2248
+ } catch {}
2249
+ const persistence = host.get("sessionPersistence");
2250
+ if (persistence === void 0 || persistence === null || typeof persistence.stat !== "function") throw new Error("session-delete: sessionPersistence 服务缺席");
2251
+ return (await persistence.stat(sessionId))?.header;
2252
+ }
2253
+ /** 官方的「这些父的孩子」那一支:`kind:'parent'` 的判据逐字是 `values.includes(record.header.parentSession ?? null)`。
2254
+ * 它只按 `parentSession` 原样匹配,**不是**子会话分类器:旧宿主(≤0.2.1-rc.5)的普通 fork 也写
2255
+ * `parentSession`,0.2.2-alpha.2 起才不写。子会话的判据是 `origin` 的 subagent 标记(宿主归档闸、归档
2256
+ * 准入、持久化找子日志三处一致),所以调用方必须再按 origin 筛一遍。 */
2151
2257
  async function filterChildrenOf(sessionQuery, values) {
2152
2258
  return sessionQuery.filterSessions([{
2153
2259
  kind: "parent",
@@ -2174,10 +2280,11 @@ async function descendantsByParent(sessionQuery, roots) {
2174
2280
  const parent = record.header?.parentSession;
2175
2281
  if (typeof id !== "string" || id.length === 0) continue;
2176
2282
  if (typeof parent !== "string" || parent.length === 0) continue;
2283
+ if (record.header?.origin !== "subagent") continue;
2177
2284
  const bare = bareSessionId(id);
2178
2285
  if (seen.has(bare)) continue;
2179
2286
  seen.add(bare);
2180
- const cwd = record.header?.cwd;
2287
+ const cwd = record.header.cwd;
2181
2288
  const descendant = {
2182
2289
  sessionId: id,
2183
2290
  projectDir: cwd === void 0 ? NO_CWD_PROJECT_DIR : projectKey(cwd)
@@ -2238,15 +2345,6 @@ function projectionCacheOf(ctx) {
2238
2345
  if (typeof cache.cachedSnapshot !== "function" || typeof cache.cachedPredecessorTitle !== "function") return void 0;
2239
2346
  return cache;
2240
2347
  }
2241
- /** 宿主 `readSessionHeader` 对未知会话抛的是裸 `Error`,只认这句;真存储故障照抛,不能折成未命中。
2242
- * 出处:asar `dsh-workspace/lib/types/index.js` 的
2243
- * `cannot validate session '${id}': session persistence holds no such session`。
2244
- * 同文件有类型的近似句是 `WorkspaceUnknownSessionError`(`hold` 无 `s`),不会误伤。 */
2245
- const NO_SUCH_SESSION = "session persistence holds no such session";
2246
- /** 只认上面那一句;其余(含非 Error 抛出物)一律**不算未命中**。 */
2247
- function isUnknownSession(error) {
2248
- return error instanceof Error && error.message.includes(NO_SUCH_SESSION);
2249
- }
2250
2348
  /** 宿主会话表的一次取数(`ctx.sessionController.list()` 的两种返回形状都折成数组)。
2251
2349
  * 服务没挂 / 形状不对 / 抛错 ⇒ 空表——**这条不替宿主兜错误**:调用方拿空表的语义都是"少发一条通告",
2252
2350
  * 不是"恢复失败"。 */
@@ -2287,23 +2385,20 @@ function createHostPort(ctx) {
2287
2385
  },
2288
2386
  /**
2289
2387
  * cwd → 宿主项目目录名;没有 cwd 落到 `_no-cwd`。
2290
- * 账本里没有这个会话回 `undefined`(未命中,不是故障);真存储故障照抛。
2388
+ * header 走公开面(live `sessions` → `sessionPersistence.stat`);未知会话回 `undefined`
2389
+ * (未命中,不是故障);服务缺席/真存储故障照抛。
2291
2390
  * 未命中由 `delete-session.ts` 折成 `not-found`,真故障留 `io`——两者不能混。
2292
2391
  */
2293
2392
  async projectDirOf(sessionId) {
2294
- let header;
2295
- try {
2296
- header = await registry.readSessionHeader(sessionId);
2297
- } catch (error) {
2298
- if (isUnknownSession(error)) return void 0;
2299
- throw error;
2300
- }
2393
+ const header = await headerOf(host, sessionId);
2394
+ if (header === void 0) return void 0;
2301
2395
  return header.cwd === void 0 ? NO_CWD_PROJECT_DIR : projectKey(header.cwd);
2302
2396
  },
2303
- /** 标题走宿主投影缓存;尽力取,取不到返回空串,绝不因此让删除失败。 */
2397
+ /** 标题走宿主投影缓存;header 来自公开的 live/持久化读面,尽力取,取不到返回空串。 */
2304
2398
  async titleOf(sessionId) {
2305
2399
  try {
2306
- const header = await registry.readSessionHeader(sessionId);
2400
+ const header = await headerOf(host, sessionId);
2401
+ if (header === void 0) return "";
2307
2402
  const cache = projectionCacheOf(host);
2308
2403
  const title = (cache?.cachedSnapshot(header, ["title"]) ?? cache?.cachedPredecessorTitle(header))?.values.title;
2309
2404
  return typeof title === "string" ? title : "";
@@ -3138,8 +3233,42 @@ function readTrashDetail(deps, limits = DEFAULT_LIMITS) {
3138
3233
  };
3139
3234
  }
3140
3235
  //#endregion
3236
+ //#region src/ports/attachments-port.ts
3237
+ /** 服务在 `available()` 之后消失/形状变了:调用方可以退回直接 fs,而不是把这次读取判成 io。 */
3238
+ var AttachmentsUnavailableError = class extends Error {
3239
+ constructor(message = "attachments service absent") {
3240
+ super(message);
3241
+ this.name = "AttachmentsUnavailableError";
3242
+ }
3243
+ };
3244
+ /** 每次调用惰性解析服务:插件与 `attachments` 的激活顺序没有承诺。 */
3245
+ function createAttachmentsPort(ctx) {
3246
+ const serviceOf = () => {
3247
+ try {
3248
+ const service = ctx.get("attachments");
3249
+ if (service === void 0 || service === null) return void 0;
3250
+ if (typeof service.readImage !== "function" || typeof service.readFileStream !== "function") return void 0;
3251
+ return service;
3252
+ } catch {
3253
+ return;
3254
+ }
3255
+ };
3256
+ return {
3257
+ available: () => serviceOf() !== void 0,
3258
+ async readImage(ref) {
3259
+ const service = serviceOf();
3260
+ if (service === void 0) throw new AttachmentsUnavailableError();
3261
+ return await service.readImage(ref);
3262
+ },
3263
+ async *readFileStream(ref) {
3264
+ const service = serviceOf();
3265
+ if (service === void 0) throw new AttachmentsUnavailableError();
3266
+ yield* service.readFileStream(ref);
3267
+ }
3268
+ };
3269
+ }
3270
+ //#endregion
3141
3271
  //#region src/api/attachment.ts
3142
- const MAX_ATTACHMENT_BYTES = 20971520;
3143
3272
  const PNG_MAGIC = Buffer.from([
3144
3273
  137,
3145
3274
  80,
@@ -3150,6 +3279,21 @@ const PNG_MAGIC = Buffer.from([
3150
3279
  26,
3151
3280
  10
3152
3281
  ]);
3282
+ const IMAGE_MEDIA_TYPES = [
3283
+ "image/png",
3284
+ "image/jpeg",
3285
+ "image/webp",
3286
+ "image/gif"
3287
+ ];
3288
+ function isImageMediaType(value) {
3289
+ return value !== null && IMAGE_MEDIA_TYPES.includes(value);
3290
+ }
3291
+ /** 引用里的正整数字段(bytes / width / height);缺、空、非整数、非正数都当"元数据不够",退回直接 fs。 */
3292
+ function positiveInt(value) {
3293
+ if (value === null || value === "") return void 0;
3294
+ const parsed = Number(value);
3295
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : void 0;
3296
+ }
3153
3297
  /** 按魔数认图片类型;认不出回 `undefined`(调用方按未知字节处理,不猜)。 */
3154
3298
  function sniffImage(bytes) {
3155
3299
  if (bytes.length >= PNG_MAGIC.length && bytes.subarray(0, PNG_MAGIC.length).equals(PNG_MAGIC)) return "image/png";
@@ -3164,63 +3308,185 @@ function sniffImage(bytes) {
3164
3308
  * 否则 `encodeURIComponent` 会在写头时抛)。
3165
3309
  */
3166
3310
  function safeFilename(name, fallback) {
3167
- const raw = name === null || name === "" ? fallback : name;
3311
+ const raw = name === null || name === void 0 || name === "" ? fallback : name;
3168
3312
  const base = Array.from(raw).slice(0, 120).join("").replace(/[\\/:*?"<>|\u0000-\u001f\uD800-\uDFFF]/gu, "_").replace(/\.\./gu, "_");
3169
3313
  return base === "" ? fallback : base;
3170
3314
  }
3171
- async function readAttachmentResponse(deps, query) {
3315
+ /** 引用里的短 id:错误文案与下载名兜底共用,别让 `sha256:` 前缀的切片散在各处。 */
3316
+ function shortId(id) {
3317
+ return id.slice(7, 19);
3318
+ }
3319
+ /** 普通文件的下载响应;图片认不出类型时也走这一支。 */
3320
+ function downloadResponse(body, filename) {
3321
+ return {
3322
+ body,
3323
+ contentType: "application/octet-stream",
3324
+ disposition: "attachment",
3325
+ filename
3326
+ };
3327
+ }
3328
+ /** 服务读取/校验失败按 io 上屏,不 fallback 直接 fs:宿主读 `files/.../<name>` 别名并做摘要/元数据
3329
+ * 校验,直接 fs 读 `file-objects/...` 对象;绕过会跳过宿主的校验(ADR-0035 边界)。 */
3330
+ function attachmentsIoError(id, error) {
3331
+ throw verbError("io", `读取附件失败:${id}(${String(error)})`, error);
3332
+ }
3333
+ /** 同一道上限:声明字节、磁盘字节、服务返回字节都从这里过。 */
3334
+ function assertWithinLimit(id, observed, maxBytes) {
3335
+ if (observed > maxBytes) throw verbError("io", `附件超过 ${maxBytes} 字节上限:${id}(${observed} 字节)`);
3336
+ }
3337
+ /** 流式读超过上限:没有"实际总字节",原文用 `>上限`。 */
3338
+ function streamOverflow(id, maxBytes) {
3339
+ throw verbError("io", `附件超过 ${maxBytes} 字节上限:${id}(>${maxBytes} 字节)`);
3340
+ }
3341
+ /** 服务路径要的引用元数据;任一字段不够就回 `undefined`(调用方退回直接 fs,不拿半截 ref 去猜)。 */
3342
+ function imageServiceRef(id, query) {
3343
+ const mediaType = query.get(PARAMS.attachmentMediaType);
3344
+ const bytes = positiveInt(query.get(PARAMS.attachmentBytes));
3345
+ const width = positiveInt(query.get(PARAMS.attachmentWidth));
3346
+ const height = positiveInt(query.get(PARAMS.attachmentHeight));
3347
+ if (!isImageMediaType(mediaType) || bytes === void 0 || width === void 0 || height === void 0) return void 0;
3348
+ const name = query.get(PARAMS.attachmentName);
3349
+ return {
3350
+ attachmentId: id,
3351
+ mediaType,
3352
+ bytes,
3353
+ width,
3354
+ height,
3355
+ ...name === null || name === "" ? {} : { name }
3356
+ };
3357
+ }
3358
+ function fileServiceRef(id, query) {
3359
+ const bytes = positiveInt(query.get(PARAMS.attachmentBytes));
3360
+ const name = query.get(PARAMS.attachmentName);
3361
+ if (bytes === void 0 || name === null || name === "") return void 0;
3362
+ return {
3363
+ attachmentId: id,
3364
+ name,
3365
+ bytes
3366
+ };
3367
+ }
3368
+ function parseServiceRef(id, kind, query) {
3369
+ if (kind === "image") {
3370
+ const ref = imageServiceRef(id, query);
3371
+ return ref === void 0 ? void 0 : {
3372
+ kind: "image",
3373
+ ref
3374
+ };
3375
+ }
3376
+ const ref = fileServiceRef(id, query);
3377
+ return ref === void 0 ? void 0 : {
3378
+ kind: "file",
3379
+ ref
3380
+ };
3381
+ }
3382
+ /** query 只解析一次:id/kind 的 400 闸与两套引用元数据都在这里收口。 */
3383
+ function parseAttachmentRequest(query) {
3172
3384
  const id = query.get(PARAMS.attachmentId);
3173
3385
  const kind = query.get(PARAMS.attachmentKind);
3174
3386
  if (!isAttachmentId(id)) throw routeError("bad-request", 400, `附件 id 需为 sha256:<64 位小写 hex>:${String(id)}`);
3175
3387
  if (kind !== "image" && kind !== "file") throw routeError("bad-request", 400, `附件 kind 只认 image / file:${String(kind)}`);
3176
- const path = attachmentPathOf(deps.home, kind, id);
3388
+ const name = query.get(PARAMS.attachmentName);
3389
+ return {
3390
+ id,
3391
+ kind,
3392
+ name: name === null || name === "" ? void 0 : name,
3393
+ serviceRef: parseServiceRef(id, kind, query)
3394
+ };
3395
+ }
3396
+ async function readImageViaAttachments(deps, ref, maxBytes) {
3397
+ assertWithinLimit(ref.attachmentId, ref.bytes, maxBytes);
3398
+ let stored;
3399
+ try {
3400
+ stored = await deps.attachments.readImage(ref);
3401
+ } catch (error) {
3402
+ if (error instanceof AttachmentsUnavailableError) return void 0;
3403
+ attachmentsIoError(ref.attachmentId, error);
3404
+ }
3405
+ const body = Buffer.from(stored.data);
3406
+ assertWithinLimit(ref.attachmentId, body.length, maxBytes);
3407
+ return {
3408
+ body,
3409
+ contentType: stored.ref.mediaType,
3410
+ disposition: "inline"
3411
+ };
3412
+ }
3413
+ /** 普通文件走宿主服务的分块读;超限在累计处停下,不把整份字节读进内存。 */
3414
+ async function readFileViaAttachments(deps, ref, maxBytes) {
3415
+ assertWithinLimit(ref.attachmentId, ref.bytes, maxBytes);
3416
+ const chunks = [];
3417
+ let total = 0;
3418
+ let tooLarge = false;
3419
+ try {
3420
+ for await (const chunk of deps.attachments.readFileStream(ref)) {
3421
+ const buffer = Buffer.from(chunk);
3422
+ total += buffer.length;
3423
+ if (total > maxBytes) {
3424
+ tooLarge = true;
3425
+ break;
3426
+ }
3427
+ chunks.push(buffer);
3428
+ }
3429
+ } catch (error) {
3430
+ if (error instanceof AttachmentsUnavailableError) return void 0;
3431
+ attachmentsIoError(ref.attachmentId, error);
3432
+ }
3433
+ if (tooLarge) streamOverflow(ref.attachmentId, maxBytes);
3434
+ return downloadResponse(Buffer.concat(chunks), safeFilename(ref.name, shortId(ref.attachmentId)));
3435
+ }
3436
+ /** 服务优先的唯一入口;`undefined` = 服务缺席 / 元数据不够 / 服务中途消失,调用方退回直接 fs。 */
3437
+ async function readViaAttachments(deps, request, maxBytes) {
3438
+ if (request.serviceRef === void 0) return void 0;
3439
+ if (!deps.attachments.available()) return void 0;
3440
+ const serviceRef = request.serviceRef;
3441
+ return serviceRef.kind === "image" ? readImageViaAttachments(deps, serviceRef.ref, maxBytes) : readFileViaAttachments(deps, serviceRef.ref, maxBytes);
3442
+ }
3443
+ /** 旧宿主 / 元数据不够:退回直接 fs(路径仍只由 id 与 kind 推导)。 */
3444
+ async function readViaFs(deps, request, maxBytes) {
3445
+ const path = attachmentPathOf(deps.home, request.kind, request.id);
3177
3446
  let size;
3178
3447
  try {
3179
3448
  size = (await deps.fs.statFile(path)).size;
3180
3449
  } catch (error) {
3181
- throw verbError("io", `附件不在:${id}(${String(error)})`, error);
3450
+ throw verbError("io", `附件不在:${request.id}(${String(error)})`, error);
3182
3451
  }
3183
- if (size > MAX_ATTACHMENT_BYTES) throw verbError("io", `附件超过 ${MAX_ATTACHMENT_BYTES} 字节上限:${id}(${size} 字节)`);
3452
+ assertWithinLimit(request.id, size, maxBytes);
3184
3453
  const bytes = await deps.fs.readFile(path);
3185
- if (bytes.length > MAX_ATTACHMENT_BYTES) throw verbError("io", `附件超过 ${MAX_ATTACHMENT_BYTES} 字节上限:${id}(${bytes.length} 字节)`);
3186
- if (kind === "file") return {
3187
- body: bytes,
3188
- contentType: "application/octet-stream",
3189
- disposition: "attachment",
3190
- filename: safeFilename(query.get(PARAMS.attachmentName), id.slice(7, 19))
3191
- };
3454
+ assertWithinLimit(request.id, bytes.length, maxBytes);
3455
+ if (request.kind === "file") return downloadResponse(bytes, safeFilename(request.name, shortId(request.id)));
3192
3456
  const contentType = sniffImage(bytes);
3193
- if (contentType === void 0) return {
3194
- body: bytes,
3195
- contentType: "application/octet-stream",
3196
- disposition: "attachment",
3197
- filename: safeFilename(query.get(PARAMS.attachmentName), `${id.slice(7, 19)}.bin`)
3198
- };
3457
+ if (contentType === void 0) return downloadResponse(bytes, safeFilename(request.name, `${shortId(request.id)}.bin`));
3199
3458
  return {
3200
3459
  body: bytes,
3201
3460
  contentType,
3202
3461
  disposition: "inline"
3203
3462
  };
3204
3463
  }
3464
+ async function readAttachmentResponse(deps, query) {
3465
+ const request = parseAttachmentRequest(query);
3466
+ const maxBytes = deps.config.maxAttachmentBytes;
3467
+ const served = await readViaAttachments(deps, request, maxBytes);
3468
+ if (served !== void 0) return served;
3469
+ return readViaFs(deps, request, maxBytes);
3470
+ }
3205
3471
  //#endregion
3206
3472
  //#region src/api/attachment-orphans.ts
3207
3473
  /**
3208
3474
  * 孤儿附件的报告段:`GET /attachment-orphans`。
3209
3475
  *
3210
3476
  * 报告把扫描计划折成数量/字节 + 对象清单(`id`/`bytes`,供设置页预览缩略图;最多
3211
- * `ORPHAN_PREVIEW_LIMIT` 条,清理不受预览条数限制)。扫描跳过(引用源或对象列表读不出)时
3212
- * **报告必须失败**——回一份 0 会让界面说"没有孤儿",而这类失败恰恰是"不知道",不是"没有"。
3477
+ * `src/config.ts` 的 `orphanPreviewLimit`(默认 200,ADR-0037),清理不受预览条数限制)。
3478
+ * 扫描跳过(引用源或对象列表读不出)时**报告必须失败**——回一份 0 会让界面说"没有孤儿",
3479
+ * 而这类失败恰恰是"不知道",不是"没有"。
3213
3480
  * 清理是 `POST /attachment-clean`,由路由直接接到 `cleanOrphanImageAttachments`。
3214
3481
  */
3215
- /** 预览清单的上限:与详情附件的载荷闸同一量级。数量与字节仍按全量报告。 */
3216
- const ORPHAN_PREVIEW_LIMIT = 200;
3217
3482
  async function reportAttachmentOrphans(deps) {
3218
3483
  const plan = await scanOrphanImageAttachments(deps);
3219
3484
  if (plan.skipped !== void 0) throw verbError("io", plan.skipped);
3485
+ const limit = deps.config.orphanPreviewLimit;
3220
3486
  return {
3221
3487
  count: plan.entries.length,
3222
3488
  totalBytes: plan.bytes,
3223
- items: plan.entries.slice(0, ORPHAN_PREVIEW_LIMIT).map((entry) => ({
3489
+ items: plan.entries.slice(0, limit).map((entry) => ({
3224
3490
  attachmentId: entry.id,
3225
3491
  bytes: entry.bytes
3226
3492
  }))
@@ -3356,13 +3622,14 @@ const ROUTE_TABLE = {
3356
3622
  run: async () => "pong"
3357
3623
  },
3358
3624
  [ROUTES.attachment.path]: {
3359
- slice: "core",
3625
+ slice: "attachment",
3360
3626
  method: ROUTES.attachment.method,
3361
3627
  body: "none",
3362
3628
  purity: "pure",
3363
3629
  read: "none",
3364
3630
  response: "binary",
3365
- run: async (deps, _body, _read, query) => readAttachmentResponse(deps, query)
3631
+ fetchGate: "same-origin",
3632
+ run: async (deps, query) => readAttachmentResponse(deps, query)
3366
3633
  },
3367
3634
  [ROUTES.attachmentOrphans.path]: {
3368
3635
  slice: "core",
@@ -3434,95 +3701,37 @@ function routeOf(rest) {
3434
3701
  }
3435
3702
  //#endregion
3436
3703
  //#region src/api/dispatch.ts
3437
- /** 请求体字节上限:这条通道只收几个 id,1MiB 是"够用且不给自己开一条内存放大器"的量级。 */
3438
- const JSON_BODY_MAX_BYTES = 1048576;
3439
- function writeJson(res, status, body) {
3440
- res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
3441
- res.end(JSON.stringify(body));
3442
- }
3443
- function writeOk(res, value) {
3444
- writeJson(res, 200, {
3445
- ok: true,
3446
- value
3447
- });
3448
- }
3449
3704
  /**
3450
- * 二进制响应(`/attachment` 专用)。四道头都是判据不是口味:
3451
- * - `content-type` 由处理函数给(图片按魔数认,文件一律 octet-stream);
3452
- * - `content-length` 与字节数同源;
3453
- * - `cache-control: private, no-store`:附件可能来自已删除会话,不许被任何中间层留下;
3454
- * - `nosniff` + `attachment` 下载名:字节内容与类型不一致时浏览器不许改猜。
3705
+ * /dsh-session-delete-api 的分发核心:只负责路由记录的执行。
3706
+ *
3707
+ * 传输适配器只有 `src/api/connection-route.ts` 一个:九段注册到宿主 `connection.fetch.register`
3708
+ * 的 exact Fetch 路由(`Request` / `Response`),Host/Origin/浏览器认证由宿主 `/api` bridge 完成
3709
+ * (ADR-0033)。`Request` 的字节闸 / JSON 解析、`Response` 的头与失败信封都归适配器;
3710
+ * 这里只收已经解析好的请求,做查表 → 按段注入窄面 → 跑处理 → 回结果。
3711
+ *
3712
+ * 纯度、body 声明、读面与方法仍住 `src/api/route-table.ts` 与 `src/shared/wire.ts`;这里只按判据执行。
3455
3713
  *
3456
- * **失败不走这里**:处理函数抛出的错误仍由 `writeFailure` 写 JSON 信封(图片的 `onError` 会换成
3457
- * 占位;普通文件走 `<a download>`,没有页面内失败面)。
3714
+ * - 请求本身不对用 4xx(400 请求体 / 405 方法 / 404 未知路由);操作失败回 200 + {ok:false,error}。
3715
+ * 浏览器半先解信封,再看状态。宿主 exact route 已在 handler 之前把未声明方法 / 路径回 404,
3716
+ * 所以 405 / 未知路由 404 只在适配器直调时可达(`tests/connection-route.test.ts` 钉防御分支)。
3717
+ * - `purity` 为 chained 的请求串行执行,避免读清册/动文件/写清册交错;整个进程共用一条到达序链。
3458
3718
  */
3459
- function writeBinary(res, body) {
3460
- const headers = {
3461
- "content-type": body.contentType,
3462
- "content-length": String(body.body.length),
3463
- "cache-control": "private, no-store",
3464
- "x-content-type-options": "nosniff"
3465
- };
3466
- if (body.filename !== void 0) headers["content-disposition"] = `${body.disposition}; filename*=UTF-8''${encodeURIComponent(body.filename)}`;
3467
- else if (body.disposition === "attachment") headers["content-disposition"] = "attachment";
3468
- res.writeHead(200, headers);
3469
- res.end(body.body);
3470
- }
3471
- /** 失败信封:码与原文都来自 `toRemoteFailure`(本插件唯一的错误映射表)。
3472
- * **状态语义**:**请求本身不对**不再伪装成 200——请求体不对 400、方法不对 405、
3473
- * 未知路由 404、栅栏拒 403(它们不是"一次办成的操作",是"这条请求不归我办/你没资格办")。
3474
- * **操作失败**仍回 200 + 信封。浏览器半先解信封再看状态,所以 4xx 上带的信封照样读得出原文;
3475
- * `code` 一律折成 `sessiondelete/io`,那三个路由标签(`bad-request` / `bad-method` / `not-found-route`)
3476
- * 是**抛出处给读代码的人看的站点名**,不许冒充 `src/shared/wire.ts` 的五个动词码上客户端。 */
3477
- function writeFailure(res, error, status) {
3478
- const carried = error?.httpStatus;
3479
- writeJson(res, status ?? (typeof carried === "number" ? carried : 200), {
3480
- ok: false,
3481
- error: toRemoteFailure(error)
3482
- });
3483
- }
3484
- /** 栅栏拒绝的两种原文:归属不对(跨源 / 重绑 / 头不成形)与远程客户端没带宿主身份。
3485
- * 两者都是 403,但客户端会上屏,原文要能告诉用户下一步做什么。 */
3486
- const UNTRUSTED_MESSAGE = "非受信来源";
3487
- const UNAUTHENTICATED_MESSAGE = "需要宿主的 Web 身份:请用 dsh web 打印的那一行 URL 打开页面后重试";
3488
- /** 流式读 body 并解析成 JSON 对象:空体回 `null`,超限 / 非法 JSON / 不是对象一律抛(→ 400 + 失败信封)。
3489
- * **请求本身不对**(400)与**那次操作读不到东西**(200 + 信封)是两件事:前者是调用方没组好请求,
3490
- * 后者是盘上没有;把前者也写成 200,代理与日志里就再也分不出这两类。 */
3491
- async function readJsonBody(req) {
3492
- const chunks = [];
3493
- let size = 0;
3494
- for await (const chunk of req) {
3495
- const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
3496
- size += buf.length;
3497
- if (size > JSON_BODY_MAX_BYTES) throw routeError("bad-request", 400, `请求体超过 ${JSON_BODY_MAX_BYTES} 字节上限`);
3498
- chunks.push(buf);
3499
- }
3500
- const text = Buffer.concat(chunks).toString("utf8").trim();
3501
- if (text === "") return null;
3502
- try {
3503
- const parsed = JSON.parse(text);
3504
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw routeError("bad-request", 400, "请求体需为 JSON 对象");
3505
- return parsed;
3506
- } catch (error) {
3507
- if (error instanceof SyntaxError) throw routeError("bad-request", 400, `请求体不是合法 JSON:${error.message}`);
3508
- throw error;
3509
- }
3510
- }
3511
3719
  function requireMethod(method, want, route) {
3512
3720
  if (method !== want) throw routeError("bad-method", 405, `方法不允许:${method}(${SESSION_DELETE_API_PREFIX}${route} 需 ${want})`);
3513
3721
  }
3722
+ /** 段名的 404 与方法的 405 只在这里判一次;空段名按插件前缀显示,避免 404 原文出现一个空串。 */
3723
+ function resolveRoute(method, rest) {
3724
+ const spec = routeOf(rest);
3725
+ if (spec === void 0) throw routeError("not-found-route", 404, `未知路由:${method} ${rest === "" ? SESSION_DELETE_API_PREFIX : rest}`);
3726
+ requireMethod(method, spec.method, rest);
3727
+ return spec;
3728
+ }
3514
3729
  function isPure(purity, body) {
3515
3730
  return typeof purity === "function" ? purity(body) === "pure" : purity === "pure";
3516
3731
  }
3517
- function readBodyFor(kind, req) {
3518
- return kind === "json" ? readJsonBody(req) : null;
3519
- }
3520
3732
  /**
3521
3733
  * 全量面按行收窄(ADR-0032):组合根装出的 {@link DeleteDeps} 只交给 `/apply`;每一段只拿到
3522
- * 自己声明的那一格(路由表的 `slice`)。收窄是**运行期事实**,不是类型投影:`core` 拿到的是
3523
- * 新建的 `CoreFsPort` 面,`removeDir` / `removeFile` 根本不在对象上;`now` 也不在 `core` / `removal`
3524
- * 上。三格逐行 `satisfies` 是闸:全量面必须结构满足每一格;`coreOf` 的 fs 字面量又逼着
3525
- * `CoreFsPort` 新增方法时同步补进来——漏了编译不过,而不是等某个段的 `run` 静默拿到超面。
3734
+ * 自己声明的那一格(路由表的 `slice`)。收窄是**运行期事实**,不是类型投影。
3526
3735
  */
3527
3736
  function coreOf(full) {
3528
3737
  return {
@@ -3540,7 +3749,8 @@ function coreOf(full) {
3540
3749
  host: full.host,
3541
3750
  ledger: full.ledger,
3542
3751
  home: full.home,
3543
- presence: full.presence
3752
+ presence: full.presence,
3753
+ config: full.config
3544
3754
  };
3545
3755
  }
3546
3756
  /** `removal` 保留删除能力,但不带会话删除的时钟;`full` 只给 `/apply`。 */
@@ -3550,25 +3760,32 @@ function removalOf(full) {
3550
3760
  host: full.host,
3551
3761
  ledger: full.ledger,
3552
3762
  home: full.home,
3553
- presence: full.presence
3763
+ presence: full.presence,
3764
+ config: full.config
3554
3765
  };
3555
3766
  }
3556
- /** 三格只建一次:收窄对象在 handler 构造期固定,请求路径只做引用选择。 */
3767
+ /** 四格只建一次:收窄对象在 dispatcher 构造期固定,请求路径只做引用选择。 */
3557
3768
  function slicesOf(full) {
3769
+ const core = coreOf(full);
3558
3770
  return {
3559
- core: coreOf(full),
3771
+ core,
3560
3772
  removal: removalOf(full),
3561
- full
3773
+ full,
3774
+ attachment: {
3775
+ ...core,
3776
+ attachments: full.attachments
3777
+ }
3562
3778
  };
3563
3779
  }
3564
- /** 按行选格:`spec.slice` 与三格的键同域,查表结果的类型由 `S` 直接给出。 */
3780
+ /** 按行选格:`spec.slice` 与 {@link SliceDeps} 的键同域,查表结果的类型由 `S` 直接给出。 */
3565
3781
  function depsFor(spec, slices) {
3566
3782
  return slices[spec.slice];
3567
3783
  }
3568
- function createApiHandler(deps, trust) {
3784
+ /** 路由执行器:查段 → 方法闸 → 读面 → 处理;返回归一结果。组合根只建一次。 */
3785
+ function createDispatcher(deps) {
3569
3786
  const slices = slicesOf(deps);
3570
3787
  /**
3571
- * 到达序串行链(见文件头):路由表里 `purity === 'chained'` 的请求一个跑完再放下一个。
3788
+ * 到达序串行链:`purity === 'chained'` 的请求一个跑完再放下一个。
3572
3789
  * `tail` 由构造保证**永不 reject**(每个任务自己的失败留在交回给那次请求的那个 promise 上),
3573
3790
  * 所以一次失败不会卡死链。
3574
3791
  */
@@ -3578,120 +3795,275 @@ function createApiHandler(deps, trust) {
3578
3795
  tail = started.then(() => void 0, () => void 0);
3579
3796
  return started;
3580
3797
  };
3581
- return async function handle(req, res) {
3582
- const verdict = judgeRequest(req, trust);
3583
- if (verdict !== "allowed") {
3584
- writeFailure(res, /* @__PURE__ */ new Error(verdict === "untrusted" ? UNTRUSTED_MESSAGE : UNAUTHENTICATED_MESSAGE), 403);
3585
- return;
3586
- }
3587
- try {
3588
- const url = new URL(req.url ?? "/", "http://dsh.internal");
3589
- if (!url.pathname.startsWith("/dsh-session-delete-api")) throw routeError("not-found-route", 404, `未知路由:${url.pathname}`);
3590
- const rest = url.pathname.slice(23);
3591
- const spec = routeOf(rest);
3592
- if (spec === void 0) throw routeError("not-found-route", 404, `未知路由:${req.method ?? "GET"} ${rest === "" ? SESSION_DELETE_API_PREFIX : rest}`);
3593
- requireMethod(req.method ?? "GET", spec.method, rest);
3594
- const body = await readBodyFor(spec.body, req);
3595
- const kind = spec.read;
3596
- let rowsPromise;
3597
- const read = { rows() {
3598
- if (kind === "none") return Promise.reject(/* @__PURE__ */ new Error("route read: none"));
3599
- rowsPromise ??= kind === "list" ? deps.ledger.list() : deps.ledger.raw();
3600
- return rowsPromise;
3601
- } };
3602
- const task = () => spec.run(depsFor(spec, slices), body, read, url.searchParams);
3603
- const result = await (isPure(spec.purity, body) ? task() : throughChain(task));
3604
- if (spec.response === "binary") writeBinary(res, result);
3605
- else writeOk(res, result);
3606
- } catch (error) {
3607
- writeFailure(res, error);
3608
- }
3798
+ return async function dispatch(input) {
3799
+ const spec = resolveRoute(input.method, input.rest);
3800
+ const kind = spec.read;
3801
+ let rowsPromise;
3802
+ const read = { rows() {
3803
+ if (kind === "none") return Promise.reject(/* @__PURE__ */ new Error("route read: none"));
3804
+ rowsPromise ??= kind === "list" ? deps.ledger.list() : deps.ledger.raw();
3805
+ return rowsPromise;
3806
+ } };
3807
+ const rowDeps = depsFor(spec, slices);
3808
+ const task = () => spec.response === "binary" ? spec.run(rowDeps, input.search) : spec.run(rowDeps, input.body, read);
3809
+ const result = await (isPure(spec.purity, input.body) ? task() : throughChain(task));
3810
+ if (spec.response === "binary") return {
3811
+ kind: "binary",
3812
+ body: result
3813
+ };
3814
+ return {
3815
+ kind: "json",
3816
+ status: 200,
3817
+ body: {
3818
+ ok: true,
3819
+ value: result
3820
+ }
3821
+ };
3609
3822
  };
3610
3823
  }
3611
3824
  //#endregion
3612
- //#region src/ports/connection-port.ts
3613
- const HOST_UNAUTHENTICATED = 401;
3614
- /** 成员抛错按「归属不对」拒绝(fail-closed)。 */
3615
- function createConnectionPort(ctx) {
3616
- return { verdictFor: (req) => {
3617
- const service = ctx.get("connection");
3618
- if (service === void 0 || typeof service.requestRejection !== "function") return { kind: "absent" };
3825
+ //#region src/api/connection-route.ts
3826
+ /**
3827
+ * 现代宿主的 exact Fetch 路由适配器:把九段注册到 `connection.fetch.register`,
3828
+ * 由宿主 `/api` bridge 做 Host/Origin/浏览器认证(ADR-0033)。
3829
+ *
3830
+ * 为什么路径必须带 `/api`:宿主 `assertFetchRoute` 用 `endpointFromPath('/api', path)` 校验,
3831
+ * 不带 `/api/` 前缀会在注册时直接抛错。`request.url` 会被 bridge 重写成 `http://dsh.internal/...`,
3832
+ * 这里只取 `pathname` / `searchParams`,不依赖外部 origin。
3833
+ *
3834
+ * 这条路由**不重复宿主的鉴权**:Host/Origin/浏览器认证在 handler 之前已经由宿主完成;只有路由记录
3835
+ * 声明了 `fetchGate` 的段(`/attachment`)再判一次同源取数(`isSameOriginFetch`)——那是字节 oracle
3836
+ * 的收口,不是身份。插件的 4xx(请求体 / 方法)与 200 操作失败信封在这里落地——`createDispatcher`
3837
+ * 只抛带 `httpStatus` 的请求错误。401/403 是宿主的纯文本响应,客户端负责映射。适配器拥有 wire 机制:
3838
+ * body 字节闸、JSON 解析、二进制头与失败信封。
3839
+ */
3840
+ /** 请求体字节上限:这条通道只收几个 id,1MiB 是"够用且不给自己开一条内存放大器"的量级。 */
3841
+ const JSON_BODY_MAX_BYTES = 1048576;
3842
+ /** 请求体文本 → JSON 对象;空体 `null`;非对象 / 非法 JSON 抛 400。 */
3843
+ function parseJsonBody(text) {
3844
+ const trimmed = text.trim();
3845
+ if (trimmed === "") return null;
3846
+ try {
3847
+ const parsed = JSON.parse(trimmed);
3848
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw routeError("bad-request", 400, "请求体需为 JSON 对象");
3849
+ return parsed;
3850
+ } catch (error) {
3851
+ if (error instanceof SyntaxError) throw routeError("bad-request", 400, `请求体不是合法 JSON:${error.message}`);
3852
+ throw error;
3853
+ }
3854
+ }
3855
+ /**
3856
+ * 二进制响应的四道头(`/attachment` 专用)。判据不是口味:类型由处理函数给、长度与字节同源、
3857
+ * 不许任何中间层留下已删除会话的附件、字节与声明类型不一致时浏览器不许改猜。
3858
+ */
3859
+ function binaryHeaders(body) {
3860
+ const headers = {
3861
+ "content-type": body.contentType,
3862
+ "content-length": String(body.body.length),
3863
+ "cache-control": "private, no-store",
3864
+ "x-content-type-options": "nosniff"
3865
+ };
3866
+ if (body.filename !== void 0) headers["content-disposition"] = `${body.disposition}; filename*=UTF-8''${encodeURIComponent(body.filename)}`;
3867
+ else if (body.disposition === "attachment") headers["content-disposition"] = "attachment";
3868
+ return headers;
3869
+ }
3870
+ /**
3871
+ * 失败的落地:码与原文都来自 `toRemoteFailure`(本插件唯一的错误映射表)。
3872
+ * 状态语义:**请求本身不对**不伪装成 200——请求体 400、方法 405、未知路由 404;
3873
+ * **操作失败**回 200 + 信封。`code` 一律折成 `sessiondelete/io`,路由站点名不冒充动词码。
3874
+ */
3875
+ function failureResult(error, status) {
3876
+ const carried = error?.httpStatus;
3877
+ return {
3878
+ kind: "json",
3879
+ status: status ?? (typeof carried === "number" ? carried : 200),
3880
+ body: {
3881
+ ok: false,
3882
+ error: toRemoteFailure(error)
3883
+ }
3884
+ };
3885
+ }
3886
+ /**
3887
+ * 同源取数闸(ADR-0033 边界收口):附件字节能被任意页面当 `<img>` 渲染,而宿主 cookie 的
3888
+ * `SameSite=Strict` 按 **site** 判——同 IP 不同端口仍会带上。宿主 `/api` 只拦 `cross-site` 与
3889
+ * authority 不符的 `Origin`,这一格补在插件这一侧:
3890
+ * - `Sec-Fetch-Site` 为 `same-site` / `cross-site` ⇒ 拒;
3891
+ * - http(s) 的 `Origin` / `Referer` 与 `Host` 不同 authority ⇒ 拒(覆盖不发送 Fetch Metadata 的代次);
3892
+ * - 桌面壳的 `dsh-app:` 来源与三头全缺的非浏览器客户端放行——桌面转发器已摘掉
3893
+ * `host` / `origin` / `sec-fetch-site`,「摘干净」不是不可信。
3894
+ *
3895
+ * `Host` 依赖的是宿主 bridge 保留原始请求头(它只改写 `request.url`);真机与
3896
+ * `tests/api.test.ts` 的 `node:http` 桥都钉住这一条:同源 `Referer` 能对上 `Host`。
3897
+ */
3898
+ function isSameOriginFetch(request) {
3899
+ const site = request.headers.get("sec-fetch-site");
3900
+ if (site === "same-origin") return true;
3901
+ if (site === "same-site" || site === "cross-site") return false;
3902
+ const host = request.headers.get("host");
3903
+ for (const name of ["origin", "referer"]) {
3904
+ const value = request.headers.get(name);
3905
+ if (value === null) continue;
3906
+ let source;
3619
3907
  try {
3620
- const status = service.requestRejection(req);
3621
- if (status === void 0) return { kind: "allowed" };
3622
- return { kind: status === HOST_UNAUTHENTICATED ? "unauthenticated" : "untrusted" };
3908
+ source = new URL(value);
3623
3909
  } catch {
3624
- return { kind: "untrusted" };
3910
+ return false;
3625
3911
  }
3626
- } };
3912
+ if (source.protocol !== "http:" && source.protocol !== "https:" && source.protocol !== "dsh-app:") return false;
3913
+ if (source.protocol === "dsh-app:") continue;
3914
+ if (host === null || !isSameAuthority(source, host)) return false;
3915
+ }
3916
+ return true;
3627
3917
  }
3628
- //#endregion
3629
- //#region src/ports/fs-port.ts
3630
- /** 模块级递归:端口方法**解构出来单独调**也不能靠 `this`(`{ sizeOfDir } = port` 会让方法体的
3631
- * `this` 变成 undefined)。所以端口只做转发,递归在模块作用域里自调。 */
3632
- async function sizeOfDir(p) {
3633
- let total = 0;
3634
- for (const e of await readdir(p, { withFileTypes: true })) {
3635
- const child = join(p, e.name);
3636
- total += e.isDirectory() ? await sizeOfDir(child) : (await stat(child)).size;
3918
+ /** 按来源的协议解析 `Host`,两侧都过 WHATWG 归一(默认端口、大小写)后比 authority。 */
3919
+ function isSameAuthority(source, host) {
3920
+ try {
3921
+ return new URL(`${source.protocol}//${host}`).host === source.host;
3922
+ } catch {
3923
+ return false;
3637
3924
  }
3638
- return total;
3639
3925
  }
3640
- function createFsPort() {
3641
- return {
3642
- async exists(p) {
3643
- return existsSync(p);
3644
- },
3645
- async ensureDir(p) {
3646
- await mkdir(p, { recursive: true });
3647
- },
3648
- async rename(from, to) {
3649
- if (existsSync(to)) throw new Error(`目标已存在,拒绝覆盖:${to}`);
3650
- try {
3651
- await rename(from, to);
3652
- } catch (error) {
3653
- if (error.code === "EXDEV") throw new Error(`跨文件系统搬不动(sessions 与 session-trash 需在同一卷):${from} → ${to}`, { cause: error });
3654
- throw error;
3926
+ /** 边读边数:宿主按 `requestBody: 'buffered'` 已预读整段,这里仍守插件侧的 1 MiB 逻辑闸;
3927
+ * 超限即停,不再把读到的分片拼成更大的字符串。 */
3928
+ async function readRequestJson(request) {
3929
+ const declared = Number(request.headers.get("content-length"));
3930
+ if (Number.isFinite(declared) && declared > 1048576) throw routeError("bad-request", 400, `请求体超过 ${JSON_BODY_MAX_BYTES} 字节上限`);
3931
+ const stream = request.body;
3932
+ if (stream === null) return null;
3933
+ const reader = stream.getReader();
3934
+ const chunks = [];
3935
+ let size = 0;
3936
+ try {
3937
+ for (;;) {
3938
+ const { done, value } = await reader.read();
3939
+ if (done) break;
3940
+ size += value.byteLength;
3941
+ if (size > 1048576) {
3942
+ await reader.cancel();
3943
+ throw routeError("bad-request", 400, `请求体超过 ${JSON_BODY_MAX_BYTES} 字节上限`);
3655
3944
  }
3656
- },
3657
- async removeDir(p) {
3658
- await rm(p, {
3659
- recursive: true,
3660
- force: true
3661
- });
3662
- },
3663
- sizeOfDir: (p) => sizeOfDir(p),
3664
- async listFiles(p) {
3665
- return readdir(p);
3666
- },
3667
- async listDirs(p) {
3668
- const dirs = [];
3669
- for (const entry of await readdir(p, { withFileTypes: true })) {
3670
- if (entry.isDirectory()) {
3671
- dirs.push(entry.name);
3672
- continue;
3945
+ chunks.push(value);
3946
+ }
3947
+ } finally {
3948
+ reader.releaseLock();
3949
+ }
3950
+ return parseJsonBody(Buffer.concat(chunks).toString("utf8"));
3951
+ }
3952
+ function toResponse(result) {
3953
+ if (result.kind === "binary") return new Response(new Uint8Array(result.body.body), {
3954
+ status: 200,
3955
+ headers: binaryHeaders(result.body)
3956
+ });
3957
+ return new Response(JSON.stringify(result.body), {
3958
+ status: result.status,
3959
+ headers: {
3960
+ "content-type": "application/json; charset=utf-8",
3961
+ "cache-control": "private, no-store"
3962
+ }
3963
+ });
3964
+ }
3965
+ /**
3966
+ * 注册九段 exact Fetch 路由。返回一个组合 disposer;调用方把它挂在自己的 effect 上。
3967
+ * `connection` 是插件的硬依赖(web UI / 桌面端都装 `client-connection`);`fetch.register`
3968
+ * 缺席属于宿主形状漂移,直接抛——静默返回空 disposer 会让插件「在但没有入口」。
3969
+ * `dispatch` 与分发器同源。
3970
+ */
3971
+ function registerConnectionRoutes(service, dispatch) {
3972
+ const fetchService = service.fetch;
3973
+ if (fetchService === void 0 || typeof fetchService.register !== "function") throw new Error("session-delete: connection.fetch.register 不可用");
3974
+ const disposers = [];
3975
+ const disposeAll = async () => {
3976
+ for (const dispose of [...disposers].reverse()) try {
3977
+ await dispose();
3978
+ } catch {}
3979
+ };
3980
+ try {
3981
+ for (const [routePath, spec] of Object.entries(ROUTE_TABLE)) {
3982
+ const path = `${OFFICIAL_SESSION_DELETE_API_PREFIX}${routePath}`;
3983
+ disposers.push(fetchService.register({
3984
+ path,
3985
+ methods: [spec.method],
3986
+ requestBody: "buffered",
3987
+ fetch: async (request) => {
3988
+ if (spec.fetchGate === "same-origin" && !isSameOriginFetch(request)) return toResponse(failureResult(routeError("bad-request", 403, "附件只允许同源页面读取")));
3989
+ try {
3990
+ const body = spec.body === "json" ? await readRequestJson(request) : null;
3991
+ return toResponse(await dispatch({
3992
+ method: request.method,
3993
+ rest: routePath,
3994
+ search: new URL(request.url).searchParams,
3995
+ body
3996
+ }));
3997
+ } catch (error) {
3998
+ return toResponse(failureResult(error));
3999
+ }
3673
4000
  }
3674
- if (!entry.isSymbolicLink()) continue;
3675
- try {
3676
- if ((await stat(join(p, entry.name))).isDirectory()) dirs.push(entry.name);
3677
- } catch {}
3678
- }
3679
- return dirs;
3680
- },
3681
- async realpath(p) {
3682
- return realpath(p);
3683
- },
3684
- async readFile(p) {
3685
- return readFile(p);
3686
- },
3687
- async statFile(p) {
3688
- return { size: (await stat(p)).size };
3689
- },
3690
- async removeFile(p) {
3691
- await rm(p, { force: true });
4001
+ }));
3692
4002
  }
4003
+ } catch (error) {
4004
+ for (const dispose of [...disposers].reverse()) dispose().catch(() => void 0);
4005
+ throw error;
4006
+ }
4007
+ return disposeAll;
4008
+ }
4009
+ //#endregion
4010
+ //#region src/config.ts
4011
+ const DEFAULT_CONFIG = Object.freeze({
4012
+ orphanPreviewLimit: 200,
4013
+ maxAttachmentBytes: 20971520,
4014
+ maxJsonRefBytes: 10485760
4015
+ });
4016
+ const LIMITS = {
4017
+ orphanPreviewLimit: {
4018
+ min: 1,
4019
+ max: 1e4
4020
+ },
4021
+ maxAttachmentBytes: {
4022
+ min: 1,
4023
+ max: 209715200
4024
+ },
4025
+ maxJsonRefBytes: {
4026
+ min: 1,
4027
+ max: 104857600
4028
+ }
4029
+ };
4030
+ function intField(input, key, range) {
4031
+ const value = input[key];
4032
+ if (value === void 0) return DEFAULT_CONFIG[key];
4033
+ if (typeof value !== "number" || !Number.isInteger(value) || value < range.min || value > range.max) throw new Error(`配置项 ${key} 需为 ${range.min}..${range.max} 的整数`);
4034
+ return value;
4035
+ }
4036
+ /**
4037
+ * 把 patch 里的 `config` 归一成完整配置:缺省取默认值,未知键 / 非整数 / 越界都抛。
4038
+ * 抛错会让插件激活失败(响亮),而不是悄悄用半套配置跑。
4039
+ */
4040
+ function normalizeConfig(value) {
4041
+ if (value === void 0 || value === null) return DEFAULT_CONFIG;
4042
+ if (typeof value !== "object" || Array.isArray(value)) throw new Error("配置需为对象");
4043
+ const input = value;
4044
+ const known = new Set(Object.keys(LIMITS));
4045
+ for (const key of Object.keys(input)) if (!known.has(key)) throw new Error(`未知配置项:${key}`);
4046
+ return {
4047
+ orphanPreviewLimit: intField(input, "orphanPreviewLimit", LIMITS.orphanPreviewLimit),
4048
+ maxAttachmentBytes: intField(input, "maxAttachmentBytes", LIMITS.maxAttachmentBytes),
4049
+ maxJsonRefBytes: intField(input, "maxJsonRefBytes", LIMITS.maxJsonRefBytes)
3693
4050
  };
3694
4051
  }
4052
+ /**
4053
+ * 插件 Config:Cordis 接受 Standard Schema,`~standard.validate` 交回归一后的配置。
4054
+ * 宿主在激活前校验;`apply` 再归一一次(同一份事实,不会漂)。
4055
+ */
4056
+ const Config = { "~standard": {
4057
+ version: 1,
4058
+ vendor: "dsh-session-delete",
4059
+ validate(value) {
4060
+ try {
4061
+ return { value: normalizeConfig(value) };
4062
+ } catch (error) {
4063
+ return { issues: [{ message: error instanceof Error ? error.message : String(error) }] };
4064
+ }
4065
+ }
4066
+ } };
3695
4067
  //#endregion
3696
4068
  //#region src/ports/home.ts
3697
4069
  /**
@@ -3950,21 +4322,19 @@ const name = "@xrn1997/dsh-session-delete";
3950
4322
  * 三项都是在隔离数据根上实测注入成功、且本插件真正用到的宿主服务。**不进 inject 的**:
3951
4323
  * - `remote`:那是**浏览器半**的服务(asar `dsh-api-gateway/lib/client.js`:`super(ctx, "remote")`),
3952
4324
  * Node 半挂的是 `ctx.typertGateway`。把它写进 Node 半的 inject 会让插件永远 pending(同 `slots` 那次的形状)。
3953
- * - `sessions` / `sessionProjectionCache`:两者都按 `ctx.get(...)` **可选读**——`sessions` 只给 `evictLive`
3954
- * 用(活跃判据走 waterfall,见 src/ports/host-port.ts;服务缺席回 `unknown`),`sessionProjectionCache` 是标题的尽力读面。
3955
- * inject 一个可能没挂的服务 = 整个插件 pending。
3956
- * 它取不到时 `titleOf` 返回空串,正是契约允许的降级。
4325
+ * - `sessions` / `sessionProjectionCache` / `sessionPersistence`:都按 `ctx.get(...)` **可选读**——
4326
+ * `sessions` 给 `evictLive` 和 live header 用(活跃判据走 waterfall,见 src/ports/host-port.ts;
4327
+ * 服务缺席回 `unknown`),`sessionProjectionCache` 是标题的投影读面,`sessionPersistence` 是
4328
+ * `projectDirOf` / `titleOf` 的公开 header 读面(workspaceRegistry 硬注入它,标准组合必有)。
4329
+ * inject 一个可能没挂的服务 = 整个插件 pending;标题取不到时 `titleOf` 返回空串,正是契约允许的降级。
3957
4330
  * - `agents`:只给 handle 捕获用(`ctx.get('agents')`;缺席时 `ctx.inject(['agents'])` 等服务就绪再装)。
3958
4331
  * 它同样可能晚于本插件激活,写进插件 inject 只会让整个插件 pending。
3959
4332
  *
3960
- * `webServer` 是**浏览器半取数通道的承载者**(那条薄线):插件用它注册 `/dsh-session-delete-api`
3961
- * 前缀路由,浏览器半同源 fetch 那九个段名。它是宿主 web 组成里的硬依赖(`dsh-host-webserver` 的
3962
- * `super(ctx, "webServer")`),服务名与 `register({kind:'prefix', path, handler})` 的形状按
3963
- * `dsh-host-webserver/lib/index.js` 与 `README.md` 原文读实,不是猜的。
3964
- *
3965
- * `connection` 只喂请求栅栏的远程支(`src/api/request-trust.ts` ②),按 `ctx.get` 可选读:
3966
- * 服务缺席时插件照常激活,远程客户端 fail-closed。它不进 `inject`——写进去会让没有 web 组成的
3967
- * profile 整个停在 pending。
4333
+ * `connection` 是浏览器半取数通道的唯一承载者:九段注册到 `connection.fetch.register` 的
4334
+ * `/api/dsh-session-delete-api/*`,Host/Origin/浏览器认证由宿主 `/api` bridge 完成(ADR-0033)。
4335
+ * 支持面是 web UI 与桌面端,二者的标准组合都装 `client-connection`;服务缺席时插件停在 pending
4336
+ * ——可见的激活失败,而不是静默地没有入口。`webServer` 不再直接使用:exact route 仍由宿主的
4337
+ * `connection` 挂在其 `/api` 前缀上,插件不需要自己碰物理服务器。
3968
4338
  *
3969
4339
  * 清册的三层各有各的家:raw 绑定 `src/trash/storage-manifest.ts`、读面(对账 + 读)`src/trash/ledger.ts`、
3970
4340
  * 数据根 `src/ports/home.ts`。这里只把它们接起来,任何一层都不在组合根里长判据。
@@ -3972,16 +4342,18 @@ const name = "@xrn1997/dsh-session-delete";
3972
4342
  const inject = [
3973
4343
  "workspaceRegistry",
3974
4344
  "storageDomain",
3975
- "webServer"
4345
+ "connection"
3976
4346
  ];
3977
4347
  /**
3978
- * 装配顺序:**清单 → 端口 → 在场能力 → 动词依赖 → 前缀路由**(`DeleteDeps` 把这些依赖面合成一颗)。
4348
+ * 装配顺序:**配置归一 → 清单 → 端口 → 在场能力 → 动词依赖 → 分发器 → exact Fetch 路由**
4349
+ * (`DeleteDeps` 把这些依赖面合成一颗;`config` 是归一后的只读快照)。
3979
4350
  *
3980
4351
  * 路由用 `ctx.effect` 挂、disposer 归装载它的那根 fiber:插件卸载即摘路由(`register` 返回的
3981
- * disposer 是官方契约)。**同一进程里只注册一次**这件事由宿主把住:重复的 `(kind, path)`
3982
- * 在 `register` 里直接抛(`webserver: duplicate prefix route "…"`),响亮失败而不是悄悄覆盖。
4352
+ * disposer 是官方契约)。**同一进程里只注册一次**这件事由宿主把住:重复的 `(path, method)`
4353
+ * 在 `connection.fetch.register` 里直接抛,响亮失败而不是悄悄覆盖。
3983
4354
  */
3984
- function apply(ctx) {
4355
+ function apply(ctx, rawConfig) {
4356
+ const config = normalizeConfig(rawConfig);
3985
4357
  const fs = createFsPort();
3986
4358
  const home = resolveHome();
3987
4359
  const rawManifest = createHostStorageManifest(ctx);
@@ -3992,23 +4364,17 @@ function apply(ctx) {
3992
4364
  ledger: createTrashLedger(rawManifest, fs, home),
3993
4365
  home,
3994
4366
  presence: createMemberPresence(host),
4367
+ config,
4368
+ attachments: createAttachmentsPort(ctx),
3995
4369
  now: () => Date.now()
3996
4370
  };
3997
- const webServer = ctx.webServer;
3998
- const connection = createConnectionPort(ctx);
4371
+ const connection = ctx.get("connection");
4372
+ if (connection === void 0) throw new Error("session-delete: connection 服务缺席");
4373
+ const dispatch = createDispatcher(deps);
3999
4374
  ctx.effect(() => {
4000
- const dispose = webServer.register({
4001
- kind: "prefix",
4002
- path: SESSION_DELETE_API_PREFIX,
4003
- handler: createApiHandler(deps, {
4004
- bindHost: webServer.host,
4005
- connection
4006
- })
4007
- });
4008
- return () => {
4009
- dispose();
4010
- };
4011
- }, "session-delete: /dsh-session-delete-api routes");
4375
+ const dispose = registerConnectionRoutes(connection, dispatch);
4376
+ return () => dispose();
4377
+ }, "session-delete: /api exact Fetch routes");
4012
4378
  }
4013
4379
  //#endregion
4014
- export { apply, inject, name };
4380
+ export { Config, apply, inject, name };