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/lib/index.js CHANGED
@@ -34,7 +34,7 @@ import { createRelayClient, RelayError, volatileUpdateTouchesServerName } from '
34
34
  import { computeFingerprints } from './fingerprint.js';
35
35
  import { runSelfCheck, installIntercept } from './intercept.js';
36
36
  import { checkGatewayShape } from './intercept-shape.js';
37
- import { checkFetchRouteShape, installFetchRouteIntercept } from './fetch-route-intercept.js';
37
+ import { checkFetchRouteShape, installFetchRouteIntercept, shouldFallbackInstall } from './fetch-route-intercept.js';
38
38
  import { checkWebServerShape, installPluginRouteIntercept } from './plugin-route-intercept.js';
39
39
  import { normalizeAdapter, adapterOwnsRegistryEntry } from './plugin-adapter-core.js';
40
40
  import { ADAPTERS } from './adapters/index.js';
@@ -56,6 +56,7 @@ import { detectForwardedSource, parseProxyList } from './wizard/source-detect.js
56
56
  import { createVerificationManager } from './wizard/verify.js';
57
57
  import { createPluginLoopback } from './plugin-loopback.js';
58
58
  import { createPluginTunnel } from './plugin-tunnel.js';
59
+ import { createLogRing, createPluginLog } from './log-ring.js';
59
60
  import { DEFAULT_MAX_UPLOAD_BYTES } from './upload-limits.js';
60
61
  // The loader's config schema (settings form) and the role normalizer live in
61
62
  // src/config.ts next to the resolution they describe; re-exported so the
@@ -72,6 +73,20 @@ import * as gateway from '../lan-gate.mjs';
72
73
  import * as push from '../dsh-push.mjs';
73
74
  /** Exact route the phone composer POSTs one file body to. */
74
75
  export const UPLOAD_ROUTE = '/_dsh/mobile-nav/upload';
76
+ /**
77
+ * This package's own version, read once off the package root (`../package.json`
78
+ * resolves there both from src/ under the type-stripping scripts and from the
79
+ * built lib/ in production). The debug report's `_meta` plate carries it —
80
+ * issue triage starts at "which build is this" — and the row-apply log line
81
+ * stamps it. Unreadable file degrades to `'unknown'`, never throws.
82
+ */
83
+ let pluginVersion = 'unknown';
84
+ try {
85
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
86
+ if (typeof pkg?.version === 'string' && pkg.version !== '')
87
+ pluginVersion = pkg.version;
88
+ }
89
+ catch { /* keep 'unknown' */ }
75
90
  /**
76
91
  * The one piece of cordis the typert interception needs at RUNTIME: the
77
92
  * registered symbol under which every cordis proxy answers with its raw
@@ -357,8 +372,14 @@ async function writeUpload(req, directory, filename, maxBytes) {
357
372
  * request must ask the connection service itself, its refusal relayed
358
373
  * verbatim — same first wall as the admin and client routes. Optional only
359
374
  * for the direct-drive check scripts; production always passes it.
375
+ * @param warn - where the rejection line goes. apply() passes its double-
376
+ * writer (context logger + the CU-1 log ring), so a rejected upload — the
377
+ * very path issue #49 is triaged on — shows up in the debug report. The
378
+ * ring is per-apply on purpose (a row reload clears it), which is why it
379
+ * arrives as a parameter instead of a module singleton. Defaults to the
380
+ * context logger for the direct-drive check scripts.
360
381
  */
