dsh-zen-remote 2.3.6 → 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 (66) 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 +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/types/client/effects/remote-drop.d.ts +20 -8
  25. package/lib/types/client/effects/remote-drop.d.ts.map +1 -1
  26. package/lib/types/client/locales.d.ts +8 -1
  27. package/lib/types/client/locales.d.ts.map +1 -1
  28. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
  29. package/lib/types/client/styles/misc.css.d.ts +1 -1
  30. package/lib/types/client/styles/misc.css.d.ts.map +1 -1
  31. package/lib/types/client-data/remote-drop.d.ts +64 -0
  32. package/lib/types/client-data/remote-drop.d.ts.map +1 -1
  33. package/lib/types/client-data/settings-form.d.ts +3 -0
  34. package/lib/types/client-data/settings-form.d.ts.map +1 -1
  35. package/lib/types/client-pairing.d.ts +8 -0
  36. package/lib/types/client-pairing.d.ts.map +1 -1
  37. package/lib/types/client-routes.d.ts +23 -0
  38. package/lib/types/client-routes.d.ts.map +1 -1
  39. package/lib/types/debug-redact.d.ts +68 -0
  40. package/lib/types/debug-redact.d.ts.map +1 -0
  41. package/lib/types/debug-report.d.ts +58 -0
  42. package/lib/types/debug-report.d.ts.map +1 -0
  43. package/lib/types/fetch-route-intercept.d.ts +26 -0
  44. package/lib/types/fetch-route-intercept.d.ts.map +1 -1
  45. package/lib/types/index.d.ts +7 -1
  46. package/lib/types/index.d.ts.map +1 -1
  47. package/lib/types/log-ring.d.ts +57 -0
  48. package/lib/types/log-ring.d.ts.map +1 -0
  49. package/package.json +3 -2
  50. package/pwa/app.css +13 -2
  51. package/scripts/check-debug-report.mjs +277 -0
  52. package/scripts/check-remote-drop.mjs +130 -0
  53. package/scripts/check-tablet-safe-area.mjs +49 -0
  54. package/src/client/effects/remote-drop.ts +271 -24
  55. package/src/client/locales.ts +20 -4
  56. package/src/client/settings/SettingsSection.tsx +120 -3
  57. package/src/client/styles/misc.css.ts +38 -0
  58. package/src/client-data/remote-drop.ts +103 -0
  59. package/src/client-data/settings-form.ts +3 -0
  60. package/src/client-pairing.ts +11 -0
  61. package/src/client-routes.ts +196 -90
  62. package/src/debug-redact.ts +290 -0
  63. package/src/debug-report.ts +93 -0
  64. package/src/fetch-route-intercept.ts +29 -0
  65. package/src/index.ts +282 -43
  66. package/src/log-ring.ts +101 -0
package/src/index.ts CHANGED
@@ -59,7 +59,7 @@ import { runSelfCheck, installIntercept } from './intercept.js'
59
59
  import type { CurrentSessionRead, InterceptDiagnostics, InterceptHandle, SessionInventorySlot } from './intercept.js'
60
60
  import { checkGatewayShape } from './intercept-shape.js'
61
61
  import type { GatewayShapeCheck } from './intercept-shape.js'
62
- import { checkFetchRouteShape, installFetchRouteIntercept } from './fetch-route-intercept.js'
62
+ import { checkFetchRouteShape, installFetchRouteIntercept, shouldFallbackInstall } from './fetch-route-intercept.js'
63
63
  import type { FetchRouteInterceptDiagnostics, FetchRouteInterceptHandle, FetchRouteShapeCheck } from './fetch-route-intercept.js'
64
64
  import { checkWebServerShape, installPluginRouteIntercept } from './plugin-route-intercept.js'
65
65
  import type { PluginRouteInterceptDiagnostics, PluginRouteInterceptHandle, WebServerShapeCheck } from './plugin-route-intercept.js'
@@ -87,6 +87,7 @@ import { createVerificationManager } from './wizard/verify.js'
87
87
  import type { BridgeDeclaration, BridgeRegistry, BridgeStatus, ServerPluginEntry, ZenRemoteService } from './plugin-bridge.js'
88
88
  import { createPluginLoopback } from './plugin-loopback.js'
89
89
  import { createPluginTunnel } from './plugin-tunnel.js'
90
+ import { createLogRing, createPluginLog } from './log-ring.js'
90
91
  import { DEFAULT_MAX_UPLOAD_BYTES } from './upload-limits.js'
91
92
 
92
93
  // The plugin bridge's public types (PB-1), re-exported so another plugin's
@@ -112,6 +113,19 @@ import * as push from '../dsh-push.mjs'
112
113
  /** Exact route the phone composer POSTs one file body to. */
113
114
  export const UPLOAD_ROUTE = '/_dsh/mobile-nav/upload'
114
115
 
