dsh-zen-remote 2.3.6 → 2.3.8

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 (80) hide show
  1. package/AGENTS.md +15 -5
  2. package/CHANGELOG.md +83 -0
  3. package/README.md +2 -2
  4. package/README.zh-CN.md +2 -2
  5. package/docs/interface.md +10 -4
  6. package/docs/remote-access.en.md +6 -0
  7. package/docs/remote-access.md +6 -0
  8. package/lan-gate.mjs +7 -0
  9. package/lib/client-pairing.js +10 -0
  10. package/lib/client-pairing.js.map +1 -1
  11. package/lib/client-routes.js +183 -94
  12. package/lib/client-routes.js.map +1 -1
  13. package/lib/client.js +505 -78
  14. package/lib/debug-redact.js +253 -0
  15. package/lib/debug-redact.js.map +1 -0
  16. package/lib/debug-report.js +60 -0
  17. package/lib/debug-report.js.map +1 -0
  18. package/lib/fetch-route-intercept.js +23 -0
  19. package/lib/fetch-route-intercept.js.map +1 -1
  20. package/lib/index.js +293 -45
  21. package/lib/index.js.map +1 -1
  22. package/lib/log-ring.js +76 -0
  23. package/lib/log-ring.js.map +1 -0
  24. package/lib/merge-streams.js +55 -19
  25. package/lib/merge-streams.js.map +1 -1
  26. package/lib/relay-filter.js +4 -3
  27. package/lib/relay-filter.js.map +1 -1
  28. package/lib/relay-server.js +5 -3
  29. package/lib/relay-server.js.map +1 -1
  30. package/lib/types/client/effects/remote-drop.d.ts +20 -8
  31. package/lib/types/client/effects/remote-drop.d.ts.map +1 -1
  32. package/lib/types/client/locales.d.ts +8 -1
  33. package/lib/types/client/locales.d.ts.map +1 -1
  34. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
  35. package/lib/types/client/styles/misc.css.d.ts +1 -1
  36. package/lib/types/client/styles/misc.css.d.ts.map +1 -1
  37. package/lib/types/client-data/remote-drop.d.ts +64 -0
  38. package/lib/types/client-data/remote-drop.d.ts.map +1 -1
  39. package/lib/types/client-data/settings-form.d.ts +3 -0
  40. package/lib/types/client-data/settings-form.d.ts.map +1 -1
  41. package/lib/types/client-pairing.d.ts +8 -0
  42. package/lib/types/client-pairing.d.ts.map +1 -1
  43. package/lib/types/client-routes.d.ts +23 -0
  44. package/lib/types/client-routes.d.ts.map +1 -1
  45. package/lib/types/debug-redact.d.ts +68 -0
  46. package/lib/types/debug-redact.d.ts.map +1 -0
  47. package/lib/types/debug-report.d.ts +58 -0
  48. package/lib/types/debug-report.d.ts.map +1 -0
  49. package/lib/types/fetch-route-intercept.d.ts +26 -0
  50. package/lib/types/fetch-route-intercept.d.ts.map +1 -1
  51. package/lib/types/index.d.ts +7 -1
  52. package/lib/types/index.d.ts.map +1 -1
  53. package/lib/types/log-ring.d.ts +57 -0
  54. package/lib/types/log-ring.d.ts.map +1 -0
  55. package/lib/types/merge-streams.d.ts +27 -0
  56. package/lib/types/merge-streams.d.ts.map +1 -1
  57. package/lib/types/relay-filter.d.ts +4 -3
  58. package/lib/types/relay-filter.d.ts.map +1 -1
  59. package/lib/types/relay-server.d.ts.map +1 -1
  60. package/package.json +6 -3
  61. package/pwa/app.css +13 -2
  62. package/scripts/check-debug-report.mjs +277 -0
  63. package/scripts/check-remote-drop.mjs +130 -0
  64. package/scripts/check-tablet-safe-area.mjs +49 -0
  65. package/src/client/effects/remote-drop.ts +271 -24
  66. package/src/client/locales.ts +20 -4
  67. package/src/client/settings/SettingsSection.tsx +120 -3
  68. package/src/client/styles/misc.css.ts +38 -0
  69. package/src/client-data/remote-drop.ts +103 -0
  70. package/src/client-data/settings-form.ts +3 -0
  71. package/src/client-pairing.ts +11 -0
  72. package/src/client-routes.ts +196 -90
  73. package/src/debug-redact.ts +290 -0
  74. package/src/debug-report.ts +93 -0
  75. package/src/fetch-route-intercept.ts +29 -0
  76. package/src/index.ts +282 -43
  77. package/src/log-ring.ts +101 -0
  78. package/src/merge-streams.ts +71 -16
  79. package/src/relay-filter.ts +4 -3
  80. package/src/relay-server.ts +5 -3