361
- export async function handleUpload(ctx, maxBytes, req, res, admit) {
382
+ export async function handleUpload(ctx, maxBytes, req, res, admit, warn) {
362
383
  if (admit !== undefined) {
363
384
  const admission = admit(req);
364
385
  if ('rejection' in admission) {
@@ -397,7 +418,10 @@ export async function handleUpload(ctx, maxBytes, req, res, admit) {
397
418
  catch (error) {
398
419
  const status = error instanceof UploadError ? error.status : 400;
399
420
  const code = error instanceof UploadError ? error.code : 'upload-rejected';
400
- ctx.logger.warn('dsh-mobile-nav upload rejected: %s', message(error));
421
+ if (warn !== undefined)
422
+ warn('dsh-mobile-nav upload rejected: %s', message(error));
423
+ else
424
+ ctx.logger.warn('dsh-mobile-nav upload rejected: %s', message(error));
401
425
  responseJson(res, status, { ok: false, error: { code, message: message(error) } });
402
426
  }
403
427
  }
@@ -423,7 +447,20 @@ export async function handleUpload(ctx, maxBytes, req, res, admit) {
423
447
  * request.
424
448
  */
425
449
  export function apply(ctx, config = {}) {
450
+ // CU-1: the plugin's own log ring (SPEC 方案 C) — rebuilt per apply, so a
451
+ // row reload clears it (expected: the report describes THIS process
452
+ // generation). `logWarn` / `logInfo` are the branch-shared double-writers:
453
+ // every line below reaches the context logger exactly as before and the
454
+ // ring besides, so the debug report (CU-2) can answer "what did the plugin
455
+ // actually log" without a log file (the desktop app writes none).
456
+ const ring = createLogRing();
457
+ const pluginLog = createPluginLog(ring, ctx.logger);
458
+ const logWarn = pluginLog.warn;
459
+ const logInfo = pluginLog.info;
426
460
  const effective = resolveConfig(config, readFileConfig(), process.env);
461
+ // Lifecycle: the row came up. Role and both versions are the first things
462
+ // an issue report needs.
463
+ ring.push('info', 'row apply: role=%s plugin=%s dsh=%s', effective.values.role, pluginVersion, resolveDshVersion());
427
464
  // RD-6 probe fact: whether the fetch-route inject callback (below, in the
428
465
  // client-half wiring) ever fired. On a composition where `connection` or
429
466
  // `fileUploads` never becomes available the callback stays silently
@@ -431,31 +468,45 @@ export function apply(ctx, config = {}) {
431
468
  // diagnostic stays undefined — this flag is the one honest signal of that
432
469
  // state. Declared this early because probes on BOTH role branches read it.
433
470
  let fetchRouteInjectSeen = false;
471
+ /** BU-1: how the fetch-route wrap got installed — `'inject'` via the
472
+ * primary `ctx.inject` callback, `'fallback'` via the bounded lazy timer
473
+ * below, `undefined` while not installed. Read live by the service probe. */
474
+ let fetchRouteInstalledVia;
475
+ /** BU-1: ISO timestamp of the moment a fallback attempt FAILED (shape
476
+ * refused, original unresolvable) — the tick stops after one, so this is
477
+ * also "the fallback gave up here". An install success does not set it. */
478
+ let fetchRouteFallbackTriedAt;
434
479
  /** The client branch's fetch-route diagnostics getter, assigned once that
435
480
  * branch declares it; the probe reads it lazily through this slot so both
436
481
  * role mounts can share one probe factory. */
437
482
  let fetchRouteDiagnosticsRef;
438
483
  /** RD-6: build the lazy service probe against a context whose own inject
439
484
  * already resolved (the client-routes mounts qualify on both roles). One
440
- * reflection per process: `connection` / `fileUploads` presence plus the
441
- * fetchRoutes table's shape — enough to name the missing service. */
485
+ * reflection per process for the SERVICE PRESENCE facts (below); the
486
+ * fetch-route facts are re-read live on every call — they change while the
487
+ * page is open (the inject may fire, or the fallback may step in, long
488
+ * after the first probe). */
442
489
  const uploadServiceProbe = (probeCtx) => {
443
490
  let cached;
444
491
  return () => {
445
- if (cached !== undefined)
446
- return cached;
447
- const reflect = probeCtx.reflect;
448
- const connection = reflect?.get('connection');
449
- const fileUploads = reflect?.get('fileUploads');
492
+ if (cached === undefined) {
493
+ const reflect = probeCtx.reflect;
494
+ const connection = reflect?.get('connection');
495
+ const fileUploads = reflect?.get('fileUploads');
496
+ cached = {
497
+ connectionService: connection === undefined ? 'absent' : 'present',
498
+ fileUploadsService: fileUploads === undefined ? 'absent' : 'present',
499
+ ...(connection === undefined ? {} : { connectionShape: checkFetchRouteShape(connection) }),
500
+ };
501
+ }
450
502
  const fetchRoute = fetchRouteDiagnosticsRef?.();
451
- cached = {
503
+ return {
452
504
  uploadInjectSeen: fetchRouteInjectSeen,
453
- connectionService: connection === undefined ? 'absent' : 'present',
454
- fileUploadsService: fileUploads === undefined ? 'absent' : 'present',
455
- ...(connection === undefined ? {} : { connectionShape: checkFetchRouteShape(connection) }),
505
+ installedVia: fetchRouteInstalledVia,
506
+ ...(fetchRouteFallbackTriedAt === undefined ? {} : { fallbackTriedAt: fetchRouteFallbackTriedAt }),
507
+ ...cached,
456
508
  ...(fetchRoute === undefined ? {} : { fetchRoute }),
457
509
  };
458
- return cached;
459
510
  };
460
511
  };
461
512
  // The plugin bridge's zenRemote service (PB-1), provided on BOTH roles
@@ -475,7 +526,7 @@ export function apply(ctx, config = {}) {
475
526
  // failing the row over — the bridge stays off, everything else keeps
476
527
  // working — so it degrades to a warning like the session feeds do.
477
528
  const registry = createBridgeRegistry({
478
- warn: (text) => { ctx.logger?.warn('dsh-zen-remote plugin bridge: %s', text); },
529
+ warn: (text) => { logWarn('dsh-zen-remote plugin bridge: %s', text); },
479
530
  });
480
531
  bridgeRegistry = registry;
481
532
  try {
@@ -485,7 +536,7 @@ export function apply(ctx, config = {}) {
485
536
  ctx.provide('zenRemote', service);
486
537
  }
487
538
  catch (error) {
488
- ctx.logger?.warn('dsh-zen-remote: cannot provide the "zenRemote" service: %s', message(error));
539
+ logWarn('dsh-zen-remote: cannot provide the "zenRemote" service: %s', message(error));
489
540
  }
490
541
  ctx.effect(() => () => {
491
542
  if (bridgeRegistry === registry)
@@ -510,7 +561,7 @@ export function apply(ctx, config = {}) {
510
561
  // halves.
511
562
  const adapters = [];
512
563
  {
513
- const adapterWarn = (text) => { ctx.logger?.warn('dsh-zen-remote adapter: %s', text); };
564
+ const adapterWarn = (text) => { logWarn('dsh-zen-remote adapter: %s', text); };
514
565
  for (const adapter of ADAPTERS) {
515
566
  try {
516
567
  const holder = ctx;
@@ -560,10 +611,10 @@ export function apply(ctx, config = {}) {
560
611
  registry.registerNormalized(normalized.declaration);
561
612
  adapters.push(normalized);
562
613
  if (unlisted && surfaceDigest !== undefined) {
563
- ctx.logger?.info('dsh-zen-remote adapter: %s bridged by surface digest %s (version %s unlisted)', normalized.plugin, surfaceDigest, installedVersion);
614
+ logInfo('dsh-zen-remote adapter: %s bridged by surface digest %s (version %s unlisted)', normalized.plugin, surfaceDigest, installedVersion);
564
615
  }
565
616
  else {
566
- ctx.logger?.info('dsh-zen-remote adapter: %s adapted at version %s', normalized.plugin, installedVersion);
617
+ logInfo('dsh-zen-remote adapter: %s adapted at version %s', normalized.plugin, installedVersion);
567
618
  }
568
619
  }
569
620
  catch (error) {
@@ -571,6 +622,19 @@ export function apply(ctx, config = {}) {
571
622
  }
572
623
  }
573
624
  }
625
+ // CU-3: the debug-report route's profile fact — read per call through the
626
+ // reflection layer's store lookup (a composition without profileContext
627
+ // answers undefined, and a throwing read degrades to undefined too).
628
+ const profileName = () => {
629
+ try {
630
+ const holder = ctx;
631
+ const profile = holder.get?.('profileContext');
632
+ return typeof profile?.name === 'string' && profile.name !== '' ? profile.name : undefined;
633
+ }
634
+ catch {
635
+ return undefined;
636
+ }
637
+ };
574
638
  if (effective.values.role === 'host') {
575
639
  // A row that switched from client to host must not leave a stale relay
576
640
  // client where getRelayClient() can find it (T23a-fix).
@@ -579,7 +643,27 @@ export function apply(ctx, config = {}) {
579
643
  // gateway child through LAN_GATE_RELAY_SECRET and kept here for the
580
644
  // relay routes below. The push half never sees it.
581
645
  const relaySecret = randomBytes(32).toString('hex');
582
- ctx.plugin(gateway, { ...effective.values, relaySecret });
646
+ // CU-1 lifecycle: the gateway child's spawn/exit, reported back through
647
+ // this hook (lan-gate.mjs reads it off the config object it is handed).
648
+ // The child runs in a subprocess whose console the ring cannot see, so
649
+ // these two lines are the debug report's only record of it. Either call
650
+ // must never break the gateway half — lan-gate.mjs guards its side, we
651
+ // guard ours. The hook rides as a NON-enumerable property: it is wiring,
652
+ // not configuration — the gateway's config stays exactly "resolved values
653
+ // + relaySecret" for every spread, deep compare and serialization (the
654
+ // role-wiring test pins that shape), while a direct read still finds it.
655
+ const gatewayEvent = (event, detail) => {
656
+ try {
657
+ if (event === 'spawn')
658
+ ring.push('info', 'gateway spawned (port=%s)', String(detail.port ?? '?'));
659
+ else
660
+ ring.push('warn', 'gateway exited (code=%s signal=%s error=%s)', String(detail.code ?? '—'), String(detail.signal ?? '—'), String(detail.error ?? '—'));
661
+ }
662
+ catch { /* the ring must never break the wiring */ }
663
+ };
664
+ const gatewayConfig = { ...effective.values, relaySecret };
665
+ Object.defineProperty(gatewayConfig, 'gatewayEvent', { value: gatewayEvent, enumerable: false });
666
+ ctx.plugin(gateway, gatewayConfig);
583
667
  ctx.plugin(push, effective.values);
584
668
  // The shared-session table backing the relay's access control. The relay
585
669
  // route consumes it below; the activity tracker and idle sweeper (T22c)
@@ -624,7 +708,7 @@ export function apply(ctx, config = {}) {
624
708
  });
625
709
  }
626
710
  catch (error) {
627
- ctx.logger.warn('dsh-zen-remote cannot listen on "session/event": %s', message(error));
711
+ logWarn('dsh-zen-remote cannot listen on "session/event": %s', message(error));
628
712
  }
629
713
  // Auto-share and fork-following (T33a), off `agent/created` — the only
630
714
  // host event that fires EXACTLY ONCE per NEW session. Its `source` is
@@ -664,12 +748,12 @@ export function apply(ctx, config = {}) {
664
748
  });
665
749
  }
666
750
  catch (error) {
667
- ctx.logger.warn('dsh-zen-remote cannot share new session "%s": %s', agent.id, message(error));
751
+ logWarn('dsh-zen-remote cannot share new session "%s": %s', agent.id, message(error));
668
752
  }
669
753
  });
670
754
  }
671
755
  catch (error) {
672
- ctx.logger.warn('dsh-zen-remote cannot listen on "agent/created": %s', message(error));
756
+ logWarn('dsh-zen-remote cannot listen on "agent/created": %s', message(error));
673
757
  }
674
758
  // Busy restore (T33a): `busy` is never persisted, so right after a row
675
759
  // restart every shared session believes itself idle — including one whose
@@ -690,7 +774,7 @@ export function apply(ctx, config = {}) {
690
774
  ctx.inject(['sessionPersistence'], (persistenceCtx) => {
691
775
  const persistence = persistenceCtx.sessionPersistence;
692
776
  if (persistence === undefined) {
693
- ctx.logger.warn('dsh-zen-remote: no sessionPersistence service — pre-restart subagent sessions stay not-shared until they emit an event');
777
+ logWarn('dsh-zen-remote: no sessionPersistence service — pre-restart subagent sessions stay not-shared until they emit an event');
694
778
  return;
695
779
  }
696
780
  void persistence
@@ -698,13 +782,13 @@ export function apply(ctx, config = {}) {
698
782
  .then((snapshots) => {
699
783
  const recorded = seedParentIndexFromSnapshots(parentIndex, snapshots);
700
784
  if (recorded > 0)
701
- ctx.logger.info?.('dsh-zen-remote: restored %d persisted parent links', recorded);
785
+ logInfo('dsh-zen-remote: restored %d persisted parent links', recorded);
702
786
  })
703
- .catch((error) => ctx.logger.warn('dsh-zen-remote cannot seed parent links from persisted sessions: %s', message(error)));
787
+ .catch((error) => logWarn('dsh-zen-remote cannot seed parent links from persisted sessions: %s', message(error)));
704
788
  });
705
789
  }
706
790
  catch (error) {
707
- ctx.logger.warn('dsh-zen-remote cannot listen on "sessionPersistence": %s', message(error));
791
+ logWarn('dsh-zen-remote cannot listen on "sessionPersistence": %s', message(error));
708
792
  }
709
793
  // Settings-surface admin routes (T14). The browser cannot call the
710
794
  // gateway's loopback-only admin API from DSH's origin, so the host
@@ -1132,6 +1216,39 @@ export function apply(ctx, config = {}) {
1132
1216
  };
1133
1217
  }, 'dsh-zen-remote: relay route');
1134
1218
  });
1219
+ // CU-3 (host mount): the gateway child's summary plate for the debug
1220
+ // report — one best-effort probe of the gateway's own status endpoint
1221
+ // (the same seam the wizard's runtime probe reads) reduced to liveness,
1222
+ // port and COUNTS. The endpoint's device rows carry no tokens and we
1223
+ // keep none of their fields anyway; the share table's size is read off
1224
+ // the store this apply owns. The gateway being down IS the summary.
1225
+ const gatewaySummary = async () => {
1226
+ let reachable = false;
1227
+ let port;
1228
+ let deviceCount;
1229
+ let pushSubscriptions;
1230
+ try {
1231
+ const response = await fetch(`http://127.0.0.1:${effective.values.port}/lan-gate/status`, { signal: AbortSignal.timeout(3000) });
1232
+ reachable = response.ok;
1233
+ if (reachable) {
1234
+ const body = await response.json();
1235
+ if (typeof body?.port === 'number')
1236
+ port = body.port;
1237
+ if (Array.isArray(body?.devices))
1238
+ deviceCount = body.devices.length;
1239
+ if (typeof body?.pushSubscriptions === 'number')
1240
+ pushSubscriptions = body.pushSubscriptions;
1241
+ }
1242
+ }
1243
+ catch { /* unreachable is an honest answer */ }
1244
+ return {
1245
+ reachable,
1246
+ ...(port === undefined ? {} : { port }),
1247
+ ...(deviceCount === undefined ? {} : { deviceCount }),
1248
+ ...(pushSubscriptions === undefined ? {} : { pushSubscriptions }),
1249
+ shareCount: store.list().length,
1250
+ };
1251
+ };
1135
1252
  // The remote-status route lives on BOTH roles (T34-fix): it is what the
1136
1253
  // client-half parts poll, and a desktop app can switch roles without a
1137
1254
  // code path change — on a host it reads the empty
@@ -1150,6 +1267,10 @@ export function apply(ctx, config = {}) {
1150
1267
  getRowConfig: () => config,
1151
1268
  remoteStatusOnly: true,
1152
1269
  getServiceProbe: uploadServiceProbe(clientCtx),
1270
+ getLogSnapshot: () => ring.snapshot(),
1271
+ getGatewaySummary: gatewaySummary,
1272
+ getProfileName: profileName,
1273
+ getPluginVersion: () => pluginVersion,
1153
1274
  }),
1154
1275
  }), 'dsh-zen-remote: client routes (host)');
1155
1276
  });
@@ -1191,6 +1312,24 @@ export function apply(ctx, config = {}) {
1191
1312
  // and `online` is the client's CURRENT state — so a registry change, a
1192
1313
  // fresh handshake or a link flip shows up at the very next read.
1193
1314
  const pluginBridgeStatus = () => compareBridges(registry.list(), relayClient.handshakeInfo?.plugins, relayClient.state === 'online');
1315
+ // CU-1 lifecycle: the relay link's flips enter the ring — `online` with
1316
+ // the server id it handshook, `offline` with the mapped failure code
1317
+ // (relayClient.lastError is a short code by contract, never free text).
1318
+ // subscribe re-announces the CURRENT state when only the handshake
1319
+ // identity moved (the T59 heartbeat), so the dedupe below keeps the
1320
+ // heartbeats out of the ring: one line per actual state change.
1321
+ let lastRelayRingState;
1322
+ const offRelayRing = relayClient.subscribe((state) => {
1323
+ if (state === lastRelayRingState)
1324
+ return;
1325
+ lastRelayRingState = state;
1326
+ if (state === 'online') {
1327
+ ring.push('info', 'relay link online (server=%s)', relayClient.handshakeInfo?.serverId ?? '?');
1328
+ }
1329
+ else if (state === 'offline') {
1330
+ ring.push('warn', 'relay link offline (code=%s)', relayClient.lastError ?? '?');
1331
+ }
1332
+ });
1194
1333
  // A stopped or reloaded row must not leave a stale instance where the
1195
1334
  // module getter can hand it out (T23a-fix), and its reconnect machinery
1196
1335
  // (T43) must not outlive the row either: stop() cancels the armed retry
@@ -1198,6 +1337,7 @@ export function apply(ctx, config = {}) {
1198
1337
  // dispose from clearing a NEWER row's instance that a reload already
1199
1338
  // installed.
1200
1339
  ctx.effect(() => () => {
1340
+ offRelayRing();
1201
1341
  relayClient.stop();
1202
1342
  if (clientRelayClient === relayClient)
1203
1343
  clientRelayClient = undefined;
@@ -1237,7 +1377,7 @@ export function apply(ctx, config = {}) {
1237
1377
  };
1238
1378
  }
1239
1379
  catch (error) {
1240
- ctx.logger.warn('dsh-zen-remote: cannot listen on "loader/volatile-update" for relay credentials: %s', message(error));
1380
+ logWarn('dsh-zen-remote: cannot listen on "loader/volatile-update" for relay credentials: %s', message(error));
1241
1381
  return () => { };
1242
1382
  }
1243
1383
  }, 'dsh-zen-remote: relay credentials watcher');
@@ -1249,7 +1389,7 @@ export function apply(ctx, config = {}) {
1249
1389
  if (error instanceof RelayError && error.code === 'unpaired')
1250
1390
  return;
1251
1391
  const code = error instanceof RelayError ? error.code : 'error';
1252
- ctx.logger.warn('dsh-zen-remote relay client initial connect failed (%s): %s', code, message(error));
1392
+ logWarn('dsh-zen-remote relay client initial connect failed (%s): %s', code, message(error));
1253
1393
  });
1254
1394
  // The typertGateway interception (T23b-1): remote-session calls ride the
1255
1395
  // relay, everything else reaches the local gateway untouched. The live
@@ -1316,9 +1456,6 @@ export function apply(ctx, config = {}) {
1316
1456
  return undefined;
1317
1457
  };
1318
1458
  ctx.inject(['typertGateway'], (gatewayCtx) => {
1319
- // Degraded logging: the real cordis context always carries a logger,
1320
- // but a row fake without one must not crash apply.
1321
- const logWarn = (...args) => { ctx.logger?.warn(...args); };
1322
1459
  const proxy = gatewayCtx.typertGateway;
1323
1460
  const raw = proxy !== null && typeof proxy === 'object'
1324
1461
  ? proxy[symbols.original]
@@ -1344,7 +1481,7 @@ export function apply(ctx, config = {}) {
1344
1481
  // PB-25b: each merged stream reports its real-session inventory
1345
1482
  // share into the union above.
1346
1483
  createSessionInventorySlot,
1347
- log: (format, ...args) => { ctx.logger?.info(`dsh-zen-remote ${format}`, ...args); },
1484
+ log: (format, ...args) => { logInfo(`dsh-zen-remote ${format}`, ...args); },
1348
1485
  });
1349
1486
  interceptHandle = handle;
1350
1487
  gatewayCtx.effect(() => () => {
@@ -1367,7 +1504,7 @@ export function apply(ctx, config = {}) {
1367
1504
  void runSelfCheck(raw, {
1368
1505
  handle,
1369
1506
  log: logWarn,
1370
- info: (format, ...args) => { ctx.logger?.info(`dsh-zen-remote ${format}`, ...args); },
1507
+ info: (format, ...args) => { logInfo(`dsh-zen-remote ${format}`, ...args); },
1371
1508
  getPeer: createOwnerPeerSource(() => gatewayCtx.reflect.get('connection')),
1372
1509
  });
1373
1510
  });
@@ -1411,8 +1548,13 @@ export function apply(ctx, config = {}) {
1411
1548
  fetchRouteDiagnosticsRef = fetchRouteDiagnostics;
1412
1549
  ctx.inject(['connection', 'fileUploads'], (connectionCtx) => {
1413
1550
  fetchRouteInjectSeen = true;
1414
- const logWarn = (...args) => { ctx.logger?.warn(...args); };
1415
- const logInfo = (...args) => { ctx.logger?.info(...args); };
1551
+ // BU-1 double-install guard: the fallback timer may have installed
1552
+ // while this callback sat PENDING — or recorded a refusal. Either way
1553
+ // this primary path has nothing left to do; installing again would
1554
+ // chain a second wrap over the first (behaviorally right, structurally
1555
+ // dirty) and overwrite the diagnostics' installedVia.
1556
+ if (fetchRouteHandle !== undefined || fetchRouteRefused !== undefined)
1557
+ return;
1416
1558
  const proxy = connectionCtx.connection;
1417
1559
  const raw = proxy !== null && typeof proxy === 'object'
1418
1560
  ? proxy[symbols.original]
@@ -1438,12 +1580,112 @@ export function apply(ctx, config = {}) {
1438
1580
  log: (format, ...args) => { logInfo(`dsh-zen-remote ${format}`, ...args); },
1439
1581
  });
1440
1582
  fetchRouteHandle = handle;
1583
+ fetchRouteInstalledVia = 'inject';
1584
+ // CU-1 lifecycle: a successful install logs nothing today (the wrap's
1585
+ // own log lines only fire per call), so the ring gets one explicit
1586
+ // record — the debug report's "did the redirect actually attach" fact.
1587
+ // The refused and fallback paths funnel through logWarn/logInfo below
1588
+ // and enter the ring that way.
1589
+ ring.push('info', 'fetch-route intercept attached (via=inject)');
1441
1590
  connectionCtx.effect(() => () => {
1442
1591
  handle.uninstall();
1443
- if (fetchRouteHandle === handle)
1592
+ if (fetchRouteHandle === handle) {
1444
1593
  fetchRouteHandle = undefined;
1594
+ fetchRouteInstalledVia = undefined;
1595
+ }
1445
1596
  }, 'dsh-zen-remote: fetch-route intercept');
1446
1597
  });
1598
+ // BU-1: the fetch-route FALLBACK install (SPEC §2). RD-6 proved the
1599
+ // machine-local failure mode: on one Windows client the
1600
+ // `['connection','fileUploads']` inject above sat silently PENDING (a
1601
+ // stuck row keeps cordis waiting forever, printing nothing), the wrap
1602
+ // never installed, and every remote upload fell through to the local
1603
+ // backend. The inject stays the FIRST choice — this timer only acts when
1604
+ // it demonstrably has not fired while BOTH services are present in the
1605
+ // reflection layer, then installs through the same
1606
+ // checkFetchRouteShape → symbols.original → installFetchRouteIntercept
1607
+ // path, sharing the handle/refused slots (which is also the
1608
+ // double-install guard: whichever path runs first, the other sees the
1609
+ // slots set and stands down). Bounded: a 3 s tick, at most 20 (a minute)
1610
+ // — a composition that never ships the services stops probing; unref'd
1611
+ // and cleared with the row.
1612
+ let fallbackTimer;
1613
+ let fallbackAttempts = 0;
1614
+ const stopFallbackTimer = () => {
1615
+ if (fallbackTimer !== undefined) {
1616
+ clearInterval(fallbackTimer);
1617
+ fallbackTimer = undefined;
1618
+ }
1619
+ };
1620
+ fallbackTimer = setInterval(() => {
1621
+ // The tick budget is checked BEFORE acting but does not cancel the
1622
+ // current tick: attempt #20 still runs, no tick #21 ever comes.
1623
+ fallbackAttempts += 1;
1624
+ if (fallbackAttempts >= 20)
1625
+ stopFallbackTimer();
1626
+ // Same reflection the service probe reads: both services must be
1627
+ // PRESENT — their absence means a legitimate startup wait (or a
1628
+ // composition without them), where the inject must stay first choice.
1629
+ const reflect = ctx.reflect;
1630
+ const connection = reflect?.get('connection');
1631
+ const fileUploads = reflect?.get('fileUploads');
1632
+ if (!shouldFallbackInstall({
1633
+ injectSeen: fetchRouteInjectSeen,
1634
+ handleSet: fetchRouteHandle !== undefined,
1635
+ refused: fetchRouteRefused !== undefined,
1636
+ servicesPresent: connection !== undefined && fileUploads !== undefined,
1637
+ })) {
1638
+ // The services being absent is a WAIT (next tick re-judges); the
1639
+ // primary path having advanced is a VERDICT — the fallback has no
1640
+ // business left, stop probing.
1641
+ if (fetchRouteInjectSeen || fetchRouteHandle !== undefined || fetchRouteRefused !== undefined)
1642
+ stopFallbackTimer();
1643
+ return;
1644
+ }
1645
+ // Shape against the proxy first (fetchRoutes reads through it); the
1646
+ // raw instance comes off the same symbols slot the inject path uses.
1647
+ const shape = checkFetchRouteShape(connection);
1648
+ if (!shape.ok) {
1649
+ // Structural mismatch is a verdict, not a wait: record the attempt
1650
+ // and stop. A later inject trigger may still install (it checks the
1651
+ // RAW instance, which can differ from what the proxy exposed).
1652
+ fetchRouteFallbackTriedAt = new Date().toISOString();
1653
+ stopFallbackTimer();
1654
+ logWarn('dsh-zen-remote fetch-route fallback declined: %s', shape.reasons.join('; '));
1655
+ return;
1656
+ }
1657
+ const raw = connection !== null && typeof connection === 'object'
1658
+ ? connection[symbols.original]
1659
+ : undefined;
1660
+ if (raw === null || typeof raw !== 'object') {
1661
+ fetchRouteFallbackTriedAt = new Date().toISOString();
1662
+ stopFallbackTimer();
1663
+ logWarn('dsh-zen-remote fetch-route fallback declined: %s', 'original: connection[symbols.original] is not an object');
1664
+ return;
1665
+ }
1666
+ const handle = installFetchRouteIntercept({
1667
+ connection: raw,
1668
+ relay: relayClient,
1669
+ getServerId: () => relayClient.handshakeInfo?.serverId,
1670
+ log: (format, ...args) => { logInfo(`dsh-zen-remote ${format}`, ...args); },
1671
+ });
1672
+ fetchRouteHandle = handle;
1673
+ fetchRouteInstalledVia = 'fallback';
1674
+ stopFallbackTimer();
1675
+ logInfo('dsh-zen-remote fetch-route intercept installed via the fallback timer (inject never fired)');
1676
+ // Same disposal contract as the inject path: the wrap dies with the
1677
+ // row (the fallback has no service context of its own to hang on).
1678
+ ctx.effect(() => () => {
1679
+ handle.uninstall();
1680
+ if (fetchRouteHandle === handle) {
1681
+ fetchRouteHandle = undefined;
1682
+ fetchRouteInstalledVia = undefined;
1683
+ }
1684
+ }, 'dsh-zen-remote: fetch-route intercept (fallback)');
1685
+ }, 3_000);
1686
+ if (typeof fallbackTimer.unref === 'function')
1687
+ fallbackTimer.unref();
1688
+ ctx.effect(() => () => { stopFallbackTimer(); }, 'dsh-zen-remote: fetch-route fallback timer');
1447
1689
  // The plugin bridge's route interception (PB-4): the local webServer's
1448
1690
  // `match` wrap forwarding bridged-plugin requests for REMOTE sessions
1449
1691
  // through the relay. Injected on `webServer` alone — the only service
@@ -1475,7 +1717,6 @@ export function apply(ctx, config = {}) {
1475
1717
  return undefined;
1476
1718
  };
1477
1719
  ctx.inject(['webServer'], (wsCtx) => {
1478
- const logWarn = (...args) => { ctx.logger?.warn(...args); };
1479
1720
  const proxy = wsCtx.webServer;
1480
1721
  const raw = proxy !== null && typeof proxy === 'object'
1481
1722
  ? proxy[symbols.original]
@@ -1514,7 +1755,7 @@ export function apply(ctx, config = {}) {
1514
1755
  // client's only early notice of a row that rides no workspace/follow
1515
1756
  // frame — hand it to the summary sync so the sidebar row appears.
1516
1757
  onMintedVirtualIds: (ids) => { interceptHandle?.nudgeSummarySync(ids); },
1517
- log: (format, ...args) => { ctx.logger?.info(`dsh-zen-remote ${format}`, ...args); },
1758
+ log: (format, ...args) => { logInfo(`dsh-zen-remote ${format}`, ...args); },
1518
1759
  });
1519
1760
  pluginRouteHandle = handle;
1520
1761
  wsCtx.effect(() => () => {
@@ -1537,6 +1778,9 @@ export function apply(ctx, config = {}) {
1537
1778
  getPluginRouteIntercept: pluginRouteDiagnostics,
1538
1779
  getPluginBridgeStatus: pluginBridgeStatus,
1539
1780
  onCurrentSession,
1781
+ getLogSnapshot: () => ring.snapshot(),
1782
+ getProfileName: profileName,
1783
+ getPluginVersion: () => pluginVersion,
1540
1784
  }),
1541
1785
  }), 'dsh-zen-remote: client routes');
1542
1786
  });