116
+ /**
117
+ * This package's own version, read once off the package root (`../package.json`
118
+ * resolves there both from src/ under the type-stripping scripts and from the
119
+ * built lib/ in production). The debug report's `_meta` plate carries it —
120
+ * issue triage starts at "which build is this" — and the row-apply log line
121
+ * stamps it. Unreadable file degrades to `'unknown'`, never throws.
122
+ */
123
+ let pluginVersion = 'unknown'
124
+ try {
125
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version?: unknown }
126
+ if (typeof pkg?.version === 'string' && pkg.version !== '') pluginVersion = pkg.version
127
+ } catch { /* keep 'unknown' */ }
128
+
115
129
  /**
116
130
  * The one piece of cordis the typert interception needs at RUNTIME: the
117
131
  * registered symbol under which every cordis proxy answers with its raw
@@ -457,6 +471,12 @@ async function writeUpload(
457
471
  * request must ask the connection service itself, its refusal relayed
458
472
  * verbatim — same first wall as the admin and client routes. Optional only
459
473
  * for the direct-drive check scripts; production always passes it.
474
+ * @param warn - where the rejection line goes. apply() passes its double-
475
+ * writer (context logger + the CU-1 log ring), so a rejected upload — the
476
+ * very path issue #49 is triaged on — shows up in the debug report. The
477
+ * ring is per-apply on purpose (a row reload clears it), which is why it
478
+ * arrives as a parameter instead of a module singleton. Defaults to the
479
+ * context logger for the direct-drive check scripts.
460
480
  */
