dsh-zen-remote 2.3.10 → 2.4.0

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 (244) hide show
  1. package/CHANGELOG.md +44 -72
  2. package/README.md +94 -84
  3. package/README.zh-CN.md +90 -81
  4. package/bin/zen-remote.mjs +5 -0
  5. package/docs/interface.md +6 -6
  6. package/docs/remote-access.en.md +6 -4
  7. package/docs/remote-access.md +6 -4
  8. package/dsh-push.mjs +96 -10
  9. package/lib/adapters/dsh-better-sidebar/index.js +7 -3
  10. package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
  11. package/lib/admin-routes.js +10 -2
  12. package/lib/admin-routes.js.map +1 -1
  13. package/lib/cli/args.js +251 -0
  14. package/lib/cli/args.js.map +1 -0
  15. package/lib/cli/bootstrap.js +449 -0
  16. package/lib/cli/bootstrap.js.map +1 -0
  17. package/lib/cli/command-setup.js +484 -0
  18. package/lib/cli/command-setup.js.map +1 -0
  19. package/lib/cli/commands-client.js +359 -0
  20. package/lib/cli/commands-client.js.map +1 -0
  21. package/lib/cli/commands-host.js +1108 -0
  22. package/lib/cli/commands-host.js.map +1 -0
  23. package/lib/cli/commands-status.js +279 -0
  24. package/lib/cli/commands-status.js.map +1 -0
  25. package/lib/cli/config-writer.js +334 -0
  26. package/lib/cli/config-writer.js.map +1 -0
  27. package/lib/cli/context.js +10 -0
  28. package/lib/cli/context.js.map +1 -0
  29. package/lib/cli/i18n.js +620 -0
  30. package/lib/cli/i18n.js.map +1 -0
  31. package/lib/cli/ipc-client.js +160 -0
  32. package/lib/cli/ipc-client.js.map +1 -0
  33. package/lib/cli/ipc-server.js +464 -0
  34. package/lib/cli/ipc-server.js.map +1 -0
  35. package/lib/cli/main.js +245 -0
  36. package/lib/cli/main.js.map +1 -0
  37. package/lib/cli/qr-terminal.js +48 -0
  38. package/lib/cli/qr-terminal.js.map +1 -0
  39. package/lib/client-data/qr.js +286 -0
  40. package/lib/client-data/qr.js.map +1 -0
  41. package/lib/client-routes.js +308 -25
  42. package/lib/client-routes.js.map +1 -1
  43. package/lib/client.js +1013 -229
  44. package/lib/config.js +50 -12
  45. package/lib/config.js.map +1 -1
  46. package/lib/debug-report.js +28 -1
  47. package/lib/debug-report.js.map +1 -1
  48. package/lib/fetch-route-intercept.js +334 -26
  49. package/lib/fetch-route-intercept.js.map +1 -1
  50. package/lib/index.js +573 -366
  51. package/lib/index.js.map +1 -1
  52. package/lib/intercept.js +1833 -280
  53. package/lib/intercept.js.map +1 -1
  54. package/lib/lan-gate-server.cjs +36 -10
  55. package/lib/merge-streams.js +103 -11
  56. package/lib/merge-streams.js.map +1 -1
  57. package/lib/plugin-route-intercept.js +93 -63
  58. package/lib/plugin-route-intercept.js.map +1 -1
  59. package/lib/relay-access.js +8 -1
  60. package/lib/relay-access.js.map +1 -1
  61. package/lib/relay-client.js +133 -4
  62. package/lib/relay-client.js.map +1 -1
  63. package/lib/relay-pool.js +275 -0
  64. package/lib/relay-pool.js.map +1 -0
  65. package/lib/relay-server.js +269 -17
  66. package/lib/relay-server.js.map +1 -1
  67. package/lib/restart-fields.js +6 -4
  68. package/lib/restart-fields.js.map +1 -1
  69. package/lib/share-store.js +65 -27
  70. package/lib/share-store.js.map +1 -1
  71. package/lib/state-store.js +400 -0
  72. package/lib/state-store.js.map +1 -0
  73. package/lib/types/adapters/dsh-better-sidebar/index.d.ts +3 -2
  74. package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
  75. package/lib/types/admin-routes.d.ts.map +1 -1
  76. package/lib/types/cli/args.d.ts +78 -0
  77. package/lib/types/cli/args.d.ts.map +1 -0
  78. package/lib/types/cli/bootstrap.d.ts +170 -0
  79. package/lib/types/cli/bootstrap.d.ts.map +1 -0
  80. package/lib/types/cli/command-setup.d.ts +72 -0
  81. package/lib/types/cli/command-setup.d.ts.map +1 -0
  82. package/lib/types/cli/commands-client.d.ts +36 -0
  83. package/lib/types/cli/commands-client.d.ts.map +1 -0
  84. package/lib/types/cli/commands-host.d.ts +82 -0
  85. package/lib/types/cli/commands-host.d.ts.map +1 -0
  86. package/lib/types/cli/commands-status.d.ts +46 -0
  87. package/lib/types/cli/commands-status.d.ts.map +1 -0
  88. package/lib/types/cli/config-writer.d.ts +92 -0
  89. package/lib/types/cli/config-writer.d.ts.map +1 -0
  90. package/lib/types/cli/context.d.ts +39 -0
  91. package/lib/types/cli/context.d.ts.map +1 -0
  92. package/lib/types/cli/i18n.d.ts +32 -0
  93. package/lib/types/cli/i18n.d.ts.map +1 -0
  94. package/lib/types/cli/ipc-client.d.ts +83 -0
  95. package/lib/types/cli/ipc-client.d.ts.map +1 -0
  96. package/lib/types/cli/ipc-server.d.ts +113 -0
  97. package/lib/types/cli/ipc-server.d.ts.map +1 -0
  98. package/lib/types/cli/main.d.ts +57 -0
  99. package/lib/types/cli/main.d.ts.map +1 -0
  100. package/lib/types/cli/qr-terminal.d.ts +17 -0
  101. package/lib/types/cli/qr-terminal.d.ts.map +1 -0
  102. package/lib/types/client/RemoteStatusIcon.d.ts.map +1 -1
  103. package/lib/types/client/current-session-report.d.ts +4 -0
  104. package/lib/types/client/current-session-report.d.ts.map +1 -1
  105. package/lib/types/client/effects/model-group-side.d.ts.map +1 -1
  106. package/lib/types/client/effects/remote-session.d.ts.map +1 -1
  107. package/lib/types/client/locales.d.ts +47 -3
  108. package/lib/types/client/locales.d.ts.map +1 -1
  109. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
  110. package/lib/types/client/settings/settings-css.d.ts +1 -1
  111. package/lib/types/client/settings/settings-css.d.ts.map +1 -1
  112. package/lib/types/client/styles/remote-session.css.d.ts +1 -1
  113. package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
  114. package/lib/types/client/wizard/GatewayWizard.d.ts.map +1 -1
  115. package/lib/types/client/wizard/cards/ProxyCard.d.ts.map +1 -1
  116. package/lib/types/client/wizard/wizard-css.d.ts +1 -1
  117. package/lib/types/client/wizard/wizard-css.d.ts.map +1 -1
  118. package/lib/types/client-data/model-group-side.d.ts +23 -2
  119. package/lib/types/client-data/model-group-side.d.ts.map +1 -1
  120. package/lib/types/client-data/remote-status.d.ts +47 -1
  121. package/lib/types/client-data/remote-status.d.ts.map +1 -1
  122. package/lib/types/client-data/settings-form.d.ts +85 -1
  123. package/lib/types/client-data/settings-form.d.ts.map +1 -1
  124. package/lib/types/client-data/wizard.d.ts +56 -1
  125. package/lib/types/client-data/wizard.d.ts.map +1 -1
  126. package/lib/types/client-routes.d.ts +25 -1
  127. package/lib/types/client-routes.d.ts.map +1 -1
  128. package/lib/types/config.d.ts +40 -14
  129. package/lib/types/config.d.ts.map +1 -1
  130. package/lib/types/debug-report.d.ts.map +1 -1
  131. package/lib/types/fetch-route-intercept.d.ts +66 -8
  132. package/lib/types/fetch-route-intercept.d.ts.map +1 -1
  133. package/lib/types/index.d.ts +9 -2
  134. package/lib/types/index.d.ts.map +1 -1
  135. package/lib/types/intercept.d.ts +72 -10
  136. package/lib/types/intercept.d.ts.map +1 -1
  137. package/lib/types/merge-streams.d.ts +79 -1
  138. package/lib/types/merge-streams.d.ts.map +1 -1
  139. package/lib/types/plugin-route-intercept.d.ts +25 -9
  140. package/lib/types/plugin-route-intercept.d.ts.map +1 -1
  141. package/lib/types/relay-access.d.ts +3 -1
  142. package/lib/types/relay-access.d.ts.map +1 -1
  143. package/lib/types/relay-client.d.ts +23 -2
  144. package/lib/types/relay-client.d.ts.map +1 -1
  145. package/lib/types/relay-pool.d.ts +148 -0
  146. package/lib/types/relay-pool.d.ts.map +1 -0
  147. package/lib/types/relay-server.d.ts +30 -3
  148. package/lib/types/relay-server.d.ts.map +1 -1
  149. package/lib/types/restart-fields.d.ts +5 -5
  150. package/lib/types/restart-fields.d.ts.map +1 -1
  151. package/lib/types/share-store.d.ts.map +1 -1
  152. package/lib/types/state-store.d.ts +179 -0
  153. package/lib/types/state-store.d.ts.map +1 -0
  154. package/lib/types/upload-limits.d.ts +15 -0
  155. package/lib/types/upload-limits.d.ts.map +1 -1
  156. package/lib/types/virtual-id.d.ts +21 -0
  157. package/lib/types/virtual-id.d.ts.map +1 -1
  158. package/lib/types/wizard/probe.d.ts.map +1 -1
  159. package/lib/types/wizard/proxy-snippets.d.ts +3 -0
  160. package/lib/types/wizard/proxy-snippets.d.ts.map +1 -1
  161. package/lib/types/wizard/routes.d.ts +1 -0
  162. package/lib/types/wizard/routes.d.ts.map +1 -1
  163. package/lib/types/wizard/status.d.ts +9 -0
  164. package/lib/types/wizard/status.d.ts.map +1 -1
  165. package/lib/types/wizard/verify.d.ts +3 -0
  166. package/lib/types/wizard/verify.d.ts.map +1 -1
  167. package/lib/upload-limits.js +15 -0
  168. package/lib/upload-limits.js.map +1 -1
  169. package/lib/virtual-id.js +60 -0
  170. package/lib/virtual-id.js.map +1 -1
  171. package/lib/wizard/probe.js +46 -1
  172. package/lib/wizard/probe.js.map +1 -1
  173. package/lib/wizard/proxy-snippets.js +14 -1
  174. package/lib/wizard/proxy-snippets.js.map +1 -1
  175. package/lib/wizard/routes.js +23 -4
  176. package/lib/wizard/routes.js.map +1 -1
  177. package/lib/wizard/status.js +21 -0
  178. package/lib/wizard/status.js.map +1 -1
  179. package/lib/wizard/verify.js +31 -8
  180. package/lib/wizard/verify.js.map +1 -1
  181. package/package.json +8 -4
  182. package/pwa/icons/badge-96.png +0 -0
  183. package/pwa/icons/badge.svg +1 -0
  184. package/pwa/manifest.json +2 -1
  185. package/pwa/sw.js +3 -1
  186. package/scripts/check-debug-report.mjs +41 -1
  187. package/scripts/check-settings-form.mjs +14 -3
  188. package/scripts/check-settings-view.mjs +247 -0
  189. package/scripts/check-wizard-ui.mjs +37 -0
  190. package/src/adapters/dsh-better-sidebar/index.ts +7 -3
  191. package/src/admin-routes.ts +10 -2
  192. package/src/cli/args.ts +289 -0
  193. package/src/cli/bootstrap.ts +569 -0
  194. package/src/cli/command-setup.ts +540 -0
  195. package/src/cli/commands-client.ts +369 -0
  196. package/src/cli/commands-host.ts +1127 -0
  197. package/src/cli/commands-status.ts +296 -0
  198. package/src/cli/config-writer.ts +354 -0
  199. package/src/cli/context.ts +38 -0
  200. package/src/cli/i18n.ts +645 -0
  201. package/src/cli/ipc-client.ts +216 -0
  202. package/src/cli/ipc-server.ts +531 -0
  203. package/src/cli/main.ts +285 -0
  204. package/src/cli/qr-terminal.ts +49 -0
  205. package/src/client/RemoteStatusIcon.tsx +4 -1
  206. package/src/client/current-session-report.ts +28 -4
  207. package/src/client/effects/model-group-side.ts +19 -5
  208. package/src/client/effects/remote-session.ts +26 -6
  209. package/src/client/locales.ts +96 -6
  210. package/src/client/settings/SettingsSection.tsx +287 -92
  211. package/src/client/settings/settings-css.ts +18 -0
  212. package/src/client/styles/remote-session.css.ts +15 -1
  213. package/src/client/wizard/GatewayWizard.tsx +470 -109
  214. package/src/client/wizard/cards/ProxyCard.tsx +2 -1
  215. package/src/client/wizard/wizard-css.ts +23 -0
  216. package/src/client-data/model-group-side.ts +37 -10
  217. package/src/client-data/remote-status.ts +112 -17
  218. package/src/client-data/settings-form.ts +164 -8
  219. package/src/client-data/wizard.ts +112 -2
  220. package/src/client-routes.ts +334 -26
  221. package/src/config.ts +62 -16
  222. package/src/debug-report.ts +27 -1
  223. package/src/fetch-route-intercept.ts +381 -32
  224. package/src/index.ts +575 -370
  225. package/src/intercept.ts +1866 -286
  226. package/src/merge-streams.ts +155 -10
  227. package/src/plugin-route-intercept.ts +120 -71
  228. package/src/relay-access.ts +14 -3
  229. package/src/relay-client.ts +163 -5
  230. package/src/relay-pool.ts +387 -0
  231. package/src/relay-server.ts +312 -16
  232. package/src/restart-fields.ts +6 -4
  233. package/src/share-store.ts +59 -26
  234. package/src/state-store.ts +428 -0
  235. package/src/upload-limits.ts +16 -0
  236. package/src/virtual-id.ts +57 -0
  237. package/src/wizard/probe.ts +54 -2
  238. package/src/wizard/proxy-snippets.ts +17 -2
  239. package/src/wizard/routes.ts +24 -5
  240. package/src/wizard/status.ts +28 -0
  241. package/src/wizard/verify.ts +33 -11
  242. package/AGENTS.md +0 -185
  243. package/docs/adr/0001-plugin-bridge-forwards-plugin-routes.md +0 -15
  244. package/docs/adr/0002-relay-emulates-websocket.md +0 -8
