dsh-zen-remote 2.3.5 → 2.3.7

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 (100) hide show
  1. package/AGENTS.md +15 -5
  2. package/CHANGELOG.md +71 -0
  3. package/README.md +2 -2
  4. package/README.zh-CN.md +2 -2
  5. package/docs/interface.md +12 -6
  6. package/docs/plugin-bridge.md +2 -2
  7. package/docs/remote-access.en.md +9 -3
  8. package/docs/remote-access.md +9 -3
  9. package/lan-gate.mjs +7 -0
  10. package/lib/adapters/dsh-better-sidebar/index.js +31 -2
  11. package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
  12. package/lib/client-pairing.js +10 -0
  13. package/lib/client-pairing.js.map +1 -1
  14. package/lib/client-routes.js +183 -94
  15. package/lib/client-routes.js.map +1 -1
  16. package/lib/client.js +589 -97
  17. package/lib/debug-redact.js +253 -0
  18. package/lib/debug-redact.js.map +1 -0
  19. package/lib/debug-report.js +60 -0
  20. package/lib/debug-report.js.map +1 -0
  21. package/lib/fetch-route-intercept.js +23 -0
  22. package/lib/fetch-route-intercept.js.map +1 -1
  23. package/lib/index.js +306 -45
  24. package/lib/index.js.map +1 -1
  25. package/lib/intercept.js +43 -1
  26. package/lib/intercept.js.map +1 -1
  27. package/lib/log-ring.js +76 -0
  28. package/lib/log-ring.js.map +1 -0
  29. package/lib/plugin-adapter-core.js +30 -1
  30. package/lib/plugin-adapter-core.js.map +1 -1
  31. package/lib/plugin-route-intercept.js +506 -11
  32. package/lib/plugin-route-intercept.js.map +1 -1
  33. package/lib/plugin-tunnel.js +210 -18
  34. package/lib/plugin-tunnel.js.map +1 -1
  35. package/lib/types/adapters/dsh-better-sidebar/index.d.ts +5 -2
  36. package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
  37. package/lib/types/client/MobileSessionInfo.d.ts.map +1 -1
  38. package/lib/types/client/effects/remote-drop.d.ts +20 -8
  39. package/lib/types/client/effects/remote-drop.d.ts.map +1 -1
  40. package/lib/types/client/locales.d.ts +10 -1
  41. package/lib/types/client/locales.d.ts.map +1 -1
  42. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
  43. package/lib/types/client/styles/content.css.d.ts +1 -1
  44. package/lib/types/client/styles/content.css.d.ts.map +1 -1
  45. package/lib/types/client/styles/layout.css.d.ts +1 -1
  46. package/lib/types/client/styles/layout.css.d.ts.map +1 -1
  47. package/lib/types/client/styles/misc.css.d.ts +1 -1
  48. package/lib/types/client/styles/misc.css.d.ts.map +1 -1
  49. package/lib/types/client-data/remote-drop.d.ts +64 -0
  50. package/lib/types/client-data/remote-drop.d.ts.map +1 -1
  51. package/lib/types/client-data/settings-form.d.ts +3 -0
  52. package/lib/types/client-data/settings-form.d.ts.map +1 -1
  53. package/lib/types/client-pairing.d.ts +8 -0
  54. package/lib/types/client-pairing.d.ts.map +1 -1
  55. package/lib/types/client-routes.d.ts +23 -0
  56. package/lib/types/client-routes.d.ts.map +1 -1
  57. package/lib/types/debug-redact.d.ts +68 -0
  58. package/lib/types/debug-redact.d.ts.map +1 -0
  59. package/lib/types/debug-report.d.ts +58 -0
  60. package/lib/types/debug-report.d.ts.map +1 -0
  61. package/lib/types/fetch-route-intercept.d.ts +26 -0
  62. package/lib/types/fetch-route-intercept.d.ts.map +1 -1
  63. package/lib/types/index.d.ts +7 -1
  64. package/lib/types/index.d.ts.map +1 -1
  65. package/lib/types/intercept.d.ts +6 -0
  66. package/lib/types/intercept.d.ts.map +1 -1
  67. package/lib/types/log-ring.d.ts +57 -0
  68. package/lib/types/log-ring.d.ts.map +1 -0
  69. package/lib/types/plugin-adapter-core.d.ts.map +1 -1
  70. package/lib/types/plugin-route-intercept.d.ts +8 -0
  71. package/lib/types/plugin-route-intercept.d.ts.map +1 -1
  72. package/lib/types/plugin-tunnel.d.ts +37 -2
  73. package/lib/types/plugin-tunnel.d.ts.map +1 -1
  74. package/package.json +3 -2
  75. package/pwa/app.css +13 -2
  76. package/scripts/check-debug-report.mjs +277 -0
  77. package/scripts/check-info-sheet-stacking.mjs +85 -0
  78. package/scripts/check-remote-drop.mjs +130 -0
  79. package/scripts/check-tablet-safe-area.mjs +49 -0
  80. package/src/adapters/dsh-better-sidebar/index.ts +31 -2
  81. package/src/client/MobileSessionInfo.tsx +29 -2
  82. package/src/client/effects/remote-drop.ts +271 -24
  83. package/src/client/locales.ts +24 -4
  84. package/src/client/settings/SettingsSection.tsx +120 -3
  85. package/src/client/styles/content.css.ts +44 -14
  86. package/src/client/styles/layout.css.ts +9 -3
  87. package/src/client/styles/misc.css.ts +38 -0
  88. package/src/client-data/remote-drop.ts +103 -0
  89. package/src/client-data/settings-form.ts +3 -0
  90. package/src/client-pairing.ts +11 -0
  91. package/src/client-routes.ts +196 -90
  92. package/src/debug-redact.ts +290 -0
  93. package/src/debug-report.ts +93 -0
  94. package/src/fetch-route-intercept.ts +29 -0
  95. package/src/index.ts +295 -43
  96. package/src/intercept.ts +46 -0
  97. package/src/log-ring.ts +101 -0
  98. package/src/plugin-adapter-core.ts +29 -2
  99. package/src/plugin-route-intercept.ts +516 -12
  100. package/src/plugin-tunnel.ts +230 -20