461
481
  export async function handleUpload(
462
482
  ctx: Context,
@@ -464,6 +484,7 @@ export async function handleUpload(
464
484
  req: IncomingMessage,
465
485
  res: ServerResponse,
466
486
  admit?: (req: IncomingMessage) => AdminAdmission,
487
+ warn?: (format: string, ...args: unknown[]) => void,
467
488
  ): Promise<void> {
468
489
  if (admit !== undefined) {
469
490
  const admission = admit(req)
@@ -502,7 +523,8 @@ export async function handleUpload(
502
523
  } catch (error) {
503
524
  const status = error instanceof UploadError ? error.status : 400
504
525
  const code = error instanceof UploadError ? error.code : 'upload-rejected'
505
- ctx.logger.warn('dsh-mobile-nav upload rejected: %s', message(error))
526
+ if (warn !== undefined) warn('dsh-mobile-nav upload rejected: %s', message(error))
527
+ else ctx.logger.warn('dsh-mobile-nav upload rejected: %s', message(error))
506
528
  responseJson(res, status, { ok: false, error: { code, message: message(error) } })
507
529
  }
508
530
  }
@@ -529,7 +551,20 @@ export async function handleUpload(
529
551
  * request.
530
552
  */
531
553
  export function apply(ctx: Context, config: MobileNavConfig = {}): void {
554
+ // CU-1: the plugin's own log ring (SPEC 方案 C) — rebuilt per apply, so a
555
+ // row reload clears it (expected: the report describes THIS process
556
+ // generation). `logWarn` / `logInfo` are the branch-shared double-writers:
557
+ // every line below reaches the context logger exactly as before and the
558
+ // ring besides, so the debug report (CU-2) can answer "what did the plugin
559
+ // actually log" without a log file (the desktop app writes none).
560
+ const ring = createLogRing()
561
+ const pluginLog = createPluginLog(ring, ctx.logger as Parameters<typeof createPluginLog>[1])
562
+ const logWarn = pluginLog.warn
563
+ const logInfo = pluginLog.info
532
564
  const effective = resolveConfig(config, readFileConfig(), process.env)
565
+ // Lifecycle: the row came up. Role and both versions are the first things
566
+ // an issue report needs.
567
+ ring.push('info', 'row apply: role=%s plugin=%s dsh=%s', effective.values.role, pluginVersion, resolveDshVersion())
533
568
  // RD-6 probe fact: whether the fetch-route inject callback (below, in the
534
569
  // client-half wiring) ever fired. On a composition where `connection` or
535
570
  // `fileUploads` never becomes available the callback stays silently
@@ -537,30 +572,49 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
537
572
  // diagnostic stays undefined — this flag is the one honest signal of that
538
573
  // state. Declared this early because probes on BOTH role branches read it.
539
574
  let fetchRouteInjectSeen = false
575
+ /** BU-1: how the fetch-route wrap got installed — `'inject'` via the
576
+ * primary `ctx.inject` callback, `'fallback'` via the bounded lazy timer
577
+ * below, `undefined` while not installed. Read live by the service probe. */
578
+ let fetchRouteInstalledVia: 'inject' | 'fallback' | undefined
579
+ /** BU-1: ISO timestamp of the moment a fallback attempt FAILED (shape
580
+ * refused, original unresolvable) — the tick stops after one, so this is
581
+ * also "the fallback gave up here". An install success does not set it. */
582
+ let fetchRouteFallbackTriedAt: string | undefined
540
583
  /** The client branch's fetch-route diagnostics getter, assigned once that
541
584
  * branch declares it; the probe reads it lazily through this slot so both
542
585
  * role mounts can share one probe factory. */
543
586
  let fetchRouteDiagnosticsRef: (() => unknown) | undefined
544
587
  /** RD-6: build the lazy service probe against a context whose own inject
545
588
  * already resolved (the client-routes mounts qualify on both roles). One
546
- * reflection per process: `connection` / `fileUploads` presence plus the
547
- * fetchRoutes table's shape — enough to name the missing service. */
589
+ * reflection per process for the SERVICE PRESENCE facts (below); the
590
+ * fetch-route facts are re-read live on every call — they change while the
591
+ * page is open (the inject may fire, or the fallback may step in, long
592
+ * after the first probe). */
548
593
  const uploadServiceProbe = (probeCtx: Context): (() => Record<string, unknown>) => {
549
- let cached: Record<string, unknown> | undefined
594
+ let cached: {
595
+ connectionService: string
596
+ fileUploadsService: string
597
+ connectionShape?: FetchRouteShapeCheck
598
+ } | undefined
550
599
  return () => {
551
- if (cached !== undefined) return cached
552
- const reflect = (probeCtx as Context & { reflect?: { get(name: string): unknown } }).reflect
553
- const connection = reflect?.get('connection')
554
- const fileUploads = reflect?.get('fileUploads')
600
+ if (cached === undefined) {
601
+ const reflect = (probeCtx as Context & { reflect?: { get(name: string): unknown } }).reflect
602
+ const connection = reflect?.get('connection')
603
+ const fileUploads = reflect?.get('fileUploads')
604
+ cached = {
605
+ connectionService: connection === undefined ? 'absent' : 'present',
606
+ fileUploadsService: fileUploads === undefined ? 'absent' : 'present',
607
+ ...(connection === undefined ? {} : { connectionShape: checkFetchRouteShape(connection) }),
608
+ }
609
+ }
555
610
  const fetchRoute = fetchRouteDiagnosticsRef?.()
556
- cached = {
611
+ return {
557
612
  uploadInjectSeen: fetchRouteInjectSeen,
558
- connectionService: connection === undefined ? 'absent' : 'present',
559
- fileUploadsService: fileUploads === undefined ? 'absent' : 'present',
560
- ...(connection === undefined ? {} : { connectionShape: checkFetchRouteShape(connection) }),
613
+ installedVia: fetchRouteInstalledVia,
614
+ ...(fetchRouteFallbackTriedAt === undefined ? {} : { fallbackTriedAt: fetchRouteFallbackTriedAt }),
615
+ ...cached,
561
616
  ...(fetchRoute === undefined ? {} : { fetchRoute }),
562
617
  }
563
- return cached
564
618
  }
565
619
  }
566
620
  // The plugin bridge's zenRemote service (PB-1), provided on BOTH roles
@@ -580,7 +634,7 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
580
634
  // failing the row over — the bridge stays off, everything else keeps
581
635
  // working — so it degrades to a warning like the session feeds do.
582
636
  const registry = createBridgeRegistry({
583
- warn: (text) => { ctx.logger?.warn('dsh-zen-remote plugin bridge: %s', text) },
637
+ warn: (text) => { logWarn('dsh-zen-remote plugin bridge: %s', text) },
584
638
  })
585
639
  bridgeRegistry = registry
586
640
  try {
@@ -589,7 +643,7 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
589
643
  }
590
644
  ctx.provide('zenRemote', service)
591
645
  } catch (error) {
592
- ctx.logger?.warn('dsh-zen-remote: cannot provide the "zenRemote" service: %s', message(error))
646
+ logWarn('dsh-zen-remote: cannot provide the "zenRemote" service: %s', message(error))
593
647
  }
594
648
  ctx.effect(() => () => {
595
649
  if (bridgeRegistry === registry) bridgeRegistry = undefined
@@ -613,7 +667,7 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
613
667
  // halves.
614
668
  const adapters: NormalizedAdapter[] = []
615
669
  {
616
- const adapterWarn = (text: string): void => { ctx.logger?.warn('dsh-zen-remote adapter: %s', text) }
670
+ const adapterWarn = (text: string): void => { logWarn('dsh-zen-remote adapter: %s', text) }
617
671
  for (const adapter of ADAPTERS) {
618
672
  try {
619
673
  const holder = ctx as unknown as { get?: (name: string) => unknown; baseUrl?: string }
@@ -660,15 +714,27 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
660
714
  registry.registerNormalized(normalized.declaration)
661
715
  adapters.push(normalized)
662
716
  if (unlisted && surfaceDigest !== undefined) {
663
- ctx.logger?.info('dsh-zen-remote adapter: %s bridged by surface digest %s (version %s unlisted)', normalized.plugin, surfaceDigest, installedVersion)
717
+ logInfo('dsh-zen-remote adapter: %s bridged by surface digest %s (version %s unlisted)', normalized.plugin, surfaceDigest, installedVersion)
664
718
  } else {
665
- ctx.logger?.info('dsh-zen-remote adapter: %s adapted at version %s', normalized.plugin, installedVersion)
719
+ logInfo('dsh-zen-remote adapter: %s adapted at version %s', normalized.plugin, installedVersion)
666
720
  }
667
721
  } catch (error) {
668
722
  adapterWarn(`${adapter.plugin} refused: ${error instanceof Error ? error.message : String(error)}`)
669
723
  }
670
724
  }
671
725
  }
726
+ // CU-3: the debug-report route's profile fact — read per call through the
727
+ // reflection layer's store lookup (a composition without profileContext
728
+ // answers undefined, and a throwing read degrades to undefined too).
729
+ const profileName = (): string | undefined => {
730
+ try {
731
+ const holder = ctx as unknown as { get?: (name: string) => unknown }
732
+ const profile = holder.get?.('profileContext') as { name?: unknown } | undefined
733
+ return typeof profile?.name === 'string' && profile.name !== '' ? profile.name : undefined
734
+ } catch {
735
+ return undefined
736
+ }
737
+ }
672
738
  if (effective.values.role === 'host') {
673
739
  // A row that switched from client to host must not leave a stale relay
674
740
  // client where getRelayClient() can find it (T23a-fix).
@@ -677,7 +743,24 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
677
743
  // gateway child through LAN_GATE_RELAY_SECRET and kept here for the
678
744
  // relay routes below. The push half never sees it.
679
745
  const relaySecret = randomBytes(32).toString('hex')
680
- ctx.plugin(gateway, { ...effective.values, relaySecret })
746
+ // CU-1 lifecycle: the gateway child's spawn/exit, reported back through
747
+ // this hook (lan-gate.mjs reads it off the config object it is handed).
748
+ // The child runs in a subprocess whose console the ring cannot see, so
749
+ // these two lines are the debug report's only record of it. Either call
750
+ // must never break the gateway half — lan-gate.mjs guards its side, we
751
+ // guard ours. The hook rides as a NON-enumerable property: it is wiring,
752
+ // not configuration — the gateway's config stays exactly "resolved values
753
+ // + relaySecret" for every spread, deep compare and serialization (the
754
+ // role-wiring test pins that shape), while a direct read still finds it.
755
+ const gatewayEvent = (event: 'spawn' | 'exit', detail: Record<string, unknown>): void => {
756
+ try {
757
+ if (event === 'spawn') ring.push('info', 'gateway spawned (port=%s)', String(detail.port ?? '?'))
758
+ else ring.push('warn', 'gateway exited (code=%s signal=%s error=%s)', String(detail.code ?? '—'), String(detail.signal ?? '—'), String(detail.error ?? '—'))
759
+ } catch { /* the ring must never break the wiring */ }
760
+ }
761
+ const gatewayConfig: Record<string, unknown> = { ...effective.values, relaySecret }
762
+ Object.defineProperty(gatewayConfig, 'gatewayEvent', { value: gatewayEvent, enumerable: false })
763
+ ctx.plugin(gateway, gatewayConfig)
681
764
  ctx.plugin(push, effective.values)
682
765
  // The shared-session table backing the relay's access control. The relay
683
766
  // route consumes it below; the activity tracker and idle sweeper (T22c)
@@ -720,7 +803,7 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
720
803
  tracker.onEvent(sessionId, event.type)
721
804
  })
722
805
  } catch (error) {
723
- ctx.logger.warn('dsh-zen-remote cannot listen on "session/event": %s', message(error))
806
+ logWarn('dsh-zen-remote cannot listen on "session/event": %s', message(error))
724
807
  }
725
808
  // Auto-share and fork-following (T33a), off `agent/created` — the only
726
809
  // host event that fires EXACTLY ONCE per NEW session. Its `source` is
@@ -758,11 +841,11 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
758
841
  parentOf: parentIndex.parentOf,
759
842
  })
760
843
  } catch (error) {
761
- ctx.logger.warn('dsh-zen-remote cannot share new session "%s": %s', agent.id, message(error))
844
+ logWarn('dsh-zen-remote cannot share new session "%s": %s', agent.id, message(error))
762
845
  }
763
846
  })