@@ -125,4 +125,42 @@ export const MISC_CSS = ` /* ---------- hero composer on mobile ----------
125
125
  display: none !important;
126
126
  }
127
127
  }
128
+
129
+ /* ---------- tablet standalone PWA: pay the safe area above 1023px ---------- */
130
+ /* The gateway serves viewport-fit=cover to every non-desktop device (see
131
+ coverViewport in lan-gate-server), phone-chrome only manages the meta at
132
+ <=1023px, and every compensation rule in layout.css lives behind the same
133
+ 1023px breakpoint and the data-mobile-nav frame marker MobileNavOverlay
134
+ only sets there. A wide tablet's home-screen PWA — iPad landscape, the
135
+ 12.9"/13" iPad portrait at exactly 1024, Android tablets — therefore
136
+ painted under the status bar with nothing paying the inset: the top row
137
+ of controls sat in the system area and was hard to tap.
138
+
139
+ Anchor: the official AppFrame (ui-layout AppFrame.tsx) is the direct
140
+ parent of the [data-shell-overlay] layer — the same element
141
+ MobileNavOverlay's findFrame() resolves — and the frame already pads
142
+ ITSELF for the Windows titlebar (padding-top: --dsh-windows-titlebar-height
143
+ in AppFrame.module.css), so padding the frame is the host's own pattern
144
+ for system bars.
145
+
146
+ Structural no-op everywhere else, by construction:
147
+ - display-mode: standalone matches only an installed PWA; desktop
148
+ browsers report browser.
149
+ - The Electron app never receives this stylesheet at all (the
150
+ isDesktopShell gate in client/index.tsx) and never gets cover.
151
+ - env() insets are hard 0 in every desktop browser, so even a browser
152
+ that somehow matched the media query computes 0px padding; on iPads
153
+ the bottom inset is 0 too (no home indicator) — the bottom line only
154
+ ever pays out on Android tablets with a gesture/nav bar.
155
+ box-sizing is load-bearing (the S2.1 lesson, see the frame rule in
156
+ layout.css): the official frame is height:100% content-box, and padding
157
+ without border-box would grow it past the viewport instead of reserving
158
+ the strip. Pinned by scripts/check-tablet-safe-area.mjs. */
159
+ @media (min-width: 1024px) and (display-mode: standalone) {
160
+ :has(> [data-shell-overlay]) {
161
+ box-sizing: border-box !important;
162
+ padding-top: var(--mnav-sat) !important;
163
+ padding-bottom: var(--mnav-sab) !important;
164
+ }
165
+ }
128
166
  `