@@ -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) {
@@ -0,0 +1,290 @@
1
+ /**
2
+ * CU-2: the debug report's redactor (SPEC 方案 C). Pure functions — no DSH
3
+ * imports, no I/O — that turn free text and addresses into shapes an issue
4
+ * report can carry: absolute paths lose everything but their leaf, home
5
+ * paths fold to `~`, virtual session ids keep only the server id and the
6
+ * first 8 characters of the session id, long secret-shaped runs become
7
+ * `<token>`, and URL hostnames degrade to their address class. The report
8
+ * assembler (debug-report.ts) runs every string it emits through
9
+ * {@link redactText}; the check script pins the truth table, including the
10
+ * "an injected path/hostname/token can never be recovered from the output"
11
+ * assertion.
12
+ *
13
+ * The passes are ordered so they cannot un-do each other: URLs are lifted
14
+ * out first (pass 0, a sentinel in their place) and restored last in
15
+ * degraded form, so no text-wide pass can merge a URL's port and path into
16
+ * a token run; then virtual ids (a ≥32-char id would otherwise die as a
17
+ * token run), the home fold BEFORE the token pass (a long home path is
18
+ * itself a ≥32 run), the token pass, and the path passes (UNC, drive
19
+ * letter, Unix). Path leaves never contain whitespace, and Unix paths must
20
+ * start at a boundary (string start, whitespace, `(`, `~`) so relative
21
+ * paths and `a/b`-style words stay untouched.
22
+ *
23
+ * Deliberately ZERO relative imports: the check script imports this file
24
+ * straight off src/ under Node's strip-only type stripping (the same seam
25
+ * scripts/check-remote-drop.mjs uses for client-data/remote-drop.ts), and a
26
+ * `.js` specifier cannot resolve there. The address-class predicate below
27
+ * therefore restates the pairing normalizer's private-network ranges; the
28
+ * two are pinned against each other by a lib-level test in
29
+ * test/client-routes.test.cjs so they cannot drift apart silently.
30
+ */
31
+ /** The address classes {@link serverUrlShape} reports. Deliberately coarse:
32
+ * enough to triage "can the two machines even see each other", never the
33
+ * hostname itself. */
34
+ export type ServerUrlClass = 'loopback' | 'lan-ip' | 'public-ip' | 'domain'
35
+
36
+ /** One pass's replacement result for a matched run of the token pass. */
37
+ const TOKEN_PLACEHOLDER = '<token>'
38
+
39
+ /** The private-network address ranges the pairing normalizer admits for
40
+ * plain http (client-pairing.ts's `isPrivateNetworkHost`), restated here
41
+ * because this module must import nothing (see the header). IPv4: `10/8`,
42
+ * `172.16/12`, `192.168/16`, `100.64/10` (CGNAT/Tailscale); IPv6: `fc00::/7`
43
+ * and `fe80::/10` by their first group. Loopback and `*.local` are decided
44
+ * by the caller, not here. */
45
+ function isLanAddress(host: string): boolean {
46
+ const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host)
47
+ if (ipv4 !== null) {
48
+ const a = Number(ipv4[1])
49
+ const b = Number(ipv4[2])
50
+ if (a === 10) return true
51
+ if (a === 172 && b >= 16 && b <= 31) return true
52
+ if (a === 192 && b === 168) return true
53
+ if (a === 100 && b >= 64 && b <= 127) return true
54
+ return false
55
+ }
56
+ const firstGroup = /^([0-9a-fA-F]{1,4}):/u.exec(host)
57
+ if (firstGroup !== null) {
58
+ const first = parseInt(firstGroup[1], 16)
59
+ return (first >= 0xfc00 && first <= 0xfdff) || (first >= 0xfe80 && first <= 0xfebf)
60
+ }
61
+ return false
62
+ }
63
+
64
+ /** Which class one hostname belongs to. Loopback first (it is also in the
65
+ * private ranges — order matters), then the remaining private ranges as
66
+ * `lan-ip`, then numeric literals as public addresses, everything else as
67
+ * `domain` (the "cannot tell" bucket, per SPEC). IPv6 brackets are
68
+ * stripped first — classification is bracket-free. */
69
+ function classifyHost(bracketed: string): ServerUrlClass {
70
+ const host = bracketed.startsWith('[') && bracketed.endsWith(']') ? bracketed.slice(1, -1) : bracketed
71
+ if (host === 'localhost' || host === '::1' || host.startsWith('127.')) return 'loopback'
72
+ if (host.endsWith('.local')) return 'domain'
73
+ if (isLanAddress(host)) return 'lan-ip'
74
+ if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(host)) return 'public-ip'
75
+ if (host.includes(':')) return 'public-ip'
76
+ return 'domain'
77
+ }
78
+
79
+ /**
80
+ * One server address's SHAPE: `scheme://class[:port]` — never the hostname.
81
+ * `http://192.168.1.10:3080` → `http://lan-ip:3080`; `https://myserver.example.com`
82
+ * → `https://domain`; the scheme's default port is omitted. Unset input
83
+ * answers `'unset'`, unparsable input `'invalid'`.
84
+ */
85
+ export function serverUrlShape(url: unknown): string {
86
+ if (typeof url !== 'string' || url.trim() === '') return 'unset'
87
+ let parsed: URL
88
+ try {
89
+ parsed = new URL(url.trim())
90
+ } catch {
91
+ return 'invalid'
92
+ }
93
+ const scheme = parsed.protocol === 'https:' ? 'https' : parsed.protocol === 'http:' ? 'http' : parsed.protocol.replace(/:$/u, '')
94
+ const port = parsed.port
95
+ const defaultPort = (scheme === 'https' && port === '443') || (scheme === 'http' && port === '80')
96
+ return `${scheme}://${classifyHost(parsed.hostname)}${port !== '' && !defaultPort ? `:${port}` : ''}`
97
+ }
98
+
99
+ /**
100
+ * A bind address's shape for the config plate (`host`): the same address
101
+ * classes as {@link serverUrlShape}, plus `wildcard` for the all-interfaces
102
+ * binds (`0.0.0.0` / `::`) — the one bind fact triage actually needs. Unset
103
+ * input answers `'unset'`. A raw address never reaches the report.
104
+ */
105
+ export function bindHostShape(host: unknown): string {
106
+ if (typeof host !== 'string' || host.trim() === '') return 'unset'
107
+ const value = host.trim()
108
+ if (value === '0.0.0.0' || value === '::' || value === '[::]') return 'wildcard'
109
+ return classifyHost(value)
110
+ }
111
+
112
+ /** The token pass: any run of ≥32 base64/base64url-ish characters (hex
113
+ * secrets, base64 keys, JWT segments — `-` and `_` included so a base64url
114
+ * signature dies whole instead of leaving a half) — long enough that real
115
+ * prose never trips it, short enough to catch every credential shape this
116
+ * plugin handles. */
117
+ const TOKEN_RUN = /[A-Za-z0-9+/=_-]{32,}/gu
118
+
119
+ /** The same run, as a CONTAINS test for one URL query value / path segment
120
+ * (non-global: `test` must not carry `lastIndex` between calls). */
121
+ const HAS_TOKEN_RUN = /[A-Za-z0-9+/=_-]{32,}/u
122
+
123
+ /** The same run without `/`, for one URL path segment at a time — a long
124
+ * path is not a secret, a long SEGMENT is. */
125
+ const HAS_TOKEN_SEGMENT = /[A-Za-z0-9+=_-]{32,}/u
126
+
127
+ /** Pass 0's URL candidates: a scheme this report can meet, then everything
128
+ * up to whitespace, a quote or an angle bracket. Trailing sentence
129
+ * punctuation is peeled back off afterwards ({@link URL_TRAILING}). */
130
+ const URL_CANDIDATE = /(?:https?|wss?|ftp):\/\/[^\s"'<>\u0000]+/gu
131
+
132
+ /** Sentence punctuation that ends a URL in prose (`see http://x/y).`) — `]`
133
+ * is deliberately absent: it closes a bracketed IPv6 host. */
134
+ const URL_TRAILING = /[.,;:!?)}'"]+$/u
135
+
136
+ /** The in-text stand-in for one extracted URL. NUL never survives into the
137
+ * text (pass 0 replaces any NUL the input carried), so a sentinel can only
138
+ * be one this function placed; nothing in it trips a later pass (no `zr~`,
139
+ * no slash, no 32-run, no drive-letter shape). */
140
+ const URL_SENTINEL = /\u0000zr-url:(\d+)\u0000/gu
141
+
142
+ /** One URL's path, redacted per segment: virtual ids truncate, and a segment
143
+ * that carries a secret-shaped run dies whole. Ordinary routes
144
+ * (`/_dsh/zen-remote/relay/v1/stream`) pass untouched. */
145
+ function redactUrlPath(path: string): string {
146
+ return path
147
+ .replace(VIRTUAL_ID, (_match: string, serverId: string, id: string) => `zr~${serverId}~${id.replace(/^session-/, '').slice(0, 8)}`)
148
+ .split('/')
149
+ .map((segment) => (HAS_TOKEN_SEGMENT.test(segment) ? TOKEN_PLACEHOLDER : segment))
150
+ .join('/')
151
+ }
152
+
153
+ /** One URL's query + fragment (`?…` / `#…`), redacted per parameter: the key
154
+ * is kept, a value carrying a secret-shaped run becomes `<token>` (a bare
155
+ * parameter without `=` likewise). Virtual ids truncate first, so a
156
+ * `sessionId=zr~…` value stays recognisable. This is the URL's only secret
157
+ * pass — the URL was extracted before the text-wide token pass ran. */
158
+ function redactUrlQuery(tail: string): string {
159
+ return tail.replace(/([?&#;])([^?&#;]*)/gu, (_match: string, separator: string, pair: string) => {
160
+ const truncated = pair.replace(VIRTUAL_ID, (_m: string, serverId: string, id: string) => `zr~${serverId}~${id.replace(/^session-/, '').slice(0, 8)}`)
161
+ const eq = truncated.indexOf('=')
162
+ if (eq === -1) return `${separator}${HAS_TOKEN_RUN.test(truncated) ? TOKEN_PLACEHOLDER : truncated}`
163
+ const value = truncated.slice(eq + 1)
164
+ return `${separator}${truncated.slice(0, eq)}=${HAS_TOKEN_RUN.test(value) ? TOKEN_PLACEHOLDER : value}`
165
+ })
166
+ }
167
+
168
+ /**
169
+ * One extracted URL in its reportable form: userinfo dropped whole to
170
+ * `<token>@`, host degraded to its address class, port and path KEPT (the
171
+ * SPEC's "端口与 path 保留"), path segments and query values carrying a
172
+ * secret-shaped run replaced by `<token>`.
173
+ *
174
+ * The userinfo boundary is the LAST `@` before the first `?`/`#` (RFC 3986:
175
+ * the authority cannot run past them), so a password holding `/`
176
+ * (`bob:ab/cd@host`) is still userinfo. The cost — a path that itself holds
177
+ * `@` loses everything before it to `<token>@` — is over-redaction, the
178
+ * safe direction.
179
+ */
180
+ function degradeUrl(raw: string): string {
181
+ const schemeEnd = raw.indexOf('://')
182
+ const scheme = raw.slice(0, schemeEnd)
183
+ const rest = raw.slice(schemeEnd + 3)
184
+ const queryAt = rest.search(/[?#]/u)
185
+ const head = queryAt === -1 ? rest : rest.slice(0, queryAt)
186
+ const tail = queryAt === -1 ? '' : rest.slice(queryAt)
187
+ const at = head.lastIndexOf('@')
188
+ const afterUser = at === -1 ? head : head.slice(at + 1)
189
+ const hostPort = /^(\[[^\]]*\]|[^/:]*)(:\d+)?/u.exec(afterUser)
190
+ const host = hostPort?.[1] ?? ''
191
+ const port = hostPort?.[2] ?? ''
192
+ const path = afterUser.slice(hostPort?.[0].length ?? 0)
193
+ return `${scheme}://${at === -1 ? '' : `${TOKEN_PLACEHOLDER}@`}${classifyHost(host)}${port}${redactUrlPath(path)}${redactUrlQuery(tail)}`
194
+ }
195
+
196
+ /** The UNC pass: `\\server\share\…\leaf` → `<路径:leaf>`. Server and share
197
+ * are mandatory and never kept; inner segments may hold spaces
198
+ * (`Shared Docs`), the leaf may not — so trailing prose stays outside. */
199
+ const UNC_PATH = /(?<![\w\\])\\\\[^\\/:*?"<>|\s]+\\[^\\/:*?"<>|\r\n]+(?:\\[^\\/:*?"<>|\r\n]+)*\\([^\\/:*?"<>|\s]+)/gu
200
+
201
+ /** A bare UNC root (`\\server\share`, no leaf beyond the share): nothing in
202
+ * it is safe to keep. Runs after {@link UNC_PATH}. */
203
+ const UNC_ROOT = /(?<![\w\\])\\\\[^\\/:*?"<>|\s]+\\[^\\/:*?"<>|\s]+/gu
204
+
205
+ /** The virtual-id pass: `zr~<serverId>~<id>` keeps the server id and the
206
+ * session id's first 8 characters (the debug report needs "same server?
207
+ * same session?" — not the full id). */
208
+ const VIRTUAL_ID = /zr~([^~\s]{1,80})~([^~\s]{1,80})/gu
209
+
210
+ /** The Windows absolute-path pass: drive letter, colon, separator, then path
211
+ * segments — reduced to `<路径:leaf>`. The drive letter must not follow a
212
+ * word character (`https:/` is not a drive); inner segments may hold spaces
213
+ * (`Program Files`), the leaf may not — so `D:\x\plan.txt failed` keeps
214
+ * `failed` outside the placeholder. */
215
+ const WINDOWS_PATH = /(?<!\w)([A-Za-z]:[\\/](?:[^\\/:*?"<>|\r\n]+[\\/])*)([^\\/:*?"<>|\s]+)/gu
216
+
217
+ /** The Unix absolute-path pass, same reduction. The boundary lookbehind
218
+ * (string start, whitespace, `(`, `~`, `>`) keeps relative paths, URL path
219
+ * segments and `a/b` words out; `~` is excluded so a home-folded path
220
+ * (`~/work/x.txt`) is never re-matched. */
221
+ const UNIX_PATH = /(?<![\w:/.\\~])(\/(?:[^/\s]+\/)*)([^/\s]+)/gu
222
+
223
+ /**
224
+ * Redact one free-text value: home paths fold to `~` (their relative tail is
225
+ * kept — that is the one shape the report intentionally preserves), absolute
226
+ * paths reduce to `<路径:leaf>`, URL hostnames degrade to their address
227
+ * class, virtual session ids truncate, secret-shaped runs become
228
+ * `<token>`. Non-strings pass through unchanged.
229
+ */
230
+ export function redactText(text: string, context: { home?: string }): string {
231
+ if (typeof text !== 'string' || text === '') return text
232
+ // 0. URLs out first, each replaced by a sentinel and restored LAST in
233
+ // degraded form — so no text-wide pass can merge a URL's port and path
234
+ // into one token run (the "ws://…:3080/_dsh/…" port loss), and the
235
+ // userinfo/query handling lives in one place (degradeUrl). Any NUL the
236
+ // input carried is neutralized first: a sentinel can only be ours.
237
+ const urls: string[] = []
238
+ let out = text.replaceAll('\u0000', '\uFFFD').replace(URL_CANDIDATE, (match: string) => {
239
+ const trailing = URL_TRAILING.exec(match)?.[0] ?? ''
240
+ urls.push(match.slice(0, match.length - trailing.length))
241
+ return `\u0000zr-url:${urls.length - 1}\u0000${trailing}`
242
+ })
243
+ // 1. virtual ids: a ≥32-char id would otherwise die as a token run. The
244
+ // id's `session-` label is stripped before the cut — the ids are all
245
+ // `session-<uuid>`, so the head 8 of the label would carry nothing.
246
+ out = out.replace(VIRTUAL_ID, (_match: string, serverId: string, id: string) => `zr~${serverId}~${id.replace(/^session-/, '').slice(0, 8)}`)
247
+ // 2. the home directory folds to `~` BEFORE the token pass — a long home
248
+ // path is itself a ≥32 run and would otherwise die whole, losing the
249
+ // `~/Downloads/x.bin` shape the SPEC promises (folding only removes
250
+ // information, so running it early is safe). Guarded: never fold the
251
+ // filesystem root; a separator the fold leaves behind is normalized, so a
252
+ // Windows home path reads `~/doc.txt` like its Unix counterpart.
253
+ const home = context.home
254
+ if (typeof home === 'string' && home.length > 1) {
255
+ out = out.split(home).join('~').replaceAll('~\\', '~/')
256
+ }
257
+ // 3. secret-shaped runs.
258
+ out = out.replace(TOKEN_RUN, () => TOKEN_PLACEHOLDER)
259
+ // 4. UNC paths, then bare UNC roots — before the drive-letter pass.
260
+ out = out.replace(UNC_PATH, (_match: string, leaf: string) => `<路径:${leaf}>`)
261
+ out = out.replace(UNC_ROOT, () => '<路径>')
262
+ // 5. Windows absolute paths → <路径:leaf>.
263
+ out = out.replace(WINDOWS_PATH, (_match: string, _prefix: string, leaf: string) => `<路径:${leaf}>`)
264
+ // 6. Unix absolute paths → <路径:leaf> (the leaf class already excludes
265
+ // whitespace).
266
+ out = out.replace(UNIX_PATH, (_match: string, _prefix: string, leaf: string) => `<路径:${leaf}>`)
267
+ // 7. URLs back in, degraded: userinfo → `<token>@`, host → address class,
268
+ // port and path kept, secret-shaped path segments / query values →
269
+ // `<token>`.
270
+ return out.replace(URL_SENTINEL, (_match: string, index: string) => degradeUrl(urls[Number(index)] ?? ''))
271
+ }
272
+
273
+ /**
274
+ * Deep-redact one JSON-ready value: every string (at any depth) through
275
+ * {@link redactText}; keys are kept as-is (plugin names, field names —
276
+ * public identifiers), numbers/booleans/null pass through. Returns new
277
+ * containers; the input is never mutated.
278
+ */
279
+ export function redactValue(value: unknown, context: { home?: string }): unknown {
280
+ if (typeof value === 'string') return redactText(value, context)
281
+ if (Array.isArray(value)) return value.map((item) => redactValue(item, context))
282
+ if (value !== null && typeof value === 'object') {
283
+ const out: Record<string, unknown> = {}
284
+ for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
285
+ out[key] = redactValue(item, context)
286
+ }
287
+ return out
288
+ }
289
+ return value
290
+ }