764
847
  } catch (error) {
765
- ctx.logger.warn('dsh-zen-remote cannot listen on "agent/created": %s', message(error))
848
+ logWarn('dsh-zen-remote cannot listen on "agent/created": %s', message(error))
766
849
  }
767
850
  // Busy restore (T33a): `busy` is never persisted, so right after a row
768
851
  // restart every shared session believes itself idle — including one whose
@@ -785,19 +868,19 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
785
868
  sessionPersistence?: { list(): Promise<readonly PersistedSessionSnapshot[]> } | undefined
786
869
  }).sessionPersistence
787
870
  if (persistence === undefined) {
788
- ctx.logger.warn('dsh-zen-remote: no sessionPersistence service — pre-restart subagent sessions stay not-shared until they emit an event')
871
+ logWarn('dsh-zen-remote: no sessionPersistence service — pre-restart subagent sessions stay not-shared until they emit an event')
789
872
  return
790
873
  }
791
874
  void persistence
792
875
  .list()
793
876
  .then((snapshots) => {
794
877
  const recorded = seedParentIndexFromSnapshots(parentIndex, snapshots)
795
- if (recorded > 0) ctx.logger.info?.('dsh-zen-remote: restored %d persisted parent links', recorded)
878
+ if (recorded > 0) logInfo('dsh-zen-remote: restored %d persisted parent links', recorded)
796
879
  })