@@ -125,3 +125,106 @@ export function decideRemoteDrop(input: RemoteDropInput): RemoteDropDecision {
125
125
  export function rewrapFiles(files: Iterable<File>): File[] {
126
126
  return Array.from(files, (file) => new File([file], file.name, { type: file.type, lastModified: file.lastModified }))
127
127
  }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // AU-1: the intake decision — the same three gates extended to the picker
131
+ // (hidden file input's `change`) and paste entries. issue #49: the drop guard
132
+ // alone left those two entries on the zero-copy path chip, so a REMOTE
133
+ // session's attachment named a file on the sub-client's disk. The decision
134
+ // keeps the drop semantics frozen in `decideRemoteDrop` (its truth table is a
135
+ // regression pin) and answers the two new entries here, where the fix's
136
+ // minimal-intervention rule can be expressed: only a file that CARRIES a path
137
+ // and is NOT an image takes the bad zero-copy branch (addFiles), so only
138
+ // those are worth intercepting — pathless and image files already upload
139
+ // bytes and must stay untouched.
140
+ // ---------------------------------------------------------------------------
141
+
142
+ /** Which entry an intake item arrived through. `drop` keeps the redispatch
143
+ * semantics of {@link decideRemoteDrop} (any file intervenes); `picker` /
144
+ * `paste` intervene only on the path-carrying non-image files. */
145
+ export type RemoteIntakeEntry = 'drop' | 'picker' | 'paste'
146
+
147
+ /** One intake item, reduced to what the decision needs. `hasPath` is the
148
+ * bridge's `pathFor(file) !== ''` — the same fact the host's zero-copy
149
+ * branch keys on; `isImage` is `file.type` starting with `image/` (the
150
+ * host's own image classification). A `paste` item with no file behind it
151
+ * never becomes a descriptor (the effect only collects `kind: 'file'`
152
+ * clipboard items), so plain-text paste arrives as an EMPTY list. */
153
+ export interface RemoteIntakeDescriptor {
154
+ entry: RemoteIntakeEntry
155
+ isDirectory: boolean
156
+ hasPath: boolean
157
+ isImage: boolean
158
+ }
159
+
160
+ /** Everything the intake decision may look at — the same three gates as
161
+ * {@link decideRemoteDrop}, injectable so no DOM is needed. */
162
+ export interface RemoteIntakeInput {
163
+ /** `__DSH_HOST_PATHS__` exists — the Electron preload's zero-copy bridge. */
164
+ hasHostPaths: boolean
165
+ /** The currently open session id, or `undefined` when no signal. */
166
+ currentSessionId: string | undefined
167
+ /** `event.isTrusted` — the re-entry guard for our redispatched events. */
168
+ trusted: boolean
169
+ /** Descriptor per collected file. `undefined` means the caller could not
170
+ * even enumerate the items — with no inventory there is nothing safe to
171
+ * judge, so this passes through (today's behavior). */
172
+ items: readonly RemoteIntakeDescriptor[] | undefined
173
+ }
174
+
175
+ export type RemoteIntakeAction = 'passthrough' | 'redirect' | 'block-directories'
176
+
177
+ /** Stable machine-readable why, one per branch, ordered by the gate sequence
178
+ * in {@link decideRemoteIntake}. */
179
+ export type RemoteIntakeReason =
180
+ | 'untrusted-event'
181
+ | 'no-host-paths'
182
+ | 'not-remote-session'
183
+ | 'no-entry-api'
184
+ | 'contains-directory'
185
+ | 'no-files'
186
+ | 'no-path-or-image-only'
187
+ | 'remote-path-files'
188
+
189
+ export interface RemoteIntakeDecision {
190
+ action: RemoteIntakeAction
191
+ reason: RemoteIntakeReason
192
+ }
193
+
194
+ /**
195
+ * The three gates, then the intervention rule. The gates are decideRemoteDrop's
196
+ * verbatim (all must affirmatively pass — a missing signal reads as local and
197
+ * never intervenes); the rule after them:
198
+ * 1. `items === undefined` — no inventory, pass through (`no-entry-api`;
199
+ * deciding blind could eat a working flow);
200
+ * 2. any directory — `block-directories`, ahead of every redirect: a folder
201
+ * cannot be fixed by single-file upload, the whole intake is stopped with
202
+ * a toast (same semantics as the drop branch);
203
+ * 3. no items at all — passthrough (`no-files`; the plain-text paste shape);
204
+ * 4. otherwise — `redirect` when ANY item is a `drop` item (the frozen
205
+ * "any file intervenes" semantics) or carries a path while not being an
206
+ * image (the zero-copy bad branch — exactly what issue #49 broke on);
207
+ * `no-path-or-image-only` passthrough when none does.
208
+ *
209
+ * Drop-entry parity pin: a `drop` item's intervention semantics here are
210
+ * kept EQUIVALENT to `decideRemoteDrop` — same gates, directory rule and
211
+ * "any file intervenes" — with exactly ONE deliberate divergence: when
212
+ * `items === undefined`, `decideRemoteDrop` redispatches (a drop always has
213
+ * a Files payload worth re-shaping) while this function passes through
214
+ * (`no-entry-api` — an intake caller that cannot even enumerate its items
215
+ * must not be intercepted blind). If either function's gate sequence or
216
+ * drop-entry rule changes, update the other in the same commit; the truth
217
+ * tables in scripts/check-remote-drop.mjs pin both sides.
218
+ */
219
+ export function decideRemoteIntake(input: RemoteIntakeInput): RemoteIntakeDecision {
220
+ if (!input.trusted) return { action: 'passthrough', reason: 'untrusted-event' }
221
+ if (!input.hasHostPaths) return { action: 'passthrough', reason: 'no-host-paths' }
222
+ if (!isRemoteSessionId(input.currentSessionId)) return { action: 'passthrough', reason: 'not-remote-session' }
223
+ if (input.items === undefined) return { action: 'passthrough', reason: 'no-entry-api' }
224
+ if (input.items.some((item) => item.isDirectory)) return { action: 'block-directories', reason: 'contains-directory' }
225
+ if (input.items.length === 0) return { action: 'passthrough', reason: 'no-files' }
226
+ const intervenes = input.items.some((item) => item.entry === 'drop' || (item.hasPath && !item.isImage))
227
+ return intervenes
228
+ ? { action: 'redirect', reason: 'remote-path-files' }
229
+ : { action: 'passthrough', reason: 'no-path-or-image-only' }
230
+ }
@@ -97,6 +97,9 @@ export const CLIENT_CLAIM_ROUTE = '/_dsh/zen-remote/client/claim'
97
97
  export const CLIENT_STATUS_ROUTE = '/_dsh/zen-remote/client/status'
98
98
  /** T43: one immediate reconnect — answered 409 unless the client is offline. */
99
99
  export const CLIENT_RECONNECT_ROUTE = '/_dsh/zen-remote/client/reconnect'
100
+ /** CU-4: the redacted diagnostic report (GET, both roles, machine-local only
101
+ * — a gateway-forwarded fetch is refused server-side). */
102
+ export const CLIENT_DEBUG_REPORT_ROUTE = '/_dsh/zen-remote/client/debug-report'
100
103
 
101
104
  /** The lightweight client-facing config route (host half, both roles): the
102
105
  * settings page's FALLBACK role probe (T17) — it registers wherever a
@@ -39,6 +39,17 @@ function isHttpAllowedHost(hostname: string): boolean {
39
39
  return isAllowedIpv4(hostname)
40
40
  }
41
41
 
42
+ /**
43
+ * Whether one hostname sits in a private/loopback network — the same verdict
44
+ * {@link isHttpAllowedHost} applies inside `normalizeServerUrl`, exported for
45
+ * the debug report's serverUrl shaper (CU-2): the one place outside the
46
+ * normalizer that needs the private-network fact, kept here so the address
47
+ * classes can never drift apart from what pairing actually accepts.
48
+ */
49
+ export function isPrivateNetworkHost(hostname: string): boolean {
50
+ return isHttpAllowedHost(hostname)
51
+ }
52
+
42
53
  /** Dotted-quad check against the allowed ranges. The hostname comes from a
43
54
  * parsed URL, so it is already canonical (the WHATWG parser folds exotic
44
55
  * spellings like `010.0x0A.0.3` into plain decimal before we ever see it). */
@@ -25,10 +25,13 @@
25
25
  */
26
26
 
27
27
  import type { IncomingMessage, ServerResponse } from 'node:http'
28
+ import { homedir } from 'node:os'
28
29
  import type { AdminAdmission } from './admin-routes.js'
29
30
  import { classifyClaimResponse, classifyProbe, CLAIM_PATH, normalizeServerUrl, RELAY_PING_PATH } from './client-pairing.js'
30
31
  import type { ClaimOutcome, ProbeState } from './client-pairing.js'
31
- import { unwrapVolatile } from './config.js'
32
+ import { readFileConfig, resolveConfig, unwrapVolatile } from './config.js'
33
+ import { assembleDebugReport } from './debug-report.js'
34
+ import type { LogRingEntry } from './log-ring.js'
32
35
  import type { FetchRouteInterceptDiagnostics } from './fetch-route-intercept.js'
33
36
  import type { CurrentSessionRead, InterceptDiagnostics } from './intercept.js'
34
37
  import type { PluginRouteInterceptDiagnostics } from './plugin-route-intercept.js'
@@ -37,6 +40,7 @@ import type { BridgeStatus } from './plugin-bridge.js'
37
40
  import { RELAY_HTTP_ROUTES } from './relay-access.js'
38
41
  import { relayCredentialsDigest, RelayError } from './relay-client.js'
39
42
  import type { RelayClient, RelayHttpResult } from './relay-client.js'
43
+ import { resolveDshVersion } from './relay-server.js'
40
44
  import { fromVirtual } from './virtual-id.js'
41
45
 
42
46
  /** Prefix all client routes live under (one webServer prefix registration). */
@@ -80,6 +84,15 @@ export const CLIENT_NETWORK_HINT_ROUTE = `${CLIENT_ROUTE_PREFIX}/network-hint`
80
84
  * closed:{}}` conclusion. It carries no token and no server address. */
81
85
  export const CLIENT_REMOTE_STATUS_ROUTE = `${CLIENT_ROUTE_PREFIX}/remote-status`
82
86
 
87
+ /** GET: the redacted diagnostic report (CU-3, SPEC 方案 C) — `_meta`,
88
+ * `config`, `status`, optional `gateway`, `logs` plates, assembled by
89
+ * {@link assembleDebugReport} and safe to paste into a GitHub issue. Mounted
90
+ * on BOTH roles (each answers its own report); a request that arrived
91
+ * through the gateway is refused BEFORE dispatch (the wizard's via wall) —
92
+ * diagnostics are a machine-local export, and the gateway must never be able
93
+ * to fetch this machine's report for its own device table either. */
94
+ export const CLIENT_DEBUG_REPORT_ROUTE = `${CLIENT_ROUTE_PREFIX}/debug-report`
95
+
83
96
  /** POST `{sessionId: <虚拟 id>}`: close one remote session from THIS machine
84
97
  * (T34) — the backend forwards the ORIGINAL id through the relay's
85
98
  * `POST relay/v1/unshare`, so the server closes it with reason `'client'`. */
@@ -172,6 +185,20 @@ export interface ClientHandlerOptions {
172
185
  * a client handler WITHOUT the sink answers the same 404 (no sink, no
173
186
  * route). */
174
187
  onCurrentSession?: (read: CurrentSessionRead) => void
188
+ /** CU-3, the debug-report route's inputs — all optional; the route answers
189
+ * its 404-shaped refusal without them (an unwired composition must not
190
+ * pretend to have diagnostics). Wired on BOTH role mounts.
191
+ *
192
+ * The log ring's snapshot (CU-1), oldest first. */
193
+ getLogSnapshot?: () => readonly LogRingEntry[]
194
+ /** The gateway child's summary plate (HOST mount only): liveness, port,
195
+ * device and share counts — counts, never tokens. May be async (it probes
196
+ * the gateway's own status). */
197
+ getGatewaySummary?: () => Record<string, unknown> | Promise<Record<string, unknown>>
198
+ /** The profile context's display name, when the composition has one. */
199
+ getProfileName?: () => string | undefined
200
+ /** This package's own version, read at module load in src/index.ts. */
201
+ getPluginVersion?: () => string
175
202
  }
176
203
 
177
204
  export type ClientHandler = (req: IncomingMessage, res: ServerResponse) => Promise<void>
@@ -396,6 +423,103 @@ function remoteStatusStateOf(relay: RelayClient | undefined): 'online' | 'offlin
396
423
  }
397
424
  }
398
425
 
426
+ /**
427
+ * The status route's body, computed exactly as the CLIENT_STATUS_ROUTE
428
+ * branch always has — extracted (CU-3) so the debug-report route can embed
429
+ * the same diagnostics aggregation without a second implementation. All
430
+ * reads are live per call: volatile row fields, the interception getters,
431
+ * the relay client's current state. May await one bounded probe or connect
432
+ * (never longer than the probe timeout).
433
+ */
434
+ async function statusBodyOf(options: ClientHandlerOptions): Promise<Record<string, unknown>> {
435
+ const fetchImpl: FetchLike = options.fetchImpl ?? ((url, init) => fetch(url, init))
436
+ // The typert interception's view (T23b-1) rides on EVERY status
437
+ // answer, whatever the connection state — the settings surface needs
438
+ // it to explain a refused install even while unpaired. The interface
439
+ // compat verdict (T42) appears once there is something to report: a
440
+ // stored comparison from the relay client's last handshake, or any
441
+ // incompatible call the interceptor recorded. Absent otherwise —
442
+ // nothing to show, and the settings block presence-gates on exactly
443
+ // that.
444
+ const intercept = options.getIntercept?.()
445
+ const fetchRouteIntercept = options.getFetchRouteIntercept?.()
446
+ const serviceProbe = options.getServiceProbe?.()
447
+ const pluginRouteIntercept = options.getPluginRouteIntercept?.()
448
+ const compatRelay = options.getRelayClient?.()
449
+ const compat =
450
+ compatRelay?.compat !== undefined || (intercept?.incompatibleCalls.length ?? 0) > 0
451
+ ? {
452
+ identical: compatRelay?.compat?.identical ?? [],
453
+ different: compatRelay?.compat?.different ?? [],
454
+ unavailable: compatRelay?.compat?.unavailable ?? [],
455
+ incompatibleCalls: intercept?.incompatibleCalls ?? [],
456
+ }
457
+ : undefined
458
+ const withDiagnostics = (body: Record<string, unknown>): Record<string, unknown> => {
459
+ // PB-2: the per-plugin bridge verdict rides EVERY status answer —
460
+ // the settings surface explains a not-bridged plugin whatever the
461
+ // connection state (unpaired reads all-offline, the honest verdict).
462
+ let next: Record<string, unknown> = { ...body, pluginBridge: options.getPluginBridgeStatus?.() ?? [] }
463
+ if (intercept !== undefined) next = { ...next, intercept }
464
+ if (fetchRouteIntercept !== undefined) next = { ...next, fetchRouteIntercept }
465
+ if (serviceProbe !== undefined) next = { ...next, serviceProbe }
466
+ if (pluginRouteIntercept !== undefined) next = { ...next, pluginRouteIntercept }
467
+ return compat === undefined ? next : { ...next, compat }
468
+ }
469
+ // The ROW decides its own two shapes first, per request (both fields
470
+ // are volatile { get() } wrappers — only the row object apply()
471
+ // received is a live source): without both credentials nothing is
472
+ // paired, and a stored address that no longer normalizes must never
473
+ // see the token (T16-fix).
474
+ const row = options.getRowConfig()
475
+ const record = row !== null && typeof row === 'object' && !Array.isArray(row) ? row as Record<string, unknown> : {}
476
+ const serverUrl = stringOrEmpty(unwrapVolatile(record.serverUrl)).trim()
477
+ const token = stringOrEmpty(unwrapVolatile(record.deviceToken))
478
+ if (token === '' || serverUrl === '') {
479
+ return withDiagnostics({ state: 'unpaired' })
480
+ }
481
+ const normalized = normalizeServerUrl(serverUrl)
482
+ if (!normalized.ok) {
483
+ return withDiagnostics({ state: 'invalid-url' })
484
+ }
485
+ const relay = options.getRelayClient?.()
486
+ if (relay === undefined) {
487
+ // T16 shape: no relay client wired — the ping probe is the answer.
488
+ const state = await probe(fetchImpl, normalized.url, token)
489
+ return withDiagnostics({ state, serverUrl: normalized.url })
490
+ }
491
+ // The cached verdict is trusted only over the EXACT credentials the
492
+ // handshake earned it with: an online state whose digest no longer
493
+ // matches the row (address edited, re-pair) is an answer about a
494
+ // DIFFERENT server and falls through to a live connect instead
495
+ // (T23a-fix). The token never enters this body — the digest is a
496
+ // one-way sha256 prefix computed on both sides.
497
+ if (
498
+ relay.state === 'online' &&
499
+ relay.handshakeInfo !== undefined &&
500
+ relay.lastHandshakeDigest !== undefined &&
501
+ relay.lastHandshakeDigest === relayCredentialsDigest(normalized.url, token)
502
+ ) {
503
+ return withDiagnostics(deviceStatusBody(relay.state, relay.handshakeInfo, normalized.url, relay.deviceNameSyncing))
504
+ }
505
+ // Everything else — never connected, offline, connecting, revoked,
506
+ // stale credentials — gets ONE live connect bounded by the probe
507
+ // timeout; its FRESH result is the answer, never the possibly stale
508
+ // cached state. A failed attempt additionally carries the relay
509
+ // client's reconnect machinery readout (T43): when the next
510
+ // automatic retry fires and which code failed last — both free of
511
+ // credential material by contract.
512
+ try {
513
+ const info = await withProbeTimeout(relay.connect())
514
+ return withDiagnostics(deviceStatusBody('online', info, normalized.url, relay.deviceNameSyncing))
515
+ } catch (error) {
516
+ const body: Record<string, unknown> = { state: probeStateOfRelayError(error), serverUrl: normalized.url }
517
+ if (relay.nextRetryAt !== null && relay.nextRetryAt !== undefined) body.nextRetryAt = relay.nextRetryAt
518
+ if (relay.lastError !== undefined) body.lastError = relay.lastError
519
+ return withDiagnostics(body)
520
+ }
521
+ }
522
+
399
523
  /**
400
524
  * Build the client route handler for one plugin row. The returned handler
401
525
  * owns the full response lifecycle of every request under
@@ -416,10 +540,24 @@ export function createClientHandler(options: ClientHandlerOptions): ClientHandle
416
540
  const route = new URL(req.url ?? '/', 'http://dsh.internal').pathname
417
541
  const method = req.method ?? 'GET'
418
542
  try {
419
- // The host mount's least-exposure wall (T41a-fix2): only remote-status
420
- // exists here, and it sits BEHIND the admission wall like every other
421
- // route. The shape of the refusal matches the unknown-path fallthrough.
422
- if (options.remoteStatusOnly === true && route !== CLIENT_REMOTE_STATUS_ROUTE) {
543
+ // CU-3: the debug-report route's via wall, BEFORE any dispatch (the
544
+ // wizard's rule): a request wearing the gateway's marker arrived from
545
+ // a remote device, and a forwarded request presents as same-origin
546
+ // (the gateway rewrites Origin/Host) — the marker is the only honest
547
+ // signal. The report is a machine-local export; the gateway must
548
+ // never be able to fetch this machine's diagnostics either.
549
+ if (route === CLIENT_DEBUG_REPORT_ROUTE && req.headers['x-zen-remote-via'] !== undefined) {
550
+ responseJson(res, 403, {
551
+ ok: false,
552
+ error: { code: 'via-gateway', message: '诊断报告只能在本机导出' },
553
+ })
554
+ return
555
+ }
556
+ // The host mount's least-exposure wall (T41a-fix2): remote-status and
557
+ // the debug report (CU-3, both roles answer their own) exist here,
558
+ // and both sit BEHIND the admission wall like every other route. The
559
+ // shape of the refusal matches the unknown-path fallthrough.
560
+ if (options.remoteStatusOnly === true && route !== CLIENT_REMOTE_STATUS_ROUTE && route !== CLIENT_DEBUG_REPORT_ROUTE) {
423
561
  responseJson(res, 404, { ok: false, error: { code: 'not-found', message: 'Unknown client route' } })
424
562
  return
425
563
  }
@@ -485,95 +623,63 @@ export function createClientHandler(options: ClientHandlerOptions): ClientHandle
485
623
  responseJson(res, 405, { ok: false, error: { code: 'method-not-allowed', message: 'Use GET' } })
486
624
  return
487
625
  }
488
- // The typert interception's view (T23b-1) rides on EVERY status
489
- // answer, whatever the connection state — the settings surface needs
490
- // it to explain a refused install even while unpaired. The interface
491
- // compat verdict (T42) appears once there is something to report: a
492
- // stored comparison from the relay client's last handshake, or any
493
- // incompatible call the interceptor recorded. Absent otherwise —
494
- // nothing to show, and the settings block presence-gates on exactly
495
- // that.
496
- const intercept = options.getIntercept?.()
497
- const fetchRouteIntercept = options.getFetchRouteIntercept?.()
498
- const serviceProbe = options.getServiceProbe?.()
499
- const pluginRouteIntercept = options.getPluginRouteIntercept?.()
500
- const compatRelay = options.getRelayClient?.()
501
- const compat =
502
- compatRelay?.compat !== undefined || (intercept?.incompatibleCalls.length ?? 0) > 0
503
- ? {
504
- identical: compatRelay?.compat?.identical ?? [],
505
- different: compatRelay?.compat?.different ?? [],
506
- unavailable: compatRelay?.compat?.unavailable ?? [],
507
- incompatibleCalls: intercept?.incompatibleCalls ?? [],
508
- }
509
- : undefined
510
- const withDiagnostics = (body: Record<string, unknown>): Record<string, unknown> => {
511
- // PB-2: the per-plugin bridge verdict rides EVERY status answer —
512
- // the settings surface explains a not-bridged plugin whatever the
513
- // connection state (unpaired reads all-offline, the honest verdict).
514
- let next: Record<string, unknown> = { ...body, pluginBridge: options.getPluginBridgeStatus?.() ?? [] }
515
- if (intercept !== undefined) next = { ...next, intercept }
516
- if (fetchRouteIntercept !== undefined) next = { ...next, fetchRouteIntercept }
517
- if (serviceProbe !== undefined) next = { ...next, serviceProbe }
518
- if (pluginRouteIntercept !== undefined) next = { ...next, pluginRouteIntercept }
519
- return compat === undefined ? next : { ...next, compat }
520
- }
521
- // The ROW decides its own two shapes first, per request (both fields
522
- // are volatile { get() } wrappers — only the row object apply()
523
- // received is a live source): without both credentials nothing is
524
- // paired, and a stored address that no longer normalizes must never
525
- // see the token (T16-fix).
526
- const row = options.getRowConfig()
527
- const record = row !== null && typeof row === 'object' && !Array.isArray(row) ? row as Record<string, unknown> : {}
528
- const serverUrl = stringOrEmpty(unwrapVolatile(record.serverUrl)).trim()
529
- const token = stringOrEmpty(unwrapVolatile(record.deviceToken))
530
- if (token === '' || serverUrl === '') {
531
- responseJson(res, 200, withDiagnostics({ state: 'unpaired' }))
532
- return
533
- }
534
- const normalized = normalizeServerUrl(serverUrl)
535
- if (!normalized.ok) {
536
- responseJson(res, 200, withDiagnostics({ state: 'invalid-url' }))
537
- return
538
- }
539
- const relay = options.getRelayClient?.()
540
- if (relay === undefined) {
541
- // T16 shape: no relay client wired — the ping probe is the answer.
542
- const state = await probe(fetchImpl, normalized.url, token)
543
- responseJson(res, 200, withDiagnostics({ state, serverUrl: normalized.url }))
626
+ responseJson(res, 200, await statusBodyOf(options))
627
+ return
628
+ }
629
+ if (route === CLIENT_DEBUG_REPORT_ROUTE) {
630
+ // Unwired composition (no log ring handed over): the same shape an
631
+ // unknown path gets — an old host must not pretend to have a report.
632
+ const logs = options.getLogSnapshot?.()
633
+ if (logs === undefined) {
634
+ responseJson(res, 404, { ok: false, error: { code: 'not-found', message: 'Unknown client route' } })
544
635
  return
545
636
  }
546
- // The cached verdict is trusted only over the EXACT credentials the
547
- // handshake earned it with: an online state whose digest no longer
548
- // matches the row (address edited, re-pair) is an answer about a
549
- // DIFFERENT server and falls through to a live connect instead
550
- // (T23a-fix). The token never enters this body — the digest is a
551
- // one-way sha256 prefix computed on both sides.
552
- if (
553
- relay.state === 'online' &&
554
- relay.handshakeInfo !== undefined &&
555
- relay.lastHandshakeDigest !== undefined &&
556
- relay.lastHandshakeDigest === relayCredentialsDigest(normalized.url, token)
557
- ) {
558
- responseJson(res, 200, withDiagnostics(deviceStatusBody(relay.state, relay.handshakeInfo, normalized.url, relay.deviceNameSyncing)))
637
+ if (method !== 'GET') {
638
+ res.setHeader('Allow', 'GET')
639
+ responseJson(res, 405, { ok: false, error: { code: 'method-not-allowed', message: 'Use GET' } })
559
640
  return
560
641
  }
561
- // Everything else — never connected, offline, connecting, revoked,
562
- // stale credentials — gets ONE live connect bounded by the probe
563
- // timeout; its FRESH result is the answer, never the possibly stale
564
- // cached state. A failed attempt additionally carries the relay
565
- // client's reconnect machinery readout (T43): when the next
566
- // automatic retry fires and which code failed last — both free of
567
- // credential material by contract.
568
- try {
569
- const info = await withProbeTimeout(relay.connect())
570
- responseJson(res, 200, withDiagnostics(deviceStatusBody('online', info, normalized.url, relay.deviceNameSyncing)))
571
- } catch (error) {
572
- const body: Record<string, unknown> = { state: probeStateOfRelayError(error), serverUrl: normalized.url }
573
- if (relay.nextRetryAt !== null && relay.nextRetryAt !== undefined) body.nextRetryAt = relay.nextRetryAt
574
- if (relay.lastError !== undefined) body.lastError = relay.lastError
575
- responseJson(res, 200, withDiagnostics(body))
576
- }
642
+ // The picked config summary (CU-2's construction layer): field by
643
+ // NAME, never a spread — deviceToken and the other secret row fields
644
+ // are structurally absent; the assembler's deep redactor is the
645
+ // second layer behind this. serverUrl travels to the assembler raw
646
+ // and comes out as a shape (scheme + address class + port).
647
+ const row = options.getRowConfig()
648
+ const record = row !== null && typeof row === 'object' && !Array.isArray(row) ? row as Record<string, unknown> : {}
649
+ const values = resolveConfig(record, readFileConfig(), process.env).values
650
+ const rawMaxUpload: unknown = unwrapVolatile(record.maxUploadBytes)
651
+ const maxUploadBytes = typeof rawMaxUpload === 'number' && Number.isFinite(rawMaxUpload) && rawMaxUpload > 0
652
+ ? rawMaxUpload
653
+ : undefined
654
+ const rawProfile = options.getProfileName?.()
655
+ const report = assembleDebugReport({
656
+ meta: {
657
+ generatedAt: new Date().toISOString(),
658
+ pluginVersion: options.getPluginVersion?.() ?? 'unknown',
659
+ dshVersion: resolveDshVersion(),
660
+ platform: `${process.platform} ${process.arch}`,
661
+ role: String(values.role),
662
+ ...(rawProfile === undefined ? {} : { profileName: rawProfile }),
663
+ },
664
+ configSummary: {
665
+ role: values.role,
666
+ host: values.host,
667
+ port: values.port,
668
+ idleHours: values.idleHours,
669
+ maxUploadBytes,
670
+ autoShareNewSessions: values.autoShareNewSessions,
671
+ pluginBridgeConsent: values.pluginBridgeConsent,
672
+ pushSummary: values.pushSummary,
673
+ pushTurnEnd: values.pushTurnEnd,
674
+ pushTool: values.pushTool,
675
+ serverUrl: stringOrEmpty(unwrapVolatile(record.serverUrl)).trim(),
676
+ },
677
+ statusBody: await statusBodyOf(options),
678
+ ...(options.getGatewaySummary === undefined ? {} : { gatewaySummary: await options.getGatewaySummary() }),
679
+ logs,
680
+ home: homedir(),
681
+ })
682
+ responseJson(res, 200, report)
577
683
  return
578
684
  }
579
685
  if (route === CLIENT_RECONNECT_ROUTE) {