@@ -1563,18 +1807,22 @@ export function apply(ctx, config = {}) {
1563
1807
  // dispose-and-reload entry. A fake context without a fiber (the route
1564
1808
  // tests) only ever reaches the warn.
1565
1809
  const reload = () => {
1810
+ // CU-1 lifecycle: the row is about to reload — the ring dies with this
1811
+ // apply right after, so this line is the reload's only witness in the
1812
+ // NEXT generation's report (the fresh apply's row-apply line follows).
1813
+ ring.push('info', 'row reload: a restart-required setting changed, reloading the plugin row');
1566
1814
  const fiber = ctx.fiber;
1567
1815
  if (fiber?.restart === undefined) {
1568
- ctx.logger.warn('dsh-zen-remote: a restart-required setting changed, but no fiber is available to reload the plugin row');
1816
+ logWarn('dsh-zen-remote: a restart-required setting changed, but no fiber is available to reload the plugin row');
1569
1817
  return;
1570
1818
  }
1571
1819
  try {
1572
1820
  Promise.resolve(fiber.restart()).catch((error) => {
1573
- ctx.logger.warn('dsh-zen-remote: plugin row reload failed: %s', message(error));
1821
+ logWarn('dsh-zen-remote: plugin row reload failed: %s', message(error));
1574
1822
  });
1575
1823
  }
1576
1824
  catch (error) {
1577
- ctx.logger.warn('dsh-zen-remote: plugin row reload failed: %s', message(error));
1825
+ logWarn('dsh-zen-remote: plugin row reload failed: %s', message(error));
1578
1826
  }
1579
1827
  };
1580
1828
  ctx.effect(() => {
@@ -1588,7 +1836,7 @@ export function apply(ctx, config = {}) {
1588
1836
  ctx.on('loader/volatile-update', () => { watcher.checkNow(); });
1589
1837
  }
1590
1838
  catch (error) {
1591
- ctx.logger.warn('dsh-zen-remote: cannot listen on "loader/volatile-update": %s', message(error));
1839
+ logWarn('dsh-zen-remote: cannot listen on "loader/volatile-update": %s', message(error));
1592
1840
  }
1593
1841
  return watcher.stop;
1594
1842
  }, 'dsh-zen-remote: restart watcher');
@@ -1614,7 +1862,7 @@ export function apply(ctx, config = {}) {
1614
1862
  // and a rejection escaping it must reach webServer's unified error
1615
1863
  // handling instead of vanishing into an unhandled rejection (T17b
1616
1864
  // restored the await-shaped contract the route used to have).
1617
- return handleUpload(webCtx, maxBytes, req, res, (admitReq) => webCtx.connection.admit(admitReq));
1865
+ return handleUpload(webCtx, maxBytes, req, res, (admitReq) => webCtx.connection.admit(admitReq), logWarn);
1618
1866
  },
1619
1867
  }), 'dsh-mobile-nav: upload route');
1620
1868
  });