797
- .catch((error: unknown) => ctx.logger.warn('dsh-zen-remote cannot seed parent links from persisted sessions: %s', message(error)))
880
+ .catch((error: unknown) => logWarn('dsh-zen-remote cannot seed parent links from persisted sessions: %s', message(error)))
798
881
  })
799
882
  } catch (error) {
800
- ctx.logger.warn('dsh-zen-remote cannot listen on "sessionPersistence": %s', message(error))
883
+ logWarn('dsh-zen-remote cannot listen on "sessionPersistence": %s', message(error))
801
884
  }
802
885
  // Settings-surface admin routes (T14). The browser cannot call the
803
886
  // gateway's loopback-only admin API from DSH's origin, so the host
@@ -1221,6 +1304,35 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
1221
1304
  }
1222
1305
  }, 'dsh-zen-remote: relay route')
1223
1306
  })
1307
+ // CU-3 (host mount): the gateway child's summary plate for the debug
1308
+ // report — one best-effort probe of the gateway's own status endpoint
1309
+ // (the same seam the wizard's runtime probe reads) reduced to liveness,
1310
+ // port and COUNTS. The endpoint's device rows carry no tokens and we
1311
+ // keep none of their fields anyway; the share table's size is read off
1312
+ // the store this apply owns. The gateway being down IS the summary.
1313
+ const gatewaySummary = async (): Promise<Record<string, unknown>> => {
1314
+ let reachable = false
1315
+ let port: number | undefined
1316
+ let deviceCount: number | undefined
1317
+ let pushSubscriptions: number | undefined
1318
+ try {
1319
+ const response = await fetch(`http://127.0.0.1:${effective.values.port}/lan-gate/status`, { signal: AbortSignal.timeout(3000) })
1320
+ reachable = response.ok
1321
+ if (reachable) {
1322
+ const body = await response.json() as { port?: unknown; devices?: unknown; pushSubscriptions?: unknown }
1323
+ if (typeof body?.port === 'number') port = body.port
1324
+ if (Array.isArray(body?.devices)) deviceCount = body.devices.length
1325
+ if (typeof body?.pushSubscriptions === 'number') pushSubscriptions = body.pushSubscriptions
1326
+ }
1327
+ } catch { /* unreachable is an honest answer */ }
1328
+ return {
1329
+ reachable,
1330
+ ...(port === undefined ? {} : { port }),
1331
+ ...(deviceCount === undefined ? {} : { deviceCount }),
1332
+ ...(pushSubscriptions === undefined ? {} : { pushSubscriptions }),
1333
+ shareCount: store.list().length,
1334
+ }
1335
+ }
1224
1336
  // The remote-status route lives on BOTH roles (T34-fix): it is what the
1225
1337
  // client-half parts poll, and a desktop app can switch roles without a
1226
1338
  // code path change — on a host it reads the empty
@@ -1239,6 +1351,10 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
1239
1351
  getRowConfig: () => config,
1240
1352
  remoteStatusOnly: true,
1241
1353
  getServiceProbe: uploadServiceProbe(clientCtx),
1354
+ getLogSnapshot: () => ring.snapshot(),
1355
+ getGatewaySummary: gatewaySummary,
1356
+ getProfileName: profileName,
1357
+ getPluginVersion: () => pluginVersion,
1242
1358
  }),
1243
1359
  }), 'dsh-zen-remote: client routes (host)')
1244
1360
  })
@@ -1280,6 +1396,22 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
1280
1396
  // fresh handshake or a link flip shows up at the very next read.
1281
1397
  const pluginBridgeStatus = (): BridgeStatus[] =>
1282
1398
  compareBridges(registry.list(), relayClient.handshakeInfo?.plugins, relayClient.state === 'online')
1399
+ // CU-1 lifecycle: the relay link's flips enter the ring — `online` with
1400
+ // the server id it handshook, `offline` with the mapped failure code
1401
+ // (relayClient.lastError is a short code by contract, never free text).
1402
+ // subscribe re-announces the CURRENT state when only the handshake
1403
+ // identity moved (the T59 heartbeat), so the dedupe below keeps the
1404
+ // heartbeats out of the ring: one line per actual state change.
1405
+ let lastRelayRingState: string | undefined
1406
+ const offRelayRing = relayClient.subscribe((state) => {
1407
+ if (state === lastRelayRingState) return
1408
+ lastRelayRingState = state
1409
+ if (state === 'online') {
1410
+ ring.push('info', 'relay link online (server=%s)', relayClient.handshakeInfo?.serverId ?? '?')
1411
+ } else if (state === 'offline') {
1412
+ ring.push('warn', 'relay link offline (code=%s)', relayClient.lastError ?? '?')
1413
+ }
1414
+ })
1283
1415
  // A stopped or reloaded row must not leave a stale instance where the
1284
1416
  // module getter can hand it out (T23a-fix), and its reconnect machinery
1285
1417
  // (T43) must not outlive the row either: stop() cancels the armed retry
@@ -1287,6 +1419,7 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
1287
1419
  // dispose from clearing a NEWER row's instance that a reload already
1288
1420
  // installed.
1289
1421
  ctx.effect(() => () => {
1422
+ offRelayRing()
1290
1423
  relayClient.stop()
1291
1424
  if (clientRelayClient === relayClient) clientRelayClient = undefined
1292
1425
  }, 'dsh-zen-remote: relay client disposal')