@@ -71,6 +71,32 @@ export function assembleDebugReport(input: DebugReportInput): Record<string, unk
71
71
  // (a bare IP must not reach the report), serverUrl its scheme+class+port.
72
72
  if ('host' in input.configSummary) config.host = bindHostShape(input.configSummary.host)
73
73
  config.serverUrl = serverUrlShape(input.configSummary.serverUrl)
74
+ const status = redactValue(input.statusBody, context) as Record<string, unknown>
75
+ // MS-5: the pool status body's per-server rows each carry a REAL address;
76
+ // every one gets the same shape treatment `config.serverUrl` has — the
77
+ // deep pass would already downgrade a well-formed URL's host, but the
78
+ // shaper's normalization is the contract (scheme-default port omitted,
79
+ // unset → 'unset') and every row must meet it, not merely survive.
80
+ // MS-5-fix: the rebuilt row's BASE is the already-redacted row from the
81
+ // deep pass above (redactValue maps arrays 1:1, so index i aligns) —
82
+ // spreading the RAW input here would silently restore every field the
83
+ // pass had just cleaned, and a hostile handshake-supplied serverName
84
+ // would leak paths and hostnames into the report. Only `serverUrl` is
85
+ // overwritten, and its shape is computed from the RAW value: the redacted
86
+ // address (`http://lan-ip:3080`) no longer parses into scheme/host/port.
87
+ // A non-object raw item keeps its redacted counterpart, never the raw one.
88
+ const rawServers = (input.statusBody as { servers?: unknown }).servers
89
+ if (Array.isArray(rawServers)) {
90
+ const redactedServers = Array.isArray(status.servers) ? status.servers : []
91
+ status.servers = rawServers.map((rawItem, index) => {
92
+ const redacted = redactedServers[index]
93
+ if (rawItem === null || typeof rawItem !== 'object' || Array.isArray(rawItem)) return redacted
94
+ const base = redacted !== null && typeof redacted === 'object' && !Array.isArray(redacted)
95
+ ? (redacted as Record<string, unknown>)
96
+ : (redactValue(rawItem, context) as Record<string, unknown>)
97
+ return { ...base, serverUrl: serverUrlShape((rawItem as Record<string, unknown>).serverUrl) }
98
+ })
99
+ }
74
100
  const report: Record<string, unknown> = {
75
101
  _meta: {
76
102
  generatedAt: redactText(input.meta.generatedAt, context),
@@ -81,7 +107,7 @@ export function assembleDebugReport(input: DebugReportInput): Record<string, unk
81
107
  ...(input.meta.profileName === undefined ? {} : { profile: redactText(input.meta.profileName, context) }),
82
108
  },
83
109
  config,
84
- status: redactValue(input.statusBody, context),
110
+ status,
85
111
  ...(input.gatewaySummary === undefined ? {} : { gateway: redactValue(input.gatewaySummary, context) }),
86
112
  logs: input.logs.map((entry) => ({
87
113
  t: entry.t,
@@ -49,7 +49,9 @@
49
49
  import { RelayError } from './relay-client.js'
50
50
  import type { RelayClient } from './relay-client.js'
51
51
  import { fromVirtual } from './virtual-id.js'
52
+ import { restoreInvokeBinary } from './invoke-binary.js'
52
53
  import { MAX_UPLOAD_BYTES } from './relay-server.js'
54
+ import { MAX_FILE_MEDIA_BYTES } from './upload-limits.js'
53
55
 
54
56
  /** The host's upload route, verbatim from its registration (RT
55
57
  * dsh-client-file-upload lib/index.js:73). */
@@ -59,19 +61,106 @@ export const FILE_UPLOAD_PATH = '/api/session/uploadFileBinary'
59
61
  * lib/index.js:488). */
60
62
  export const SESSION_EXPORT_PATH = '/api/session.export'
61
63
 
64
+ /** The host's media route (P55-2): how a Markdown image `![a](path)` in a
65
+ * session body actually loads — the renderer resolves the path against the
66
+ * session cwd into an ABSOLUTE server path and the browser's `<img>` asks
67
+ * for `/api/file?path=<encoded>` natively (no `window.fetch`, no
68
+ * `sessionId`), landing in this very table (RT
69
+ * dsh-api-session-controller lib/types/media-references.js:
70
+ * `registerFetchRoute({ path: '/api/file', methods: ['GET','HEAD'],
71
+ * requestBody: 'buffered', … })`). */
72
+ export const FILE_MEDIA_PATH = '/api/file'
73
+
74
+ /** The byte cap one forwarded file may answer with — defined once in
75
+ * src/upload-limits.ts (the one home of every MiB-scale ceiling, per the
76
+ * upload unification and scripts/check-upload-limits.mjs), re-exported
77
+ * here for the consumers the route narrative belongs to. */
78
+ export { MAX_FILE_MEDIA_BYTES }
79
+
80
+ /** The response headers every forwarded file answer carries — the host's
81
+ * own /api/file set (RT lib/types/media-references.js BASE_HEADERS +
82
+ * NO_STORE), so a remote-served image meets exactly the sandbox the local
83
+ * route would have given it. */
84
+ const FILE_BASE_HEADERS: Record<string, string> = {
85
+ 'Cache-Control': 'private, no-store',
86
+ 'X-Content-Type-Options': 'nosniff',
87
+ 'Content-Security-Policy': "sandbox; default-src 'none'",
88
+ }
89
+
90
+ /** Extension → Content-Type for the forwarded answers. A fixed table (the
91
+ * host routes through `mime-types`; the reply-body vocabulary fits a table)
92
+ * with the same conservative default. */
93
+ const FILE_MEDIA_TYPES: Readonly<Record<string, string>> = {
94
+ png: 'image/png',
95
+ jpg: 'image/jpeg',
96
+ jpeg: 'image/jpeg',
97
+ gif: 'image/gif',
98
+ webp: 'image/webp',
99
+ svg: 'image/svg+xml',
100
+ bmp: 'image/bmp',
101
+ ico: 'image/x-icon',
102
+ avif: 'image/avif',
103
+ pdf: 'application/pdf',
104
+ mp4: 'video/mp4',
105
+ webm: 'video/webm',
106
+ mov: 'video/quicktime',
107
+ mp3: 'audio/mpeg',
108
+ wav: 'audio/wav',
109
+ ogg: 'audio/ogg',
110
+ json: 'application/json',
111
+ txt: 'text/plain; charset=utf-8',
112
+ log: 'text/plain; charset=utf-8',
113
+ md: 'text/markdown; charset=utf-8',
114
+ html: 'text/html; charset=utf-8',
115
+ }
116
+
117
+ /** The Content-Type one forwarded file answers with, read from its path
118
+ * extension (case-folded; no extension → the stream default). */
119
+ function mediaTypeOfPath(filePath: string): string {
120
+ const dot = filePath.lastIndexOf('.')
121
+ if (dot === -1) return 'application/octet-stream'
122
+ return FILE_MEDIA_TYPES[filePath.slice(dot + 1).toLowerCase()] ?? 'application/octet-stream'
123
+ }
124
+
62
125
  /** The remote upload cap, the relay's own 500 MiB (relay-server.ts explains
63
126
  * why the host has no number to mirror). Interpolated into the local
64
127
  * oversize refusal so the card names the real limit. */
65
128
  const UPLOAD_CAP_MIB = Math.floor(MAX_UPLOAD_BYTES / (1024 * 1024))
66
-
67
- const OVERSIZE_MESSAGE = `文件超过远程上传上限(${UPLOAD_CAP_MIB} MiB)`
68
- const OFFLINE_MESSAGE = '服务端离线,远程会话暂时无法上传文件'
69
- const MISMATCH_MESSAGE = '此远程会话属于其他主服务端'
70
- /** The mid-upload transport death (T5x): the socket died UNDER the body —
71
- * most plausibly the server's size gate cutting an undeclared oversize
72
- * upload, possibly a network fault. Not a link verdict (the upload route
73
- * never judges the link, T51-fix), so the card must not say 服务端离线. */
74
- const INTERRUPTED_MESSAGE = `上传中断,文件可能超过远程上传上限(${UPLOAD_CAP_MIB} MiB)或网络不稳定`
129
+ const FILE_MEDIA_CAP_MIB = Math.floor(MAX_FILE_MEDIA_BYTES / (1024 * 1024))
130
+
131
+ export type FetchRouteLang = 'zh' | 'en'
132
+
133
+ const FETCH_ROUTE_COPY = {
134
+ zh: {
135
+ oversize: `文件超过远程上传上限(${UPLOAD_CAP_MIB} MiB)`,
136
+ fileMediaOversize: `文件超过远程读取上限(${FILE_MEDIA_CAP_MIB} MiB)`,
137
+ offlineUpload: '服务端离线,远程会话暂时无法上传文件',
138
+ mismatch: '此远程会话属于其他主服务端',
139
+ interrupted: `上传中断,文件可能超过远程上传上限(${UPLOAD_CAP_MIB} MiB)或网络不稳定`,
140
+ exportUnsupported: '远程会话不支持导出',
141
+ offlineFile: '服务端离线,远程文件暂时无法读取',
142
+ fileFailed: '远程文件读取失败',
143
+ fileNotFound: '远程与本机都不存在该文件',
144
+ fileForbidden: '远程文件不可读取',
145
+ },
146
+ en: {
147
+ oversize: `File exceeds the remote upload limit (${UPLOAD_CAP_MIB} MiB)`,
148
+ fileMediaOversize: `File exceeds the remote read limit (${FILE_MEDIA_CAP_MIB} MiB)`,
149
+ offlineUpload: 'Server offline — cannot upload files to this remote session right now',
150
+ mismatch: 'This remote session belongs to another server',
151
+ interrupted: `Upload interrupted — the file may exceed the remote upload limit (${UPLOAD_CAP_MIB} MiB) or the connection dropped`,
152
+ exportUnsupported: 'Export is not supported for remote sessions',
153
+ offlineFile: 'Server offline — remote file cannot be read right now',
154
+ fileFailed: 'Failed to read remote file',
155
+ fileNotFound: 'File not found on the server or on this machine',
156
+ fileForbidden: 'Remote file is not readable',
157
+ },
158
+ } as const
159
+
160
+ function resolveFetchRouteLang(lang: FetchRouteLang | (() => FetchRouteLang) | undefined): FetchRouteLang {
161
+ const value = typeof lang === 'function' ? lang() : lang
162
+ return value === 'en' ? 'en' : 'zh'
163
+ }
75
164
 
76
165
  /** Verdict of {@link checkFetchRouteShape}. STRUCTURAL only: `ok` answers
77
166
  * "is this a connection service with a fetch-route table at all"; each
@@ -124,9 +213,14 @@ export function shouldFallbackInstall(state: {
124
213
  * Check the RAW connection service instance against the shape the wrap
125
214
  * depends on. Pure inspection — nothing is mutated, so a failed check
126
215
  * leaves every route exactly as it was. Only the structural facts refuse;
127
- * the two entries' facts are described in `notes` (they attach lazily).
216
+ * the entries' facts are described in `notes` (they attach lazily).
217
+ *
218
+ * `probeFileMedia` (P55-2) adds the `/api/file` entry's note and — load-
219
+ * bearing for the legacy probe-count pins — its table READ. It defaults to
220
+ * false so a caller without a `resolveRemoteFileScope` (every pre-P55-2
221
+ * call site and test) touches exactly the two routes it always did.
128
222
  */
129
- export function checkFetchRouteShape(connection: unknown): FetchRouteShapeCheck {
223
+ export function checkFetchRouteShape(connection: unknown, probeFileMedia = false): FetchRouteShapeCheck {
130
224
  if (!isRecord(connection) || Array.isArray(connection)) {
131
225
  return { ok: false, reasons: ['connection: not an object'] }
132
226
  }
@@ -139,6 +233,7 @@ export function checkFetchRouteShape(connection: unknown): FetchRouteShapeCheck
139
233
  notes: [
140
234
  describeEntry(routes.get(FILE_UPLOAD_PATH), 'upload', ['POST'], 'streaming'),
141
235
  describeEntry(routes.get(SESSION_EXPORT_PATH), 'export', ['GET', 'HEAD'], 'buffered'),
236
+ ...(probeFileMedia ? [describeEntry(routes.get(FILE_MEDIA_PATH), 'file', ['GET', 'HEAD'], 'buffered')] : []),
142
237
  ],
143
238
  }
144
239
  }
@@ -172,7 +267,7 @@ export interface FetchRouteFailureRecord {
172
267
  /** ISO timestamp of the moment the refusal was answered. */
173
268
  time: string
174
269
  /** Which wrapper answered it. */
175
- route: 'upload' | 'export'
270
+ route: 'upload' | 'export' | 'file'
176
271
  code: string
177
272
  }
178
273
 
@@ -208,6 +303,24 @@ export interface FetchRouteInterceptDiagnostics {
208
303
  exportBlocked: number
209
304
  /** The most recent refusals, oldest first, capped. */
210
305
  recentFailures: FetchRouteFailureRecord[]
306
+ // ---- the /api/file media wrap (P55-2) — present ONLY when the wiring
307
+ // handed a resolveRemoteFileScope over; absent otherwise, which is how the
308
+ // legacy diagnostics consumers (and tests) tell the two generations apart.
309
+ /** The media entry currently holds our wrapper. */
310
+ fileWrapped?: boolean
311
+ /** The media entry was absent at last check and the attach retry is still
312
+ * running. */
313
+ filePending?: boolean
314
+ /** The media entry was present but misshaped — attach refused for THIS
315
+ * route only; the reason is stable diagnostics text. */
316
+ fileAttachRefused?: string | undefined
317
+ /** Every media request that entered the wrapper, passthroughs included. */
318
+ fileCalls?: number
319
+ /** Media requests served through the relay. */
320
+ fileForwarded?: number
321
+ /** Media requests refused (offline, mismatch, relay/remote errors,
322
+ * oversize). */
323
+ fileRefused?: number
211
324
  }
212
325
 
213
326
  export interface InstallFetchRouteInterceptOptions {
@@ -215,11 +328,27 @@ export interface InstallFetchRouteInterceptOptions {
215
328
  * (`ctx.connection[symbols.original]`) — the one whose `fetchRoutes` Map
216
329
  * the shared handler reads per request. */
217
330
  connection: object
218
- /** The client relay client (T23a) the uploads travel through. */
219
- relay: RelayClient
220
- /** The CURRENT handshake's server id, read live per call; `undefined`
221
- * makes every remote upload a `remote-offline`. */
222
- getServerId: () => string | undefined
331
+ /** SINGLE-SERVER mode: the client relay client (T23a) the uploads travel
332
+ * through. Required together with {@link getServerId} unless
333
+ * {@link getRelayForServer} is given (MS-5's pool wiring). */
334
+ relay?: RelayClient
335
+ /** SINGLE-SERVER mode: the CURRENT handshake's server id, read live per
336
+ * call; `undefined` makes every remote upload a `remote-offline`. */
337
+ getServerId?: () => string | undefined
338
+ /** MS-4: the relay client serving ONE paired server, looked up by the
339
+ * virtual id's serverId half per upload (the connection pool's
340
+ * `getByServerId`). When given, routing reads it alone: `undefined` (a
341
+ * server the pool does not hold) answers `remote-mismatch`, a held client
342
+ * that is not online `remote-offline`. Absent — the single-server wiring —
343
+ * the upload goes through `relay` exactly when the id belongs to
344
+ * `getServerId()`, with the original verdict order. */
345
+ getRelayForServer?: (serverId: string) => RelayClient | undefined
346
+ /** P55-2: the intercept handle's session-attribution answer (live, so the
347
+ * wiring forwards `interceptHandle?.resolveRemoteFileScope`). Its PRESENCE
348
+ * is the media mount's switch: without it, `/api/file` is never probed,
349
+ * wrapped, or counted — the pre-P55-2 behavior, byte for byte (the
350
+ * legacy probe-count pins depend on this). */
351
+ resolveRemoteFileScope?: (path: string) => { serverId: string; sessionId: string } | undefined
223
352
  /** Progress logging, wired to the context logger by index.ts. */
224
353
  log?: (format: string, ...args: unknown[]) => void
225
354
  /** The lazy-attach retry cadence (T51-fix): how often the table is
@@ -227,6 +356,9 @@ export interface InstallFetchRouteInterceptOptions {
227
356
  * before giving up. Defaults 3000 ms × 10; tests shrink both. */
228
357
  attachRetryMs?: number
229
358
  attachAttempts?: number
359
+ /** Language for error messages (`'zh'` default, `'en'` English). Accepts a
360
+ * getter for live resolution. */
361
+ lang?: FetchRouteLang | (() => FetchRouteLang) | undefined
230
362
  }
231
363
 
232
364
  export interface FetchRouteInterceptHandle {
@@ -295,15 +427,31 @@ const LINK_DOWN_CODES: ReadonlySet<string> = new Set(['offline', 'unpaired', 're
295
427
  * present-but-misshaped.
296
428
  */
297
429
  export function installFetchRouteIntercept(options: InstallFetchRouteInterceptOptions): FetchRouteInterceptHandle {
298
- const { connection, relay, getServerId, log } = options
430
+ const { connection, relay, getServerId, getRelayForServer, log } = options
431
+ // Exactly one mode, like installIntercept: the pool face or the single
432
+ // row client — a wiring that passes neither would silently send every
433
+ // remote upload to remote-offline, so it is refused here instead.
434
+ if (getRelayForServer === undefined && (relay === undefined || getServerId === undefined)) {
435
+ throw new Error('installFetchRouteIntercept requires either `relay` + `getServerId` (single server) or `getRelayForServer`')
436
+ }
437
+ // The single-server facts, proven present by the guard above. The
438
+ // single-server code path reads THESE (never the optionals), so the old
439
+ // behavior stays byte-identical without a narrowing dance.
440
+ const singleRelay = relay as RelayClient
441
+ const singleGetServerId = getServerId as () => string | undefined
442
+ const resolveRemoteFileScope = options.resolveRemoteFileScope
443
+ // The media mount's switch (P55-2): its presence probes, wraps and counts
444
+ // `/api/file`; its absence keeps every pre-P55-2 behavior byte-identical —
445
+ // including the exact fetchRoutes probe counts the legacy tests pin.
446
+ const fileMediaEnabled = resolveRemoteFileScope !== undefined
299
447
  const attachRetryMs = options.attachRetryMs ?? DEFAULT_ATTACH_RETRY_MS
300
448
  const attachAttempts = options.attachAttempts ?? DEFAULT_ATTACH_ATTEMPTS
301
- const shape = checkFetchRouteShape(connection)
449
+ const shape = checkFetchRouteShape(connection, fileMediaEnabled)
302
450
  const routes = (connection as { fetchRoutes: Map<string, FetchRouteEntry> }).fetchRoutes
303
451
 
304
452
  let installed = true
305
453
  const failures: FetchRouteFailureRecord[] = []
306
- const counters = { uploadCalls: 0, uploadForwarded: 0, uploadRefused: 0, exportBlocked: 0 }
454
+ const counters = { uploadCalls: 0, uploadForwarded: 0, uploadRefused: 0, exportBlocked: 0, fileCalls: 0, fileForwarded: 0, fileRefused: 0 }
307
455
 
308
456
  // The per-route attach state. `undefined` entry = absent so far (the
309
457
  // retry may still land it); a refusal string = present-but-misshaped,
@@ -314,17 +462,37 @@ export function installFetchRouteIntercept(options: InstallFetchRouteInterceptOp
314
462
  let exportEntry: FetchRouteEntry | undefined
315
463
  let savedExport: FetchRouteEntry['fetch'] | undefined
316
464
  let exportAttachRefused: string | undefined
465
+ let fileEntry: FetchRouteEntry | undefined
466
+ let savedFile: FetchRouteEntry['fetch'] | undefined
467
+ let fileAttachRefused: string | undefined
317
468
 
469
+ const copy = () => FETCH_ROUTE_COPY[resolveFetchRouteLang(options.lang)]
318
470
  const recordFailure = (route: FetchRouteFailureRecord['route'], code: string): void => {
319
471
  if (failures.length >= MAX_FAILURES) failures.shift()
320
472
  failures.push({ time: new Date().toISOString(), route, code })
321
473
  }
322
474
 
475
+ /** The relay client one virtual id's upload travels through, or the
476
+ * refusal. Single-server mode keeps the original order (offline before
477
+ * mismatch); pool mode (MS-4) asks the pool for the id's own server. */
478
+ const uploadRelayFor = (serverId: string): RelayClient | 'remote-offline' | 'remote-mismatch' => {
479
+ if (getRelayForServer !== undefined) {
480
+ const target = getRelayForServer(serverId)
481
+ if (target === undefined) return 'remote-mismatch'
482
+ return target.state === 'online' ? target : 'remote-offline'
483
+ }
484
+ const current = singleGetServerId()
485
+ if (singleRelay.state !== 'online' || current === undefined) return 'remote-offline'
486
+ if (serverId !== current) return 'remote-mismatch'
487
+ return singleRelay
488
+ }
489
+
323
490
  const wrappedUpload = async (request: Request): Promise<Response> => {
324
491
  counters.uploadCalls += 1
325
492
  // The other half may have registered while we were installing: every
326
493
  // real call re-probes a still-missing route before doing anything else.
327
494
  if (installed && exportEntry === undefined && exportAttachRefused === undefined) attachExport()
495
+ if (installed && fileMediaEnabled && fileEntry === undefined && fileAttachRefused === undefined) attachFile()
328
496
  const passthrough = (): Promise<Response> =>
329
497
  (savedUpload as FetchRouteEntry['fetch']).call(uploadEntry, request)
330
498
  // An uninstalled (chained-over) wrap is INERT, not absent.
@@ -354,27 +522,27 @@ export function installFetchRouteIntercept(options: InstallFetchRouteInterceptOp
354
522
  counters.uploadRefused += 1
355
523
  recordFailure('upload', 'payload-too-large')
356
524
  log?.('upload for %s refused locally: payload-too-large (%s declared)', sessionId, declaredLength)
357
- return uploadFailure('payload-too-large', OVERSIZE_MESSAGE)
525
+ return uploadFailure('payload-too-large', copy().oversize)
358
526
  }
359
- const serverId = getServerId()
360
- if (relay.state !== 'online' || serverId === undefined) {
527
+ const target = uploadRelayFor(parts.serverId)
528
+ if (target === 'remote-offline') {
361
529
  counters.uploadRefused += 1
362
530
  recordFailure('upload', 'remote-offline')
363
531
  log?.('upload for %s refused: relay not online', sessionId)
364
- return uploadFailure('remote-offline', OFFLINE_MESSAGE)
532
+ return uploadFailure('remote-offline', copy().offlineUpload)
365
533
  }
366
- if (parts.serverId !== serverId) {
534
+ if (target === 'remote-mismatch') {
367
535
  counters.uploadRefused += 1
368
536
  recordFailure('upload', 'remote-mismatch')
369
537
  log?.('upload for %s refused: belongs to another server', sessionId)
370
- return uploadFailure('remote-mismatch', MISMATCH_MESSAGE)
538
+ return uploadFailure('remote-mismatch', copy().mismatch)
371
539
  }
372
540
  counters.uploadForwarded += 1
373
541
  try {
374
542
  // The request's own body stream rides to the server UNREAD by us; the
375
543
  // browser's abort signal rides along so a cancelled picker closes the
376
544
  // relay round-trip too.
377
- const result = await relay.upload(
545
+ const result = await target.upload(
378
546
  { sessionId: parts.id, name: firstQuery(url, 'name'), body: request.body, bytes: declared },
379
547
  request.signal,
380
548
  )
@@ -400,9 +568,9 @@ export function installFetchRouteIntercept(options: InstallFetchRouteInterceptOp
400
568
  // judges the link — while a status-carrying `offline` is the relay
401
569
  // CHAIN answering 502/503/504, which IS the gateway being down.
402
570
  if (error.code === 'offline' && error.status === undefined) {
403
- return uploadFailure('upload-interrupted', INTERRUPTED_MESSAGE)
571
+ return uploadFailure('upload-interrupted', copy().interrupted)
404
572
  }
405
- return uploadFailure('remote-offline', OFFLINE_MESSAGE)
573
+ return uploadFailure('remote-offline', copy().offlineUpload)
406
574
  }
407
575
  // A business answer — the relay's own refusal (`not-shared`,
408
576
  // `payload-too-large`, …) or the upstream route's failure envelope —
@@ -415,6 +583,7 @@ export function installFetchRouteIntercept(options: InstallFetchRouteInterceptOp
415
583
 
416
584
  const wrappedExport = async (request: Request): Promise<Response> => {
417
585
  if (installed && uploadEntry === undefined && uploadAttachRefused === undefined) attachUpload()
586
+ if (installed && fileMediaEnabled && fileEntry === undefined && fileAttachRefused === undefined) attachFile()
418
587
  const passthrough = (): Promise<Response> =>
419
588
  savedExport !== undefined ? savedExport.call(exportEntry, request) : Promise.resolve(new Response('not found', { status: 404 }))
420
589
  if (!installed) return passthrough()
@@ -430,7 +599,144 @@ export function installFetchRouteIntercept(options: InstallFetchRouteInterceptOp
430
599
  if (parts === undefined) return passthrough()
431
600
  counters.exportBlocked += 1
432
601
  recordFailure('export', 'remote-unsupported')
433
- return statusFailure(403, 'remote-unsupported', '远程会话不支持导出')
602
+ return statusFailure(403, 'remote-unsupported', copy().exportUnsupported)
603
+ }
604
+
605
+ /** One /api/file answer the relay could not serve, with the honest
606
+ * status. The `<img>` consumer reads the status alone (it renders no
607
+ * error body), so unlike the upload wrap these keep real statuses — a
608
+ * 200-with-envelope here would render a BROKEN image while pretending
609
+ * success. */
610
+ const fetchRemoteFile = async (
611
+ scope: { serverId: string; sessionId: string },
612
+ filePath: string,
613
+ request: Request,
614
+ passthrough: () => Promise<Response>,
615
+ ): Promise<Response> => {
616
+ const target = uploadRelayFor(scope.serverId)
617
+ if (target === 'remote-offline') {
618
+ counters.fileRefused += 1
619
+ recordFailure('file', 'remote-offline')
620
+ return statusFailure(503, 'remote-offline', copy().offlineFile)
621
+ }
622
+ if (target === 'remote-mismatch') {
623
+ counters.fileRefused += 1
624
+ recordFailure('file', 'remote-mismatch')
625
+ return statusFailure(403, 'remote-mismatch', copy().mismatch)
626
+ }
627
+ if (typeof (target as { invoke?: unknown }).invoke !== 'function') {
628
+ counters.fileRefused += 1
629
+ recordFailure('file', 'internal')
630
+ return statusFailure(502, 'internal', copy().fileFailed)
631
+ }
632
+ const mediaType = mediaTypeOfPath(filePath)
633
+ try {
634
+ if (request.method === 'HEAD') {
635
+ // Size only: stat answers `bytes` (RT dsh-api-workspace-files
636
+ // lib/typert.remote-client.js — the result carries
637
+ // absolutePath/version and an OPTIONAL `bytes`).
638
+ const stat = await target.invoke('workspaceFiles', 'stat', { workspaceFileScopeId: scope.sessionId, path: filePath }, request.signal)
639
+ const size = isRecord(stat) && typeof stat.bytes === 'number' ? stat.bytes : undefined
640
+ if (size !== undefined && size > MAX_FILE_MEDIA_BYTES) {
641
+ counters.fileRefused += 1
642
+ return statusFailure(413, 'file-too-large', copy().fileMediaOversize)
643
+ }
644
+ return new Response(null, {
645
+ status: 200,
646
+ headers: {
647
+ ...FILE_BASE_HEADERS,
648
+ 'Content-Type': mediaType,
649
+ ...(size !== undefined ? { 'Content-Length': String(size) } : {}),
650
+ },
651
+ })
652
+ }
653
+ // Whole file: readBytes answers a marker-encoded result
654
+ // (`data: {$zr-u8: base64}` on the JSON channel), restored here.
655
+ const raw = await target.invoke(
656
+ 'workspaceFiles',
657
+ 'readBytes',
658
+ { workspaceFileScopeId: scope.sessionId, path: filePath, options: {} },
659
+ request.signal,
660
+ )
661
+ const restored = restoreInvokeBinary(raw)
662
+ const data = isRecord(restored) && restored.data instanceof Uint8Array ? (restored.data as Uint8Array) : undefined
663
+ if (data === undefined) {
664
+ counters.fileRefused += 1
665
+ recordFailure('file', 'internal')
666
+ return statusFailure(502, 'internal', copy().fileFailed)
667
+ }
668
+ if (data.byteLength > MAX_FILE_MEDIA_BYTES) {
669
+ counters.fileRefused += 1
670
+ return statusFailure(413, 'file-too-large', copy().fileMediaOversize)
671
+ }
672
+ return new Response(data.slice(), {
673
+ status: 200,
674
+ headers: { ...FILE_BASE_HEADERS, 'Content-Type': mediaType, 'Content-Length': String(data.byteLength) },
675
+ })
676
+ } catch (error) {
677
+ const code = error instanceof RelayError ? error.code : 'internal'
678
+ recordFailure('file', code)
679
+ log?.('file %s for %s failed through the relay: %s', filePath, scope.sessionId, code)
680
+ // The file the session cited may exist on BOTH machines (the reply
681
+ // quoted a path the sub-client also has, or the remote record is
682
+ // stale): a not-found answer falls back to the LOCAL route once —
683
+ // its 200 is adopted, anything else becomes our 404.
684
+ if (code === 'workspace-file/not-found' || code === 'FS_NOT_FOUND') {
685
+ const local = await passthrough()
686
+ if (local.status === 200) return local
687
+ counters.fileRefused += 1
688
+ return statusFailure(404, 'file-not-found', copy().fileNotFound)
689
+ }
690
+ counters.fileRefused += 1
691
+ if (code === 'workspace-file/too-large' || code === 'FS_TOO_LARGE') {
692
+ return statusFailure(413, 'file-too-large', copy().fileMediaOversize)
693
+ }
694
+ if (
695
+ code === 'workspace-file/not-regular-file' ||
696
+ code === 'FS_NOT_REGULAR_FILE' ||
697
+ code === 'FS_PERMISSION_DENIED' ||
698
+ code === 'FS_SANDBOX_DENIED' ||
699
+ code === 'not-shared'
700
+ ) {
701
+ return statusFailure(403, code, copy().fileForbidden)
702
+ }
703
+ if (LINK_DOWN_CODES.has(code)) return statusFailure(503, code, copy().offlineFile)
704
+ return statusFailure(502, code, copy().fileFailed)
705
+ }
706
+ }
707
+
708
+ const wrappedFile = async (request: Request): Promise<Response> => {
709
+ counters.fileCalls += 1
710
+ // The other halves may have registered while we were installing (the
711
+ // same courtesy the upload and export wrappers pay each other).
712
+ if (installed && uploadEntry === undefined && uploadAttachRefused === undefined) attachUpload()
713
+ if (installed && exportEntry === undefined && exportAttachRefused === undefined) attachExport()
714
+ const passthrough = (): Promise<Response> =>
715
+ savedFile !== undefined ? savedFile.call(fileEntry, request) : Promise.resolve(new Response('not found', { status: 404 }))
716
+ // An uninstalled (chained-over) wrap is INERT, not absent.
717
+ if (!installed) return passthrough()
718
+ if (request.method !== 'GET' && request.method !== 'HEAD') return passthrough()
719
+ let url: URL
720
+ try {
721
+ url = new URL(request.url)
722
+ } catch {
723
+ return passthrough()
724
+ }
725
+ if (url.pathname !== FILE_MEDIA_PATH) return passthrough()
726
+ const filePath = firstQuery(url, 'path')
727
+ if (filePath === undefined || filePath === '' || filePath.includes('\0')) return passthrough()
728
+ // Which remote session owns this path — an explicit VIRTUAL sessionId
729
+ // in the URL decides on its own (forward-compat for callers that know
730
+ // their session); otherwise the intercept handle attributes by cwd and
731
+ // current-session. `undefined` = the LOCAL route's business.
732
+ const explicit = firstQuery(url, 'sessionId')
733
+ const explicitParts = explicit === undefined || explicit === '' ? undefined : fromVirtual(explicit)
734
+ const scope = explicitParts !== undefined
735
+ ? { serverId: explicitParts.serverId, sessionId: explicitParts.id }
736
+ : resolveRemoteFileScope?.(filePath)
737
+ if (scope === undefined) return passthrough()
738
+ counters.fileForwarded += 1
739
+ return fetchRemoteFile(scope, filePath, request, passthrough)
434
740
  }
435
741
 
436
742
  // ---- the per-route attach (T51-fix) --------------------------------------
@@ -475,10 +781,33 @@ export function installFetchRouteIntercept(options: InstallFetchRouteInterceptOp
475
781
  return true
476
782
  }
477
783
 
784
+ /** The media half of {@link attachUpload} (P55-2). Runs only when the
785
+ * wiring handed a `resolveRemoteFileScope` over — without it the route is
786
+ * never probed at all, which is what keeps the legacy installs (and the
787
+ * probe-count pins) byte-identical. */
788
+ const attachFile = (): boolean => {
789
+ if (!fileMediaEnabled || !installed) return true
790
+ if (fileEntry !== undefined) return true
791
+ const candidate = routes.get(FILE_MEDIA_PATH)
792
+ if (candidate === undefined) return false
793
+ const problem = entryProblem(candidate, ['GET', 'HEAD'], 'buffered')
794
+ if (problem !== undefined) {
795
+ fileAttachRefused = problem
796
+ log?.('file media route attach refused: %s', problem)
797
+ return true
798
+ }
799
+ fileEntry = candidate
800
+ savedFile = fileEntry.fetch
801
+ fileEntry.fetch = wrappedFile
802
+ log?.('file media route attached')
803
+ return true
804
+ }
805
+
478
806
  // First attach, right now: whatever the table already held is wrapped
479
807
  // before this returns.
480
808
  attachUpload()
481
809
  attachExport()
810
+ attachFile()
482
811
 
483
812
  // The bounded retry for what was absent (T51-fix): a probe every
484
813
  // attachRetryMs, at most attachAttempts of them, stopped early once
@@ -497,7 +826,9 @@ export function installFetchRouteIntercept(options: InstallFetchRouteInterceptOp
497
826
  // itself, so once a route's attach is refused it must not keep the
498
827
  // bounded retry spinning idle past it — only a route that is still
499
828
  // genuinely absent counts as pending. (Mirrors the `uploadPending` /
500
- // `exportPending` diagnostics flags.)
829
+ // `exportPending` / `filePending` diagnostics flags.) The media clause
830
+ // exists only when the scope resolver was handed over (P55-2).
831
+ (fileMediaEnabled && fileEntry === undefined && fileAttachRefused === undefined) ||
501
832
  (uploadEntry === undefined && uploadAttachRefused === undefined) ||
502
833
  (exportEntry === undefined && exportAttachRefused === undefined)
503
834
  if (stillPending() && attempts < attachAttempts) {
@@ -505,6 +836,7 @@ export function installFetchRouteIntercept(options: InstallFetchRouteInterceptOp
505
836
  attempts += 1
506
837
  attachUpload()
507
838
  attachExport()
839
+ attachFile()
508
840
  if (!stillPending() || attempts >= attachAttempts) stopRetrying()
509
841
  }, attachRetryMs)
510
842
  if (typeof retryTimer.unref === 'function') retryTimer.unref()
@@ -520,6 +852,7 @@ export function installFetchRouteIntercept(options: InstallFetchRouteInterceptOp
520
852
  // original would silently drop ITS wrap. Ours goes inert instead.
521
853
  if (uploadEntry !== undefined && uploadEntry.fetch === wrappedUpload) uploadEntry.fetch = savedUpload as FetchRouteEntry['fetch']
522
854
  if (exportEntry !== undefined && exportEntry.fetch === wrappedExport) exportEntry.fetch = savedExport as FetchRouteEntry['fetch']
855
+ if (fileEntry !== undefined && fileEntry.fetch === wrappedFile) fileEntry.fetch = savedFile as FetchRouteEntry['fetch']
523
856
  },
524
857
  diagnostics(): FetchRouteInterceptDiagnostics {
525
858
  return {
@@ -531,7 +864,23 @@ export function installFetchRouteIntercept(options: InstallFetchRouteInterceptOp
531
864
  exportPending: installed && exportEntry === undefined && exportAttachRefused === undefined,
532
865
  uploadAttachRefused,
533
866
  exportAttachRefused,
534
- ...counters,
867
+ // The upload/export counters spread verbatim; the file counters and
868
+ // flags stay ABSENT unless the media mount is enabled (P55-2), so a
869
+ // pre-P55-2 diagnostics consumer sees the exact old key set.
870
+ uploadCalls: counters.uploadCalls,
871
+ uploadForwarded: counters.uploadForwarded,
872
+ uploadRefused: counters.uploadRefused,
873
+ exportBlocked: counters.exportBlocked,
874
+ ...(fileMediaEnabled
875
+ ? {
876
+ fileWrapped: fileEntry !== undefined && fileEntry.fetch === wrappedFile,
877
+ filePending: installed && fileEntry === undefined && fileAttachRefused === undefined,
878
+ fileAttachRefused,
879
+ fileCalls: counters.fileCalls,
880
+ fileForwarded: counters.fileForwarded,
881
+ fileRefused: counters.fileRefused,
882
+ }
883
+ : {}),
535
884
  recentFailures: [...failures],
536
885
  }
537
886
  },