@@ -1322,7 +1455,7 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
1322
1455
  off()
1323
1456
  }
1324
1457
  } catch (error) {
1325
- ctx.logger.warn('dsh-zen-remote: cannot listen on "loader/volatile-update" for relay credentials: %s', message(error))
1458
+ logWarn('dsh-zen-remote: cannot listen on "loader/volatile-update" for relay credentials: %s', message(error))
1326
1459
  return () => {}
1327
1460
  }
1328
1461
  }, 'dsh-zen-remote: relay credentials watcher')
@@ -1333,7 +1466,7 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
1333
1466
  void relayClient.connect().catch((error) => {
1334
1467
  if (error instanceof RelayError && error.code === 'unpaired') return
1335
1468
  const code = error instanceof RelayError ? error.code : 'error'
1336
- ctx.logger.warn('dsh-zen-remote relay client initial connect failed (%s): %s', code, message(error))
1469
+ logWarn('dsh-zen-remote relay client initial connect failed (%s): %s', code, message(error))
1337
1470
  })
1338
1471
  // The typertGateway interception (T23b-1): remote-session calls ride the
1339
1472
  // relay, everything else reaches the local gateway untouched. The live
@@ -1397,9 +1530,6 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
1397
1530
  return undefined
1398
1531
  }
1399
1532
  ctx.inject(['typertGateway'], (gatewayCtx) => {
1400
- // Degraded logging: the real cordis context always carries a logger,
1401
- // but a row fake without one must not crash apply.
1402
- const logWarn = (...args: Parameters<Context['logger']['warn']>): void => { ctx.logger?.warn(...args) }
1403
1533
  const proxy = (gatewayCtx as Context & { typertGateway?: unknown }).typertGateway
1404
1534
  const raw = proxy !== null && typeof proxy === 'object'
1405
1535
  ? (proxy as Record<PropertyKey, unknown>)[symbols.original]
@@ -1425,7 +1555,7 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
1425
1555
  // PB-25b: each merged stream reports its real-session inventory
1426
1556
  // share into the union above.
1427
1557
  createSessionInventorySlot,
1428
- log: (format, ...args) => { ctx.logger?.info(`dsh-zen-remote ${format}`, ...args) },
1558
+ log: (format, ...args) => { logInfo(`dsh-zen-remote ${format}`, ...args) },
1429
1559
  })
1430
1560
  interceptHandle = handle
1431
1561
  gatewayCtx.effect(() => () => {
@@ -1447,7 +1577,7 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
1447
1577
  void runSelfCheck(raw, {
1448
1578
  handle,
1449
1579
  log: logWarn,
1450
- info: (format, ...args) => { ctx.logger?.info(`dsh-zen-remote ${format}`, ...args) },
1580
+ info: (format, ...args) => { logInfo(`dsh-zen-remote ${format}`, ...args) },
1451
1581
  getPeer: createOwnerPeerSource(() => gatewayCtx.reflect.get('connection')),
1452
1582
  })
1453
1583
  })
@@ -1490,8 +1620,12 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
1490
1620
  fetchRouteDiagnosticsRef = fetchRouteDiagnostics
1491
1621
  ctx.inject(['connection', 'fileUploads'], (connectionCtx) => {
1492
1622
  fetchRouteInjectSeen = true
1493
- const logWarn = (...args: Parameters<Context['logger']['warn']>): void => { ctx.logger?.warn(...args) }
1494
- const logInfo = (...args: Parameters<Context['logger']['info']>): void => { ctx.logger?.info(...args) }
1623
+ // BU-1 double-install guard: the fallback timer may have installed
1624
+ // while this callback sat PENDING — or recorded a refusal. Either way
1625
+ // this primary path has nothing left to do; installing again would
1626
+ // chain a second wrap over the first (behaviorally right, structurally
1627
+ // dirty) and overwrite the diagnostics' installedVia.
1628
+ if (fetchRouteHandle !== undefined || fetchRouteRefused !== undefined) return
1495
1629
  const proxy = (connectionCtx as Context & { connection?: unknown }).connection
1496
1630
  const raw = proxy !== null && typeof proxy === 'object'
1497
1631
  ? (proxy as unknown as Record<PropertyKey, unknown>)[symbols.original]
@@ -1517,11 +1651,109 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
1517
1651
  log: (format, ...args) => { logInfo(`dsh-zen-remote ${format}`, ...args) },
1518
1652
  })
1519
1653
  fetchRouteHandle = handle
1654
+ fetchRouteInstalledVia = 'inject'
1655
+ // CU-1 lifecycle: a successful install logs nothing today (the wrap's
1656
+ // own log lines only fire per call), so the ring gets one explicit
1657
+ // record — the debug report's "did the redirect actually attach" fact.
1658
+ // The refused and fallback paths funnel through logWarn/logInfo below
1659
+ // and enter the ring that way.
1660
+ ring.push('info', 'fetch-route intercept attached (via=inject)')
1520
1661
  connectionCtx.effect(() => () => {
1521
1662
  handle.uninstall()
1522
- if (fetchRouteHandle === handle) fetchRouteHandle = undefined
1663
+ if (fetchRouteHandle === handle) {
1664
+ fetchRouteHandle = undefined
1665
+ fetchRouteInstalledVia = undefined
1666
+ }
1523
1667
  }, 'dsh-zen-remote: fetch-route intercept')
1524
1668
  })
1669
+ // BU-1: the fetch-route FALLBACK install (SPEC §2). RD-6 proved the
1670
+ // machine-local failure mode: on one Windows client the
1671
+ // `['connection','fileUploads']` inject above sat silently PENDING (a
1672
+ // stuck row keeps cordis waiting forever, printing nothing), the wrap
1673
+ // never installed, and every remote upload fell through to the local
1674
+ // backend. The inject stays the FIRST choice — this timer only acts when
1675
+ // it demonstrably has not fired while BOTH services are present in the
1676
+ // reflection layer, then installs through the same
1677
+ // checkFetchRouteShape → symbols.original → installFetchRouteIntercept
1678
+ // path, sharing the handle/refused slots (which is also the
1679
+ // double-install guard: whichever path runs first, the other sees the
1680
+ // slots set and stands down). Bounded: a 3 s tick, at most 20 (a minute)
1681
+ // — a composition that never ships the services stops probing; unref'd
1682
+ // and cleared with the row.
1683
+ let fallbackTimer: ReturnType<typeof setInterval> | undefined
1684
+ let fallbackAttempts = 0
1685
+ const stopFallbackTimer = (): void => {
1686
+ if (fallbackTimer !== undefined) {
1687
+ clearInterval(fallbackTimer)
1688
+ fallbackTimer = undefined
1689
+ }
1690
+ }
1691
+ fallbackTimer = setInterval(() => {
1692
+ // The tick budget is checked BEFORE acting but does not cancel the
1693
+ // current tick: attempt #20 still runs, no tick #21 ever comes.
1694
+ fallbackAttempts += 1
1695
+ if (fallbackAttempts >= 20) stopFallbackTimer()
1696
+ // Same reflection the service probe reads: both services must be
1697
+ // PRESENT — their absence means a legitimate startup wait (or a
1698
+ // composition without them), where the inject must stay first choice.
1699
+ const reflect = (ctx as Context & { reflect?: { get(name: string): unknown } }).reflect
1700
+ const connection = reflect?.get('connection')
1701
+ const fileUploads = reflect?.get('fileUploads')
1702
+ if (!shouldFallbackInstall({
1703
+ injectSeen: fetchRouteInjectSeen,
1704
+ handleSet: fetchRouteHandle !== undefined,
1705
+ refused: fetchRouteRefused !== undefined,
1706
+ servicesPresent: connection !== undefined && fileUploads !== undefined,
1707
+ })) {
1708
+ // The services being absent is a WAIT (next tick re-judges); the
1709
+ // primary path having advanced is a VERDICT — the fallback has no
1710
+ // business left, stop probing.
1711
+ if (fetchRouteInjectSeen || fetchRouteHandle !== undefined || fetchRouteRefused !== undefined) stopFallbackTimer()
1712
+ return
1713
+ }
1714
+ // Shape against the proxy first (fetchRoutes reads through it); the
1715
+ // raw instance comes off the same symbols slot the inject path uses.
1716
+ const shape = checkFetchRouteShape(connection)
1717
+ if (!shape.ok) {
1718
+ // Structural mismatch is a verdict, not a wait: record the attempt
1719
+ // and stop. A later inject trigger may still install (it checks the
1720
+ // RAW instance, which can differ from what the proxy exposed).
1721
+ fetchRouteFallbackTriedAt = new Date().toISOString()
1722
+ stopFallbackTimer()
1723
+ logWarn('dsh-zen-remote fetch-route fallback declined: %s', shape.reasons.join('; '))
1724
+ return
1725
+ }
1726
+ const raw = connection !== null && typeof connection === 'object'
1727
+ ? (connection as unknown as Record<PropertyKey, unknown>)[symbols.original]
1728
+ : undefined
1729
+ if (raw === null || typeof raw !== 'object') {
1730
+ fetchRouteFallbackTriedAt = new Date().toISOString()
1731
+ stopFallbackTimer()
1732
+ logWarn('dsh-zen-remote fetch-route fallback declined: %s', 'original: connection[symbols.original] is not an object')
1733
+ return
1734
+ }
1735
+ const handle = installFetchRouteIntercept({
1736
+ connection: raw,
1737
+ relay: relayClient,
1738
+ getServerId: () => relayClient.handshakeInfo?.serverId,
1739
+ log: (format, ...args) => { logInfo(`dsh-zen-remote ${format}`, ...args) },
1740
+ })
1741
+ fetchRouteHandle = handle
1742
+ fetchRouteInstalledVia = 'fallback'
1743
+ stopFallbackTimer()
1744
+ logInfo('dsh-zen-remote fetch-route intercept installed via the fallback timer (inject never fired)')
1745
+ // Same disposal contract as the inject path: the wrap dies with the
1746
+ // row (the fallback has no service context of its own to hang on).
1747
+ ctx.effect(() => () => {
1748
+ handle.uninstall()
1749
+ if (fetchRouteHandle === handle) {
1750
+ fetchRouteHandle = undefined
1751
+ fetchRouteInstalledVia = undefined
1752
+ }
1753
+ }, 'dsh-zen-remote: fetch-route intercept (fallback)')
1754
+ }, 3_000)
1755
+ if (typeof fallbackTimer.unref === 'function') fallbackTimer.unref()
1756
+ ctx.effect(() => () => { stopFallbackTimer() }, 'dsh-zen-remote: fetch-route fallback timer')
1525
1757
  // The plugin bridge's route interception (PB-4): the local webServer's
1526
1758
  // `match` wrap forwarding bridged-plugin requests for REMOTE sessions
1527
1759
  // through the relay. Injected on `webServer` alone — the only service
@@ -1552,7 +1784,6 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
1552
1784
  return undefined
1553
1785
  }
1554
1786
  ctx.inject(['webServer'], (wsCtx) => {
1555
- const logWarn = (...args: Parameters<Context['logger']['warn']>): void => { ctx.logger?.warn(...args) }
1556
1787
  const proxy = (wsCtx as Context & { webServer?: unknown }).webServer
1557
1788
  const raw = proxy !== null && typeof proxy === 'object'
1558
1789
  ? (proxy as unknown as Record<PropertyKey, unknown>)[symbols.original]
@@ -1591,7 +1822,7 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
1591
1822
  // client's only early notice of a row that rides no workspace/follow
1592
1823
  // frame — hand it to the summary sync so the sidebar row appears.
1593
1824
  onMintedVirtualIds: (ids) => { interceptHandle?.nudgeSummarySync(ids) },
1594
- log: (format, ...args) => { ctx.logger?.info(`dsh-zen-remote ${format}`, ...args) },
1825
+ log: (format, ...args) => { logInfo(`dsh-zen-remote ${format}`, ...args) },
1595
1826
  })
1596
1827
  pluginRouteHandle = handle
1597
1828
  wsCtx.effect(() => () => {
@@ -1613,6 +1844,9 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
1613
1844
  getPluginRouteIntercept: pluginRouteDiagnostics,
1614
1845
  getPluginBridgeStatus: pluginBridgeStatus,
1615
1846
  onCurrentSession,
1847
+ getLogSnapshot: () => ring.snapshot(),
1848
+ getProfileName: profileName,
1849
+ getPluginVersion: () => pluginVersion,
1616
1850
  }),
1617
1851
  }), 'dsh-zen-remote: client routes')
1618
1852
  })
@@ -1639,17 +1873,21 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
1639
1873
  // dispose-and-reload entry. A fake context without a fiber (the route
1640
1874
  // tests) only ever reaches the warn.
1641
1875
  const reload = (): void => {
1876
+ // CU-1 lifecycle: the row is about to reload — the ring dies with this
1877
+ // apply right after, so this line is the reload's only witness in the
1878
+ // NEXT generation's report (the fresh apply's row-apply line follows).
1879
+ ring.push('info', 'row reload: a restart-required setting changed, reloading the plugin row')
1642
1880
  const fiber = (ctx as { fiber?: { restart?: () => Promise<unknown> } }).fiber
1643
1881
  if (fiber?.restart === undefined) {
1644
- ctx.logger.warn('dsh-zen-remote: a restart-required setting changed, but no fiber is available to reload the plugin row')
1882
+ logWarn('dsh-zen-remote: a restart-required setting changed, but no fiber is available to reload the plugin row')
1645
1883
  return
1646
1884
  }
1647
1885
  try {
1648
1886
  Promise.resolve(fiber.restart()).catch((error) => {
1649
- ctx.logger.warn('dsh-zen-remote: plugin row reload failed: %s', message(error))
1887
+ logWarn('dsh-zen-remote: plugin row reload failed: %s', message(error))
1650
1888
  })
1651
1889
  } catch (error) {
1652
- ctx.logger.warn('dsh-zen-remote: plugin row reload failed: %s', message(error))
1890
+ logWarn('dsh-zen-remote: plugin row reload failed: %s', message(error))
1653
1891
  }
1654
1892
  }
1655
1893
  ctx.effect(() => {
@@ -1662,7 +1900,7 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
1662
1900
  try {
1663
1901
  ctx.on('loader/volatile-update', () => { watcher.checkNow() })
1664
1902
  } catch (error) {
1665
- ctx.logger.warn('dsh-zen-remote: cannot listen on "loader/volatile-update": %s', message(error))
1903
+ logWarn('dsh-zen-remote: cannot listen on "loader/volatile-update": %s', message(error))
1666
1904
  }
1667
1905
  return watcher.stop
1668
1906
  }, 'dsh-zen-remote: restart watcher')
@@ -1694,6 +1932,7 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
1694
1932
  req,
1695
1933
  res,
1696
1934
  (admitReq) => webCtx.connection.admit(admitReq),
1935
+ logWarn,
1697
1936
  )
1698
1937
  },
1699
1938
  }), 'dsh-mobile-nav: upload route')