dsh-zen-remote 2.3.11 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (244) hide show
  1. package/CHANGELOG.md +39 -78
  2. package/README.md +94 -84
  3. package/README.zh-CN.md +90 -81
  4. package/bin/zen-remote.mjs +5 -0
  5. package/docs/interface.md +6 -6
  6. package/docs/remote-access.en.md +6 -4
  7. package/docs/remote-access.md +6 -4
  8. package/dsh-push.mjs +96 -10
  9. package/lib/adapters/dsh-better-sidebar/index.js +7 -3
  10. package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
  11. package/lib/admin-routes.js +10 -2
  12. package/lib/admin-routes.js.map +1 -1
  13. package/lib/cli/args.js +251 -0
  14. package/lib/cli/args.js.map +1 -0
  15. package/lib/cli/bootstrap.js +449 -0
  16. package/lib/cli/bootstrap.js.map +1 -0
  17. package/lib/cli/command-setup.js +484 -0
  18. package/lib/cli/command-setup.js.map +1 -0
  19. package/lib/cli/commands-client.js +359 -0
  20. package/lib/cli/commands-client.js.map +1 -0
  21. package/lib/cli/commands-host.js +1108 -0
  22. package/lib/cli/commands-host.js.map +1 -0
  23. package/lib/cli/commands-status.js +279 -0
  24. package/lib/cli/commands-status.js.map +1 -0
  25. package/lib/cli/config-writer.js +334 -0
  26. package/lib/cli/config-writer.js.map +1 -0
  27. package/lib/cli/context.js +10 -0
  28. package/lib/cli/context.js.map +1 -0
  29. package/lib/cli/i18n.js +620 -0
  30. package/lib/cli/i18n.js.map +1 -0
  31. package/lib/cli/ipc-client.js +160 -0
  32. package/lib/cli/ipc-client.js.map +1 -0
  33. package/lib/cli/ipc-server.js +464 -0
  34. package/lib/cli/ipc-server.js.map +1 -0
  35. package/lib/cli/main.js +245 -0
  36. package/lib/cli/main.js.map +1 -0
  37. package/lib/cli/qr-terminal.js +48 -0
  38. package/lib/cli/qr-terminal.js.map +1 -0
  39. package/lib/client-data/qr.js +286 -0
  40. package/lib/client-data/qr.js.map +1 -0
  41. package/lib/client-routes.js +308 -25
  42. package/lib/client-routes.js.map +1 -1
  43. package/lib/client.js +1013 -229
  44. package/lib/config.js +50 -12
  45. package/lib/config.js.map +1 -1
  46. package/lib/debug-report.js +28 -1
  47. package/lib/debug-report.js.map +1 -1
  48. package/lib/fetch-route-intercept.js +334 -26
  49. package/lib/fetch-route-intercept.js.map +1 -1
  50. package/lib/index.js +564 -363
  51. package/lib/index.js.map +1 -1
  52. package/lib/intercept.js +1833 -280
  53. package/lib/intercept.js.map +1 -1
  54. package/lib/lan-gate-server.cjs +36 -10
  55. package/lib/merge-streams.js +103 -11
  56. package/lib/merge-streams.js.map +1 -1
  57. package/lib/plugin-route-intercept.js +93 -63
  58. package/lib/plugin-route-intercept.js.map +1 -1
  59. package/lib/relay-access.js +8 -1
  60. package/lib/relay-access.js.map +1 -1
  61. package/lib/relay-client.js +133 -4
  62. package/lib/relay-client.js.map +1 -1
  63. package/lib/relay-pool.js +275 -0
  64. package/lib/relay-pool.js.map +1 -0
  65. package/lib/relay-server.js +269 -17
  66. package/lib/relay-server.js.map +1 -1
  67. package/lib/restart-fields.js +6 -4
  68. package/lib/restart-fields.js.map +1 -1
  69. package/lib/share-store.js +65 -27
  70. package/lib/share-store.js.map +1 -1
  71. package/lib/state-store.js +400 -0
  72. package/lib/state-store.js.map +1 -0
  73. package/lib/types/adapters/dsh-better-sidebar/index.d.ts +3 -2
  74. package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
  75. package/lib/types/admin-routes.d.ts.map +1 -1
  76. package/lib/types/cli/args.d.ts +78 -0
  77. package/lib/types/cli/args.d.ts.map +1 -0
  78. package/lib/types/cli/bootstrap.d.ts +170 -0
  79. package/lib/types/cli/bootstrap.d.ts.map +1 -0
  80. package/lib/types/cli/command-setup.d.ts +72 -0
  81. package/lib/types/cli/command-setup.d.ts.map +1 -0
  82. package/lib/types/cli/commands-client.d.ts +36 -0
  83. package/lib/types/cli/commands-client.d.ts.map +1 -0
  84. package/lib/types/cli/commands-host.d.ts +82 -0
  85. package/lib/types/cli/commands-host.d.ts.map +1 -0
  86. package/lib/types/cli/commands-status.d.ts +46 -0
  87. package/lib/types/cli/commands-status.d.ts.map +1 -0
  88. package/lib/types/cli/config-writer.d.ts +92 -0
  89. package/lib/types/cli/config-writer.d.ts.map +1 -0
  90. package/lib/types/cli/context.d.ts +39 -0
  91. package/lib/types/cli/context.d.ts.map +1 -0
  92. package/lib/types/cli/i18n.d.ts +32 -0
  93. package/lib/types/cli/i18n.d.ts.map +1 -0
  94. package/lib/types/cli/ipc-client.d.ts +83 -0
  95. package/lib/types/cli/ipc-client.d.ts.map +1 -0
  96. package/lib/types/cli/ipc-server.d.ts +113 -0
  97. package/lib/types/cli/ipc-server.d.ts.map +1 -0
  98. package/lib/types/cli/main.d.ts +57 -0
  99. package/lib/types/cli/main.d.ts.map +1 -0
  100. package/lib/types/cli/qr-terminal.d.ts +17 -0
  101. package/lib/types/cli/qr-terminal.d.ts.map +1 -0
  102. package/lib/types/client/RemoteStatusIcon.d.ts.map +1 -1
  103. package/lib/types/client/current-session-report.d.ts +4 -0
  104. package/lib/types/client/current-session-report.d.ts.map +1 -1
  105. package/lib/types/client/effects/model-group-side.d.ts.map +1 -1
  106. package/lib/types/client/effects/remote-session.d.ts.map +1 -1
  107. package/lib/types/client/locales.d.ts +47 -3
  108. package/lib/types/client/locales.d.ts.map +1 -1
  109. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
  110. package/lib/types/client/settings/settings-css.d.ts +1 -1
  111. package/lib/types/client/settings/settings-css.d.ts.map +1 -1
  112. package/lib/types/client/styles/remote-session.css.d.ts +1 -1
  113. package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
  114. package/lib/types/client/wizard/GatewayWizard.d.ts.map +1 -1
  115. package/lib/types/client/wizard/cards/ProxyCard.d.ts.map +1 -1
  116. package/lib/types/client/wizard/wizard-css.d.ts +1 -1
  117. package/lib/types/client/wizard/wizard-css.d.ts.map +1 -1
  118. package/lib/types/client-data/model-group-side.d.ts +23 -2
  119. package/lib/types/client-data/model-group-side.d.ts.map +1 -1
  120. package/lib/types/client-data/remote-status.d.ts +47 -1
  121. package/lib/types/client-data/remote-status.d.ts.map +1 -1
  122. package/lib/types/client-data/settings-form.d.ts +85 -1
  123. package/lib/types/client-data/settings-form.d.ts.map +1 -1
  124. package/lib/types/client-data/wizard.d.ts +56 -1
  125. package/lib/types/client-data/wizard.d.ts.map +1 -1
  126. package/lib/types/client-routes.d.ts +25 -1
  127. package/lib/types/client-routes.d.ts.map +1 -1
  128. package/lib/types/config.d.ts +40 -14
  129. package/lib/types/config.d.ts.map +1 -1
  130. package/lib/types/debug-report.d.ts.map +1 -1
  131. package/lib/types/fetch-route-intercept.d.ts +66 -8
  132. package/lib/types/fetch-route-intercept.d.ts.map +1 -1
  133. package/lib/types/index.d.ts +9 -2
  134. package/lib/types/index.d.ts.map +1 -1
  135. package/lib/types/intercept.d.ts +72 -10
  136. package/lib/types/intercept.d.ts.map +1 -1
  137. package/lib/types/merge-streams.d.ts +79 -1
  138. package/lib/types/merge-streams.d.ts.map +1 -1
  139. package/lib/types/plugin-route-intercept.d.ts +25 -9
  140. package/lib/types/plugin-route-intercept.d.ts.map +1 -1
  141. package/lib/types/relay-access.d.ts +3 -1
  142. package/lib/types/relay-access.d.ts.map +1 -1
  143. package/lib/types/relay-client.d.ts +23 -2
  144. package/lib/types/relay-client.d.ts.map +1 -1
  145. package/lib/types/relay-pool.d.ts +148 -0
  146. package/lib/types/relay-pool.d.ts.map +1 -0
  147. package/lib/types/relay-server.d.ts +30 -3
  148. package/lib/types/relay-server.d.ts.map +1 -1
  149. package/lib/types/restart-fields.d.ts +5 -5
  150. package/lib/types/restart-fields.d.ts.map +1 -1
  151. package/lib/types/share-store.d.ts.map +1 -1
  152. package/lib/types/state-store.d.ts +179 -0
  153. package/lib/types/state-store.d.ts.map +1 -0
  154. package/lib/types/upload-limits.d.ts +15 -0
  155. package/lib/types/upload-limits.d.ts.map +1 -1
  156. package/lib/types/virtual-id.d.ts +21 -0
  157. package/lib/types/virtual-id.d.ts.map +1 -1
  158. package/lib/types/wizard/probe.d.ts.map +1 -1
  159. package/lib/types/wizard/proxy-snippets.d.ts +3 -0
  160. package/lib/types/wizard/proxy-snippets.d.ts.map +1 -1
  161. package/lib/types/wizard/routes.d.ts +1 -0
  162. package/lib/types/wizard/routes.d.ts.map +1 -1
  163. package/lib/types/wizard/status.d.ts +9 -0
  164. package/lib/types/wizard/status.d.ts.map +1 -1
  165. package/lib/types/wizard/verify.d.ts +3 -0
  166. package/lib/types/wizard/verify.d.ts.map +1 -1
  167. package/lib/upload-limits.js +15 -0
  168. package/lib/upload-limits.js.map +1 -1
  169. package/lib/virtual-id.js +60 -0
  170. package/lib/virtual-id.js.map +1 -1
  171. package/lib/wizard/probe.js +46 -1
  172. package/lib/wizard/probe.js.map +1 -1
  173. package/lib/wizard/proxy-snippets.js +14 -1
  174. package/lib/wizard/proxy-snippets.js.map +1 -1
  175. package/lib/wizard/routes.js +23 -4
  176. package/lib/wizard/routes.js.map +1 -1
  177. package/lib/wizard/status.js +21 -0
  178. package/lib/wizard/status.js.map +1 -1
  179. package/lib/wizard/verify.js +31 -8
  180. package/lib/wizard/verify.js.map +1 -1
  181. package/package.json +8 -4
  182. package/pwa/icons/badge-96.png +0 -0
  183. package/pwa/icons/badge.svg +1 -0
  184. package/pwa/manifest.json +2 -1
  185. package/pwa/sw.js +3 -1
  186. package/scripts/check-debug-report.mjs +41 -1
  187. package/scripts/check-settings-form.mjs +14 -3
  188. package/scripts/check-settings-view.mjs +247 -0
  189. package/scripts/check-wizard-ui.mjs +37 -0
  190. package/src/adapters/dsh-better-sidebar/index.ts +7 -3
  191. package/src/admin-routes.ts +10 -2
  192. package/src/cli/args.ts +289 -0
  193. package/src/cli/bootstrap.ts +569 -0
  194. package/src/cli/command-setup.ts +540 -0
  195. package/src/cli/commands-client.ts +369 -0
  196. package/src/cli/commands-host.ts +1127 -0
  197. package/src/cli/commands-status.ts +296 -0
  198. package/src/cli/config-writer.ts +354 -0
  199. package/src/cli/context.ts +38 -0
  200. package/src/cli/i18n.ts +645 -0
  201. package/src/cli/ipc-client.ts +216 -0
  202. package/src/cli/ipc-server.ts +531 -0
  203. package/src/cli/main.ts +285 -0
  204. package/src/cli/qr-terminal.ts +49 -0
  205. package/src/client/RemoteStatusIcon.tsx +4 -1
  206. package/src/client/current-session-report.ts +28 -4
  207. package/src/client/effects/model-group-side.ts +19 -5
  208. package/src/client/effects/remote-session.ts +26 -6
  209. package/src/client/locales.ts +96 -6
  210. package/src/client/settings/SettingsSection.tsx +287 -92
  211. package/src/client/settings/settings-css.ts +18 -0
  212. package/src/client/styles/remote-session.css.ts +15 -1
  213. package/src/client/wizard/GatewayWizard.tsx +470 -109
  214. package/src/client/wizard/cards/ProxyCard.tsx +2 -1
  215. package/src/client/wizard/wizard-css.ts +23 -0
  216. package/src/client-data/model-group-side.ts +37 -10
  217. package/src/client-data/remote-status.ts +112 -17
  218. package/src/client-data/settings-form.ts +164 -8
  219. package/src/client-data/wizard.ts +112 -2
  220. package/src/client-routes.ts +334 -26
  221. package/src/config.ts +62 -16
  222. package/src/debug-report.ts +27 -1
  223. package/src/fetch-route-intercept.ts +381 -32
  224. package/src/index.ts +566 -367
  225. package/src/intercept.ts +1866 -286
  226. package/src/merge-streams.ts +155 -10
  227. package/src/plugin-route-intercept.ts +120 -71
  228. package/src/relay-access.ts +14 -3
  229. package/src/relay-client.ts +163 -5
  230. package/src/relay-pool.ts +387 -0
  231. package/src/relay-server.ts +312 -16
  232. package/src/restart-fields.ts +6 -4
  233. package/src/share-store.ts +59 -26
  234. package/src/state-store.ts +428 -0
  235. package/src/upload-limits.ts +16 -0
  236. package/src/virtual-id.ts +57 -0
  237. package/src/wizard/probe.ts +54 -2
  238. package/src/wizard/proxy-snippets.ts +17 -2
  239. package/src/wizard/routes.ts +24 -5
  240. package/src/wizard/status.ts +28 -0
  241. package/src/wizard/verify.ts +33 -11
  242. package/AGENTS.md +0 -185
  243. package/docs/adr/0001-plugin-bridge-forwards-plugin-routes.md +0 -15
  244. package/docs/adr/0002-relay-emulates-websocket.md +0 -8
package/lib/index.js CHANGED
@@ -20,17 +20,17 @@ import { lstat, mkdir, open, realpath, rename, rm, writeFile } from 'node:fs/pro
20
20
  import { randomBytes } from 'node:crypto';
21
21
  import { homedir, networkInterfaces } from 'node:os';
22
22
  import { basename, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
23
- import { readFileConfig, resolveConfig, unwrapVolatile } from './config.js';
23
+ import { readFileConfig, resolveConfig, unwrapVolatile, servesRole, connectsRole } from './config.js';
24
24
  import { createActivityTracker, createParentIndex, seedParentIndexFromSnapshots, startSweeper } from './activity.js';
25
25
  import { ADMIN_ROUTE_PREFIX, createAdminHandler } from './admin-routes.js';
26
- import { normalizedRowServerUrl } from './client-pairing.js';
27
26
  import { CLIENT_ROUTE_PREFIX, createClientHandler } from './client-routes.js';
28
27
  import { responseJson, sameOriginPost } from './http.js';
29
28
  import { onSessionCreated, restoreBusy } from './share-ops.js';
30
29
  import { startRestartWatcher } from './restart-watcher.js';
31
30
  import { handleShareExport, SHARE_EXPORT_ROUTE } from './share-export.js';
32
31
  import { createShareStore } from './share-store.js';
33
- import { createRelayClient, RelayError, volatileUpdateTouchesServerName } from './relay-client.js';
32
+ import { createRelayPool } from './relay-pool.js';
33
+ import { volatileUpdateTouchesServerName } from './relay-client.js';
34
34
  import { computeFingerprints } from './fingerprint.js';
35
35
  import { runSelfCheck, installIntercept } from './intercept.js';
36
36
  import { checkGatewayShape } from './intercept-shape.js';
@@ -58,12 +58,14 @@ import { createPluginLoopback } from './plugin-loopback.js';
58
58
  import { createPluginTunnel } from './plugin-tunnel.js';
59
59
  import { createLogRing, createPluginLog } from './log-ring.js';
60
60
  import { DEFAULT_MAX_UPLOAD_BYTES } from './upload-limits.js';
61
+ import { applyUnshadowedRowKeys, clearUnshadowedRowKeys, writeCliConfig } from './cli/config-writer.js';
62
+ import { acquireCliIpcServer } from './cli/ipc-server.js';
61
63
  // The loader's config schema (settings form) and the role normalizer live in
62
64
  // src/config.ts next to the resolution they describe; re-exported so the
63
65
  // plugin row's public surface stays on the main entry. The same-origin gate
64
66
  // moved to src/http.ts next to the response envelope (T14) but its export
65
67
  // stays here for scripts/check-upload-endpoint.mjs.
66
- export { Config, resolveRole } from './config.js';
68
+ export { Config, resolveRole, servesRole, connectsRole } from './config.js';
67
69
  export { sameOriginPost } from './http.js';
68
70
  // The two sub-plugin entries the host role loads. They ship as plain .mjs at
69
71
  // the package root, and `..` resolves there both from this file (via the
@@ -124,10 +126,27 @@ const MAX_COLLISION_TRIES = 100;
124
126
  * compositions that never ran this module's apply).
125
127
  */
126
128
  let clientRelayClient;
127
- /** The live client-role relay client, if one was built. */
129
+ /** The live client-role relay client, if one was built. MS-5: the wiring no
130
+ * longer builds one — the relay POOL below replaced it — so this stays
131
+ * undefined in production compositions; the getter (and the client-routes
132
+ * option of the same shape) remain for the single-client contract's old
133
+ * consumers and tests. */
128
134
  export function getRelayClient() {
129
135
  return clientRelayClient;
130
136
  }
137
+ /**
138
+ * The client role's relay pool (MS-5), created inside apply() and kept on
139
+ * the module so the debug-report wiring or a later half can reach it without
140
+ * reaching into a context. Undefined on a pure host role (and in
141
+ * compositions that never ran this module's apply), and — identity-guarded
142
+ * in the disposal effect — after the row that owned a pool was unloaded or
143
+ * reloaded (the T23a-fix discipline the relay client's slot always followed).
144
+ */
145
+ let clientRelayPool;
146
+ /** The live client-role relay pool, if one was built. */
147
+ export function getRelayPool() {
148
+ return clientRelayPool;
149
+ }
131
150
  /**
132
151
  * The plugin bridge's declaration registry (PB-1), created inside apply() on
133
152
  * BOTH roles and kept on the module so later tasks (the handshake's plugins
@@ -447,6 +466,8 @@ export async function handleUpload(ctx, maxBytes, req, res, admit, warn) {
447
466
  * request.
448
467
  */
449
468
  export function apply(ctx, config = {}) {
469
+ const initialHome = process.env.DSH_HOME ?? join(homedir(), '.dsh');
470
+ applyUnshadowedRowKeys(initialHome, config);
450
471
  // CU-1: the plugin's own log ring (SPEC 方案 C) — rebuilt per apply, so a
451
472
  // row reload clears it (expected: the report describes THIS process
452
473
  // generation). `logWarn` / `logInfo` are the branch-shared double-writers:
@@ -635,10 +656,44 @@ export function apply(ctx, config = {}) {
635
656
  return undefined;
636
657
  }
637
658
  };
638
- if (effective.values.role === 'host') {
659
+ // MS-5: the two role halves are INDEPENDENT branches now, not an either/or
660
+ // — a future 'peer' deploy (servesRole AND connectsRole both true) runs
661
+ // both; host runs only the serving half; client only the dialing half.
662
+ // CLI-1: the row-scoped pieces the CLI IPC delegate forwards to. The role
663
+ // branches fill the slots they own; the restart-watcher effect at the
664
+ // bottom hands them to acquireCliIpcServer as ONE delegate, whose release
665
+ // rides the same effect (role-wiring pins the top-level effect counts, so
666
+ // the IPC lifecycle may not register its own).
667
+ const noopAdmit = () => ({ peer: {} });
668
+ const reflectNow = () => ctx.reflect;
669
+ let cliWizardHandler;
670
+ let cliAdminHandler;
671
+ let cliClientHandler;
672
+ let reportedBrowserLang;
673
+ const resolveActiveLang = () => {
674
+ const cfg = resolveConfig(config, readFileConfig(), process.env).values.lang;
675
+ if (cfg === 'en')
676
+ return 'en';
677
+ if (cfg === 'zh')
678
+ return 'zh';
679
+ if (reportedBrowserLang !== undefined)
680
+ return reportedBrowserLang;
681
+ return 'zh';
682
+ };
683
+ /** The managed ingress children THIS row generation owns (host branch
684
+ * only): the restart-watcher disposer stops them, which is the ONLY stop
685
+ * on compositions without a webServer (there the wizard effect — the
686
+ * historic owner of the stop — never fires). */
687
+ const cliManagedChildren = [];
688
+ const home = process.env.DSH_HOME ?? join(homedir(), '.dsh');
689
+ if (servesRole(effective.values.role)) {
639
690
  // A row that switched from client to host must not leave a stale relay
640
- // client where getRelayClient() can find it (T23a-fix).
691
+ // client where getRelayClient() can find it (T23a-fix) — and no stale
692
+ // pool either where getRelayPool() can (MS-5; a peer row rebuilds its
693
+ // pool right after, this block having only cleared the previous
694
+ // generation's slot).
641
695
  clientRelayClient = undefined;
696
+ clientRelayPool = undefined;
642
697
  // The relay shared secret (T22a): minted fresh per apply, handed to the
643
698
  // gateway child through LAN_GATE_RELAY_SECRET and kept here for the
644
699
  // relay routes below. The push half never sees it.
@@ -668,10 +723,14 @@ export function apply(ctx, config = {}) {
668
723
  // The shared-session table backing the relay's access control. The relay
669
724
  // route consumes it below; the activity tracker and idle sweeper (T22c)
670
725
  // keep its "quiet for idleHours → close" promise. The settings surface is
671
- // a later task.
672
- const home = process.env.DSH_HOME ?? join(homedir(), '.dsh');
726
+ // a later task. (`home` is hoisted to apply() level — the CLI IPC
727
+ // delegate binds its socket under the same directory.)
728
+ // MS-1: the table now lives INSIDE the three-in-one state file (alongside
729
+ // the server identity and the paired-servers list); share-store's loader
730
+ // migrates a legacy zen-remote-shares.json in and every write preserves
731
+ // the other two top-level fields.
673
732
  const store = createShareStore({
674
- file: join(home, 'zen-remote-shares.json'),
733
+ file: join(home, 'zen-remote-state.json'),
675
734
  idleHours: effective.values.idleHours,
676
735
  });
677
736
  // The live relay handler, kept so the admin shares route can read viewer
@@ -790,6 +849,253 @@ export function apply(ctx, config = {}) {
790
849
  catch (error) {
791
850
  logWarn('dsh-zen-remote cannot listen on "sessionPersistence": %s', message(error));
792
851
  }
852
+ // CLI-1: the managed-ingress machinery MOVED OUT of the wizard route's
853
+ // inject so the CLI IPC delegate shares ONE set of managers / handler
854
+ // options with the browser routes (same caddyManager, same
855
+ // cloudflaredManager, same verification session, same relay store). The
856
+ // subprocess service is read the lazy way — off the reflection layer,
857
+ // which answers undefined on compositions without it — so the wizard
858
+ // options simply degrade to their 501 placeholders there.
859
+ const getConfig = () => resolveConfig(config, readFileConfig(), process.env);
860
+ const subprocessService = reflectNow()?.get('subprocess');
861
+ const caddyManager = subprocessService === undefined ? undefined : createCaddyManager({ subprocess: subprocessService, dshHome: home });
862
+ const cloudflaredManager = subprocessService === undefined ? undefined : createCloudflaredManager({ subprocess: subprocessService, dshHome: home });
863
+ for (const child of [caddyManager, cloudflaredManager]) {
864
+ if (child !== undefined)
865
+ cliManagedChildren.push(child);
866
+ }
867
+ // The verification session lives in THIS manager set's closure (GW-9):
868
+ // memory-only by design, and a row reload rebuilds it — the same
869
+ // "stale" verdict the config fingerprint produces, arriving via the
870
+ // process generation instead.
871
+ const currentMode = () => getConfig().values.ingressMode;
872
+ const detectLanCandidates = () => {
873
+ try {
874
+ const nets = networkInterfaces();
875
+ const tailscale = [];
876
+ const primaryLan = [];
877
+ const bridgeLan = [];
878
+ for (const [iface, entries] of Object.entries(nets)) {
879
+ if (!Array.isArray(entries))
880
+ continue;
881
+ const isContainerBridge = /^(?:docker\d*|br-|veth|cni\d*|flannel|virbr\d*|cali)/i.test(iface);
882
+ for (const entry of entries) {
883
+ if (!entry || entry.internal || entry.family !== 'IPv4')
884
+ continue;
885
+ const addr = entry.address;
886
+ if (/^100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(addr)) {
887
+ tailscale.push({ ip: addr, kind: 'tailscale', iface });
888
+ }
889
+ else if (/^192\.168\./.test(addr)
890
+ || /^10\./.test(addr)
891
+ || /^172\.(?:1[6-9]|2\d|3[01])\./.test(addr)) {
892
+ if (isContainerBridge)
893
+ bridgeLan.push({ ip: addr, kind: 'lan', iface });
894
+ else
895
+ primaryLan.push({ ip: addr, kind: 'lan', iface });
896
+ }
897
+ }
898
+ }
899
+ return [...tailscale, ...primaryLan, ...bridgeLan];
900
+ }
901
+ catch {
902
+ return [];
903
+ }
904
+ };
905
+ // One runtime probe per request: the gateway's own status (source-IP
906
+ // detection + the ACTUAL port for snippets/QR), plus our processes'
907
+ // liveness for the projection.
908
+ const buildRuntime = async () => {
909
+ const lanAddresses = detectLanCandidates();
910
+ const lanAddress = (lanAddresses.find((c) => c.kind === 'lan') ?? lanAddresses[0])?.ip;
911
+ try {
912
+ const response = await fetch(`http://127.0.0.1:${effective.values.port}/lan-gate/status`, { signal: AbortSignal.timeout(3000) });
913
+ if (!response.ok)
914
+ return { caddyRunning: caddyManager?.isRunning() === true, lanAddress, lanAddresses };
915
+ const body = (await response.json());
916
+ return {
917
+ lastForwardedSource: detectForwardedSource(body),
918
+ // GW-fix-3/M2: the wizard's snippets and QR target the
919
+ // gateway's ACTUAL port (it may have retried past the
920
+ // configured one).
921
+ gatewayActualPort: typeof body?.port === 'number' ? body.port : undefined,
922
+ gatewayActualHost: typeof body?.host === 'string' ? body.host : undefined,
923
+ lanAddress,
924
+ lanAddresses,
925
+ caddyRunning: caddyManager?.isRunning() === true,
926
+ cloudflaredRunning: cloudflaredManager?.isRunning() === true,
927
+ tunnelTokenConfigured: cloudflaredManager !== undefined,
928
+ cfTokenConfigured: cfTokenConfigured(home),
929
+ };
930
+ }
931
+ catch {
932
+ // No answer is no detection — the status projection renders
933
+ // its null shape and the UI offers nothing.
934
+ return {
935
+ lanAddress,
936
+ lanAddresses,
937
+ caddyRunning: caddyManager?.isRunning() === true,
938
+ cloudflaredRunning: cloudflaredManager?.isRunning() === true,
939
+ tunnelTokenConfigured: tunnelTokenConfigured(home),
940
+ cfTokenConfigured: cfTokenConfigured(home),
941
+ };
942
+ }
943
+ };
944
+ const verification = createVerificationManager({
945
+ gatewayBase: `http://127.0.0.1:${effective.values.port}`,
946
+ getValues: () => getConfig().values,
947
+ // runtimeReady now covers the managed paths: Caddy must actually be
948
+ // alive; cloudflared arrives with GW-13b; the external path has no
949
+ // process of ours to wait for.
950
+ runtimeReady: () => {
951
+ const mode = currentMode();
952
+ if (mode === 'caddy')
953
+ return caddyManager?.isRunning() === true;
954
+ if (mode === 'cloudflared')
955
+ return cloudflaredManager?.isRunning() === true;
956
+ return true;
957
+ },
958
+ });
959
+ const wizardOptionsFor = (admit) => ({
960
+ admit,
961
+ getConfig,
962
+ tokenStore: {
963
+ writeCfToken: (token) => { writeCfTokenFile(home, token); },
964
+ deleteCfToken: () => {
965
+ // Stop the consumers first (the mode-switch rule): whichever
966
+ // process might hold the token in its environment dies before
967
+ // the file disappears.
968
+ caddyManager?.stop();
969
+ cloudflaredManager?.stop();
970
+ deleteCfTokenFile(home);
971
+ },
972
+ cfTokenConfigured: () => cfTokenConfigured(home),
973
+ writeTunnelToken: (token) => { writeTunnelTokenFile(home, token); },
974
+ },
975
+ ...(caddyManager === undefined ? {} : {
976
+ caddy: {
977
+ start: async (input) => {
978
+ // 模式切换:starting managed Caddy stops any running cloudflared tunnel first,
979
+ // and restarts Caddy if it was already running with an older config.
980
+ cloudflaredManager?.stop();
981
+ if (caddyManager.isRunning())
982
+ caddyManager.stop();
983
+ // The token rides the child env only — read from the store
984
+ // inside the wiring, never through a route payload.
985
+ const token = readCfTokenFile(home);
986
+ if (token === undefined)
987
+ return { ok: false, error: 'missing-token' };
988
+ return caddyManager.start({ ...input, cfToken: token });
989
+ },
990
+ stop: () => caddyManager.stop(),
991
+ status: () => (caddyManager.isRunning() ? 'running' : caddyManager.hasStarted() ? 'stopped' : 'never-started'),
992
+ },
993
+ }),
994
+ ...(cloudflaredManager === undefined ? {} : {
995
+ tunnel: {
996
+ checkDomain: (domain) => runTunnelCheck({ readToken: () => readCfTokenFile(home) }, domain),
997
+ setup: (input) => setupTunnel({
998
+ dshHome: home,
999
+ readToken: () => readCfTokenFile(home),
1000
+ gatewayPort: async () => {
1001
+ // The ingress service target is the gateway's ACTUAL
1002
+ // port — same fallback as the caddy start route: probe
1003
+ // /lan-gate/status first, fall back to the configured
1004
+ // port when it does not answer (GW-fix-4/M2).
1005
+ try {
1006
+ const response = await fetch(`http://127.0.0.1:${getConfig().values.port}/lan-gate/status`, { signal: AbortSignal.timeout(3000) });
1007
+ if (response.ok) {
1008
+ const body = (await response.json());
1009
+ if (typeof body?.port === 'number')
1010
+ return body.port;
1011
+ }
1012
+ }
1013
+ catch { /* fall through to the configured port */ }
1014
+ return getConfig().values.port;
1015
+ },
1016
+ cloudflared: {
1017
+ start: async (startInput) => {
1018
+ if (cloudflaredManager.isRunning())
1019
+ cloudflaredManager.stop();
1020
+ const started = await cloudflaredManager.start({ tunnelToken: startInput.tunnelToken });
1021
+ return started.ok ? { ok: true } : { ok: false, error: started.error ?? 'spawn-failed' };
1022
+ },
1023
+ // 模式切换:starting the tunnel stops the managed Caddy.
1024
+ stopCaddy: () => caddyManager?.stop(),
1025
+ },
1026
+ }, input),
1027
+ stop: () => cloudflaredManager.stop(),
1028
+ status: () => (cloudflaredManager.isRunning() ? 'running' : cloudflaredManager.hasStarted() ? 'stopped' : 'never-started'),
1029
+ },
1030
+ }),
1031
+ verify: { start: (input) => verification.start(input), check: () => verification.check() },
1032
+ getRuntime: () => buildRuntime(),
1033
+ trustProxy: {
1034
+ resolved: () => {
1035
+ const snapshot = resolveConfig(config, readFileConfig(), process.env);
1036
+ return { value: snapshot.values.trustedProxies, source: snapshot.sources.trustedProxies };
1037
+ },
1038
+ write: async (expected, list) => {
1039
+ const file = join(home, 'lan-gate.config.json');
1040
+ const current = readFileConfig();
1041
+ // Concurrent-write guard (GW-fix-2/L2): the route resolved
1042
+ // the list moments ago; if the file layer moved since (a
1043
+ // parallel trust click, a hand edit), refuse instead of
1044
+ // clobbering the other writer's entry. Both sides compare
1045
+ // normalized so an illegal file value reads as "empty", the
1046
+ // same thing resolveConfig saw.
1047
+ const now = typeof current.trustedProxies === 'string' ? current.trustedProxies : '';
1048
+ if (parseProxyList(now).join(',') !== parseProxyList(expected).join(','))
1049
+ return false;
1050
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
1051
+ await writeFile(tmp, `${JSON.stringify({ ...current, trustedProxies: list }, null, 2)}\n`, { mode: 0o600 });
1052
+ await rename(tmp, file);
1053
+ return true;
1054
+ },
1055
+ },
1056
+ });
1057
+ const adminOptionsFor = (admit) => ({
1058
+ admit,
1059
+ gatewayBase: `http://127.0.0.1:${effective.values.port}`,
1060
+ getConfig,
1061
+ // Shares routes (T33a): the table above is the truth they serve and
1062
+ // mutate. typertGateway and agents are deliberately NOT declared in
1063
+ // any inject — adding them would keep the WHOLE admin prefix off
1064
+ // 0.1.7 / Electron compositions that lack them — so they are read PER
1065
+ // REQUEST through the reflection layer's store lookup (runtime
1066
+ // `ctx.get`, declared `ctx.reflect.get`), which answers undefined
1067
+ // when the service is absent. Property access is not an option:
1068
+ // cordis refuses reads of services the context did not declare
1069
+ // (`cannot get property … without inject`), which would turn every
1070
+ // shares request into a 500.
1071
+ store,
1072
+ typert: () => reflectNow()?.get('typertGateway'),
1073
+ getPeer: createOwnerPeerSource(() => reflectNow()?.get('connection')),
1074
+ listAgents: () => reflectNow()?.get('agents')?.list() ?? [],
1075
+ // The "who is looking" badge (T33a2): read through whichever relay
1076
+ // handler is live right now. sessionQuery likewise — the share
1077
+ // action reads the durable session header through it to judge
1078
+ // subagent sessions, degrading to the projection identity when the
1079
+ // composition lacks the service.
1080
+ viewerCount: (id) => relayHandler?.viewerCount(id) ?? 0,
1081
+ sessionQuery: () => reflectNow()?.get('sessionQuery'),
1082
+ // The plugin bridge listing (PB-5): the SAME registry this apply
1083
+ // built and the SAME live consent reader the relay's plugins table
1084
+ // uses — one source of truth per question. PB-11: the listing
1085
+ // marks the plugins an adapter stands in for (live — a plugin
1086
+ // that registered its own declaration stops being marked).
1087
+ pluginBridge: {
1088
+ registry,
1089
+ getConsent: () => getConfig().values.pluginBridgeConsent,
1090
+ adaptedPlugins: () => new Set(adapters.filter((adapter) => adapterOwnsRegistryEntry(adapter, registry.get(adapter.plugin))).map((adapter) => adapter.plugin)),
1091
+ },
1092
+ });
1093
+ // The CLI IPC delegate's OWN handler instances (CLI-1): same options,
1094
+ // same managers, same verification session as the browser routes below
1095
+ // — only the admit differs, because the request arrived over the 0600
1096
+ // socket (same user, same machine) instead of DSH's cookie wall.
1097
+ cliAdminHandler = createAdminHandler(adminOptionsFor(noopAdmit));
1098
+ cliWizardHandler = createWizardHandler(wizardOptionsFor(noopAdmit));
793
1099
  // Settings-surface admin routes (T14). The browser cannot call the
794
1100
  // gateway's loopback-only admin API from DSH's origin, so the host
795
1101
  // process re-exposes it same-origin and the handler calls the gateway AS
@@ -806,42 +1112,7 @@ export function apply(ctx, config = {}) {
806
1112
  webCtx.effect(() => webCtx.webServer.register({
807
1113
  kind: 'prefix',
808
1114
  path: ADMIN_ROUTE_PREFIX,
809
- handler: createAdminHandler({
810
- admit: (req) => webCtx.connection.admit(req),
811
- gatewayBase: `http://127.0.0.1:${effective.values.port}`,
812
- getConfig: () => resolveConfig(config, readFileConfig(), process.env),
813
- // Shares routes (T33a): the table above is the truth they serve and
814
- // mutate. typertGateway and agents are deliberately NOT in this
815
- // inject's declaration list — adding them would keep the WHOLE
816
- // admin prefix off 0.1.7 / Electron compositions that lack them —
817
- // so they are read PER REQUEST through the reflection layer's
818
- // store lookup (runtime `ctx.get`, declared `ctx.reflect.get`),
819
- // which answers undefined when the service is absent. Property
820
- // access is not an option: cordis refuses reads of services the
821
- // context did not declare (`cannot get property … without inject`),
822
- // which would turn every shares request into a 500.
823
- store,
824
- typert: () => webCtx.reflect.get('typertGateway'),
825
- getPeer: createOwnerPeerSource(() => webCtx.connection),
826
- listAgents: () => webCtx.reflect.get('agents')?.list() ?? [],
827
- // The "who is looking" badge (T33a2): read through whichever relay
828
- // handler is live right now. sessionQuery likewise — the share
829
- // action reads the durable session header through it to judge
830
- // subagent sessions, degrading to the projection identity when the
831
- // composition lacks the service.
832
- viewerCount: (id) => relayHandler?.viewerCount(id) ?? 0,
833
- sessionQuery: () => webCtx.reflect.get('sessionQuery'),
834
- // The plugin bridge listing (PB-5): the SAME registry this apply
835
- // built and the SAME live consent reader the relay's plugins table
836
- // uses — one source of truth per question. PB-11: the listing
837
- // marks the plugins an adapter stands in for (live — a plugin
838
- // that registered its own declaration stops being marked).
839
- pluginBridge: {
840
- registry,
841
- getConsent: () => resolveConfig(config, readFileConfig(), process.env).values.pluginBridgeConsent,
842
- adaptedPlugins: () => new Set(adapters.filter((adapter) => adapterOwnsRegistryEntry(adapter, registry.get(adapter.plugin))).map((adapter) => adapter.plugin)),
843
- },
844
- }),
1115
+ handler: createAdminHandler(adminOptionsFor((req) => webCtx.connection.admit(req))),
845
1116
  }), 'dsh-zen-remote: admin routes');
846
1117
  });
847
1118
  // Gateway-wizard routes (GW-4, extended by GW-5): a separate LONGEST-
@@ -857,204 +1128,16 @@ export function apply(ctx, config = {}) {
857
1128
  // reloads the row from (trustedProxies is a restart field), restarting
858
1129
  // the gateway with the new trust list. When the resolved value comes
859
1130
  // from env or the row the route refuses instead: a file-layer write
860
- // would be shadowed there.
1131
+ // would be shadowed there. (CLI-1: the managers and the handler options
1132
+ // moved OUT of this inject to apply() level — see wizardOptionsFor —
1133
+ // so the CLI IPC delegate drives the SAME manager set; this effect only
1134
+ // registers the route and stops the children with the row.)
861
1135
  ctx.inject(['webServer', 'connection'], (wizardCtx) => {
862
- // GW-13a: the managed Caddy lives in THIS row's closure — a row
863
- // reload rebuilds the manager (and the row's disposal, below, kills
864
- // the child), which is the reconcile-by-process-generation rule. The
865
- // subprocess service is a lazy reflection lookup: adding it to the
866
- // inject list would keep the whole wizard off compositions that lack
867
- // it, and there `reflect.get` answers undefined instead.
868
- const dshHome = process.env.DSH_HOME ?? join(homedir(), '.dsh');
869
- const subprocessService = wizardCtx.reflect.get('subprocess');
870
- const caddyManager = subprocessService === undefined ? undefined : createCaddyManager({ subprocess: subprocessService, dshHome });
871
- const cloudflaredManager = subprocessService === undefined ? undefined : createCloudflaredManager({ subprocess: subprocessService, dshHome });
872
- // The verification session lives in THIS manager's closure (GW-9):
873
- // memory-only by design, and a row reload rebuilds it — the same
874
- // "stale" verdict the config fingerprint produces, arriving via the
875
- // process generation instead.
876
- const currentMode = () => resolveConfig(config, readFileConfig(), process.env).values.ingressMode;
877
- const detectLanAddress = () => {
878
- try {
879
- const nets = networkInterfaces();
880
- let tailscaleIp;
881
- let lanIp;
882
- for (const entries of Object.values(nets)) {
883
- if (!Array.isArray(entries))
884
- continue;
885
- for (const entry of entries) {
886
- if (!entry || entry.internal || entry.family !== 'IPv4')
887
- continue;
888
- const addr = entry.address;
889
- if (/^192\.168\./.test(addr)
890
- || /^10\./.test(addr)
891
- || /^172\.(?:1[6-9]|2\d|3[01])\./.test(addr)) {
892
- lanIp ??= addr;
893
- }
894
- else if (/^100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(addr)) {
895
- tailscaleIp ??= addr;
896
- }
897
- }
898
- }
899
- return lanIp ?? tailscaleIp;
900
- }
901
- catch {
902
- return undefined;
903
- }
904
- };
905
- // One runtime probe per request: the gateway's own status (source-IP
906
- // detection + the ACTUAL port for snippets/QR), plus our processes'
907
- // liveness for the projection.
908
- const buildRuntime = async () => {
909
- const lanAddress = detectLanAddress();
910
- try {
911
- const response = await fetch(`http://127.0.0.1:${effective.values.port}/lan-gate/status`, { signal: AbortSignal.timeout(3000) });
912
- if (!response.ok)
913
- return { caddyRunning: caddyManager?.isRunning() === true, lanAddress };
914
- const body = (await response.json());
915
- return {
916
- lastForwardedSource: detectForwardedSource(body),
917
- // GW-fix-3/M2: the wizard's snippets and QR target the
918
- // gateway's ACTUAL port (it may have retried past the
919
- // configured one).
920
- gatewayActualPort: typeof body?.port === 'number' ? body.port : undefined,
921
- lanAddress,
922
- caddyRunning: caddyManager?.isRunning() === true,
923
- cloudflaredRunning: cloudflaredManager?.isRunning() === true,
924
- tunnelTokenConfigured: cloudflaredManager !== undefined,
925
- cfTokenConfigured: cfTokenConfigured(dshHome),
926
- };
927
- }
928
- catch {
929
- // No answer is no detection — the status projection renders
930
- // its null shape and the UI offers nothing.
931
- return {
932
- lanAddress,
933
- caddyRunning: caddyManager?.isRunning() === true,
934
- cloudflaredRunning: cloudflaredManager?.isRunning() === true,
935
- tunnelTokenConfigured: tunnelTokenConfigured(dshHome),
936
- cfTokenConfigured: cfTokenConfigured(dshHome),
937
- };
938
- }
939
- };
940
- const verification = createVerificationManager({
941
- gatewayBase: `http://127.0.0.1:${effective.values.port}`,
942
- getValues: () => resolveConfig(config, readFileConfig(), process.env).values,
943
- // runtimeReady now covers the managed paths: Caddy must actually be
944
- // alive; cloudflared arrives with GW-13b; the external path has no
945
- // process of ours to wait for.
946
- runtimeReady: () => {
947
- const mode = currentMode();
948
- if (mode === 'caddy')
949
- return caddyManager?.isRunning() === true;
950
- if (mode === 'cloudflared')
951
- return cloudflaredManager?.isRunning() === true;
952
- return true;
953
- },
954
- });
955
1136
  wizardCtx.effect(() => {
956
1137
  const unregister = wizardCtx.webServer.register({
957
1138
  kind: 'prefix',
958
1139
  path: WIZARD_ROUTE_PREFIX,
959
- handler: createWizardHandler({
960
- admit: (req) => wizardCtx.connection.admit(req),
961
- getConfig: () => resolveConfig(config, readFileConfig(), process.env),
962
- tokenStore: {
963
- writeCfToken: (token) => { writeCfTokenFile(dshHome, token); },
964
- deleteCfToken: () => {
965
- // Stop the consumers first (the mode-switch rule): whichever
966
- // process might hold the token in its environment dies before
967
- // the file disappears.
968
- caddyManager?.stop();
969
- cloudflaredManager?.stop();
970
- deleteCfTokenFile(dshHome);
971
- },
972
- cfTokenConfigured: () => cfTokenConfigured(dshHome),
973
- writeTunnelToken: (token) => { writeTunnelTokenFile(dshHome, token); },
974
- },
975
- ...(caddyManager === undefined ? {} : {
976
- caddy: {
977
- start: async (input) => {
978
- // 模式切换:starting managed Caddy stops any running cloudflared tunnel first,
979
- // and restarts Caddy if it was already running with an older config.
980
- cloudflaredManager?.stop();
981
- if (caddyManager.isRunning())
982
- caddyManager.stop();
983
- // The token rides the child env only — read from the store
984
- // inside the wiring, never through a route payload.
985
- const token = readCfTokenFile(dshHome);
986
- if (token === undefined)
987
- return { ok: false, error: 'missing-token' };
988
- return caddyManager.start({ ...input, cfToken: token });
989
- },
990
- stop: () => caddyManager.stop(),
991
- status: () => (caddyManager.isRunning() ? 'running' : caddyManager.hasStarted() ? 'stopped' : 'never-started'),
992
- },
993
- }),
994
- ...(cloudflaredManager === undefined ? {} : {
995
- tunnel: {
996
- checkDomain: (domain) => runTunnelCheck({ readToken: () => readCfTokenFile(dshHome) }, domain),
997
- setup: (input) => setupTunnel({
998
- dshHome,
999
- readToken: () => readCfTokenFile(dshHome),
1000
- gatewayPort: async () => {
1001
- // The ingress service target is the gateway's ACTUAL
1002
- // port — same fallback as the caddy start route: probe
1003
- // /lan-gate/status first, fall back to the configured
1004
- // port when it does not answer (GW-fix-4/M2).
1005
- try {
1006
- const response = await fetch(`http://127.0.0.1:${resolveConfig(config, readFileConfig(), process.env).values.port}/lan-gate/status`, { signal: AbortSignal.timeout(3000) });
1007
- if (response.ok) {
1008
- const body = (await response.json());
1009
- if (typeof body?.port === 'number')
1010
- return body.port;
1011
- }
1012
- }
1013
- catch { /* fall through to the configured port */ }
1014
- return resolveConfig(config, readFileConfig(), process.env).values.port;
1015
- },
1016
- cloudflared: {
1017
- start: async (startInput) => {
1018
- if (cloudflaredManager.isRunning())
1019
- cloudflaredManager.stop();
1020
- const started = await cloudflaredManager.start({ tunnelToken: startInput.tunnelToken });
1021
- return started.ok ? { ok: true } : { ok: false, error: started.error ?? 'spawn-failed' };
1022
- },
1023
- // 模式切换:starting the tunnel stops the managed Caddy.
1024
- stopCaddy: () => caddyManager?.stop(),
1025
- },
1026
- }, input),
1027
- stop: () => cloudflaredManager.stop(),
1028
- status: () => (cloudflaredManager.isRunning() ? 'running' : cloudflaredManager.hasStarted() ? 'stopped' : 'never-started'),
1029
- },
1030
- }),
1031
- verify: { start: (input) => verification.start(input), check: () => verification.check() },
1032
- getRuntime: () => buildRuntime(),
1033
- trustProxy: {
1034
- resolved: () => {
1035
- const snapshot = resolveConfig(config, readFileConfig(), process.env);
1036
- return { value: snapshot.values.trustedProxies, source: snapshot.sources.trustedProxies };
1037
- },
1038
- write: async (expected, list) => {
1039
- const home = process.env.DSH_HOME ?? join(homedir(), '.dsh');
1040
- const file = join(home, 'lan-gate.config.json');
1041
- const current = readFileConfig();
1042
- // Concurrent-write guard (GW-fix-2/L2): the route resolved
1043
- // the list moments ago; if the file layer moved since (a
1044
- // parallel trust click, a hand edit), refuse instead of
1045
- // clobbering the other writer's entry. Both sides compare
1046
- // normalized so an illegal file value reads as "empty", the
1047
- // same thing resolveConfig saw.
1048
- const now = typeof current.trustedProxies === 'string' ? current.trustedProxies : '';
1049
- if (parseProxyList(now).join(',') !== parseProxyList(expected).join(','))
1050
- return false;
1051
- const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
1052
- await writeFile(tmp, `${JSON.stringify({ ...current, trustedProxies: list }, null, 2)}\n`, { mode: 0o600 });
1053
- await rename(tmp, file);
1054
- return true;
1055
- },
1056
- },
1057
- }),
1140
+ handler: createWizardHandler(wizardOptionsFor((req) => wizardCtx.connection.admit(req))),
1058
1141
  });
1059
1142
  // H1: the row's disposal must not orphan the managed children — the
1060
1143
  // Caddy/cloudflared processes die WITH the row that spawned them,
@@ -1271,106 +1354,130 @@ export function apply(ctx, config = {}) {
1271
1354
  getGatewaySummary: gatewaySummary,
1272
1355
  getProfileName: profileName,
1273
1356
  getPluginVersion: () => pluginVersion,
1357
+ lang: resolveActiveLang,
1274
1358
  }),
1275
1359
  }), 'dsh-zen-remote: client routes (host)');
1276
1360
  });
1361
+ // CLI-1: the IPC delegate's client face on a serving role — remote-status
1362
+ // and the debug report answer (both are every role's own); the IPC
1363
+ // prefix gate covers the pairing routes (409 wrong-role before any
1364
+ // request reaches here), and the handler keeps the host mount's
1365
+ // least-exposure wall besides.
1366
+ cliClientHandler = createClientHandler({
1367
+ admit: noopAdmit,
1368
+ getRowConfig: () => config,
1369
+ remoteStatusOnly: true,
1370
+ getServiceProbe: uploadServiceProbe(ctx),
1371
+ getLogSnapshot: () => ring.snapshot(),
1372
+ getGatewaySummary: gatewaySummary,
1373
+ getProfileName: profileName,
1374
+ getPluginVersion: () => pluginVersion,
1375
+ lang: resolveActiveLang,
1376
+ });
1277
1377
  }
1278
- else {
1279
- // Sub-client half (T16, T23a): pairing claim + connection status, talking to
1280
- // the SERVER's gateway instead of running one. Same admission wall as
1281
- // the admin routes — and, like them, a composition without a connection
1282
- // service (Electron carries none) simply never mounts the routes. The
1283
- // handler keeps the RAW row object: serverUrl / deviceToken are volatile
1284
- // ({ get() } wrapped), so every request reads them live through
1285
- // unwrapVolatile — an apply-time snapshot would go stale the moment the
1286
- // settings page pairs or unpairs.
1287
- //
1288
- // The relay client (T23a) reads the SAME volatile fields live at every
1289
- // request; the address passes the claim route's normalizer, and an
1290
- // address that no longer normalizes counts as missing — a hand-edited
1291
- // row must not receive the pairing token.
1378
+ if (connectsRole(effective.values.role)) {
1379
+ // Sub-client half (T16, T23a; MS-5 pool rewiring): the paired-servers
1380
+ // connection pool instead of the single row-driven client. The row's
1381
+ // serverUrl/deviceToken survive only as the LEGACY source — read once
1382
+ // here so an old single-server row migrates into
1383
+ // `zen-remote-state.json`'s paired list (state-store's migration keeps
1384
+ // the list's other entries); from then on the pool's paired list is the
1385
+ // single truth, edited by the client routes' claim/unpair.
1292
1386
  const row = config;
1293
- const relayClient = createRelayClient({
1294
- getServerUrl: () => normalizedRowServerUrl(row),
1295
- getToken: () => {
1296
- const value = unwrapVolatile(row.deviceToken);
1297
- return typeof value === 'string' && value !== '' ? value : undefined;
1298
- },
1387
+ // (`home` is hoisted to apply() level — the pool's state file and the
1388
+ // CLI IPC socket share the same directory.)
1389
+ // MS-3/MS-5: the intercept handle is created inside the typertGateway
1390
+ // inject below, but the POOL's state callbacks (created first) forward
1391
+ // to it — the closure is fine (the callbacks fire after apply() returned
1392
+ // or not at all); the declaration just moved up so the wiring reads
1393
+ // top-down.
1394
+ let interceptHandle;
1395
+ const legacyCredentials = () => {
1396
+ const url = unwrapVolatile(row.serverUrl);
1397
+ const token = unwrapVolatile(row.deviceToken);
1398
+ const serverUrl = typeof url === 'string' && url.trim() !== '' ? url : undefined;
1399
+ const deviceToken = typeof token === 'string' && token !== '' ? token : undefined;
1400
+ return serverUrl === undefined && deviceToken === undefined ? undefined : { serverUrl, deviceToken };
1401
+ };
1402
+ // CU-1 lifecycle: the pool's per-server link flips enter the ring —
1403
+ // `online` with the entry and its handshake server id, `offline` with
1404
+ // the failure code (lastError is a short code by contract, never free
1405
+ // text). The client's subscribe re-announces the CURRENT state when only
1406
+ // the handshake identity moved (the T59 heartbeat), so the per-entry
1407
+ // dedupe keeps the heartbeats out of the ring: one line per actual state
1408
+ // change. Every event ALSO reaches the intercept's pool hub — the merged
1409
+ // streams re-scan `pool.entries()` on it.
1410
+ const poolRingStates = new Map();
1411
+ const relayPool = createRelayPool({
1412
+ stateFile: join(home, 'zen-remote-state.json'),
1413
+ legacySource: legacyCredentials,
1299
1414
  // Interface compatibility (T42): after each completed handshake this
1300
1415
  // side computes its OWN fingerprints against THIS process's typert
1301
1416
  // registry (read per call — at creation time the row context may still
1302
- // be assembling), and the relay client stores the group-by-group
1303
- // verdict the status route reports.
1417
+ // be assembling), and the client stores the group-by-group verdict the
1418
+ // status route reports.
1304
1419
  computeOwnFingerprints: () => computeFingerprints(ctx),
1420
+ onStateChange: ({ entryId, serverId, state, lastError }) => {
1421
+ if (poolRingStates.get(entryId) !== state) {
1422
+ poolRingStates.set(entryId, state);
1423
+ if (state === 'online')
1424
+ ring.push('info', 'relay pool online (entry=%s server=%s)', entryId, serverId ?? '?');
1425
+ else if (state === 'offline')
1426
+ ring.push('warn', 'relay pool offline (entry=%s code=%s)', entryId, lastError ?? '?');
1427
+ }
1428
+ interceptHandle?.notePoolEvent();
1429
+ },
1430
+ onServerRemoved: ({ entryId, reason }) => {
1431
+ poolRingStates.delete(entryId);
1432
+ ring.push('info', 'relay pool server removed (entry=%s reason=%s)', entryId, reason);
1433
+ interceptHandle?.notePoolEvent();
1434
+ },
1305
1435
  });
1306
- clientRelayClient = relayClient;
1307
- // The plugin bridge's live verdict (PB-2), the CLIENT role's job — a host
1308
- // has no remote sessions of its own to bridge anything into. Computed PER
1436
+ clientRelayPool = relayPool;
1437
+ // A stopped or reloaded row must not leave a stale pool where the module
1438
+ // getter can hand it out (T23a-fix's discipline), and its clients'
1439
+ // reconnect machinery must not outlive the row either: stop() ends every
1440
+ // client (which cancels armed retry timers and blocks scheduling). The
1441
+ // identity guard keeps a slow dispose from clearing a NEWER row's pool.
1442
+ ctx.effect(() => () => {
1443
+ relayPool.stop();
1444
+ if (clientRelayPool === relayPool)
1445
+ clientRelayPool = undefined;
1446
+ }, 'dsh-zen-remote: relay pool disposal');
1447
+ // The plugin bridge's live verdict (PB-2), per POOL ENTRY now (MS-5) — a
1448
+ // host has no remote sessions of its own to bridge anything into, and
1449
+ // server A's verdicts must never answer for server B's. Computed PER
1309
1450
  // CALL (never a stored conclusion): the local registry is this apply's
1310
- // own closure, the server's table is the LAST handshake's `plugins`
1451
+ // own closure, the entry's table is ITS LAST handshake's `plugins`
1311
1452
  // (undefined until one succeeded — an old server reads server-too-old),
1312
- // and `online` is the client's CURRENT state — so a registry change, a
1453
+ // and `online` is that client's CURRENT state — so a registry change, a
1313
1454
  // fresh handshake or a link flip shows up at the very next read.
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
- });
1333
- // A stopped or reloaded row must not leave a stale instance where the
1334
- // module getter can hand it out (T23a-fix), and its reconnect machinery
1335
- // (T43) must not outlive the row either: stop() cancels the armed retry
1336
- // timer and blocks further scheduling. The identity guard keeps a slow
1337
- // dispose from clearing a NEWER row's instance that a reload already
1338
- // installed.
1339
- ctx.effect(() => () => {
1340
- offRelayRing();
1341
- relayClient.stop();
1342
- if (clientRelayClient === relayClient)
1343
- clientRelayClient = undefined;
1344
- }, 'dsh-zen-remote: relay client disposal');
1345
- // Credentials watch (T43): serverUrl / deviceToken are volatile row
1346
- // fields, and a pairing write (or unpair) commits them WITHOUT a row
1347
- // restart. The loader announces the commit on the row context; the
1348
- // client drops its pending reconnect wait and dials the new credentials
1349
- // immediately. Failure to subscribe degrades to a warning — the ladder
1350
- // and the per-request getters keep working without it.
1351
- // T59 rides the same announcement, and ONLY that announcement (T59-fix):
1352
- // the row's `serverName` is this device's name, and a USER-committed
1353
- // change is forwarded to the gateway's device table — but the event
1354
- // fires for EVERY volatile commit (an unrelated knob, or the settings
1355
- // page's own server-driven follow write arriving at startup), so the
1356
- // listener filters on the changed paths. A push fires only when
1357
- // `serverName` itself moved and differs from the server's record
1358
- // (queueDeviceName's contract) — which is what keeps the follow write
1359
- // from bouncing back and forth. NOT pushed at startup either: the row's
1360
- // loaded name is stale by definition (the server's table may have been
1361
- // edited while we were gone) and an unconditional boot-time push would
1362
- // overwrite an administrator's rename. A rename committed offline and
1363
- // lost to a process exit therefore stays local until the user edits it
1364
- // again — accepted; the server's record wins by default.
1455
+ const pluginBridgeStatusFor = (serverId, client) => compareBridges(registry.list(), client.handshakeInfo?.plugins, client.state === 'online');
1456
+ // Device-name watch (T59 through MS-5's pool): the row's `serverName` is
1457
+ // this device's name, and a USER-committed change is broadcast to EVERY
1458
+ // paired server's gateway device table — one-way (MS decision: the
1459
+ // server's record never rewrites the row). The loader announces the
1460
+ // commit on the row context, but the event fires for EVERY volatile
1461
+ // commit (an unrelated knob, or a settings page's own server-driven
1462
+ // follow write), so the listener filters on the changed paths. Each
1463
+ // client pushes only when the name differs from its server's record
1464
+ // (queueDeviceName's contract) — which is what keeps a follow write from
1465
+ // bouncing back and forth. NOT pushed at startup either: the row's
1466
+ // loaded name is stale by definition and an unconditional boot-time push
1467
+ // would overwrite an administrator's rename. A rename committed offline
1468
+ // and lost to a process exit stays local until the user edits it again —
1469
+ // accepted; the server's record wins by default. (The old T43
1470
+ // credentialsChanged half of this listener is gone with the row client:
1471
+ // the pool's credentials live in the state file, edited by claim/unpair,
1472
+ // and its clients react to THOSE on their own.)
1365
1473
  ctx.effect(() => {
1366
1474
  try {
1367
1475
  const off = ctx.on('loader/volatile-update', (paths) => {
1368
- relayClient.credentialsChanged();
1369
1476
  if (!volatileUpdateTouchesServerName(paths))
1370
1477
  return;
1371
1478
  const name = unwrapVolatile(row.serverName);
1372
1479
  if (typeof name === 'string' && name.trim() !== '')
1373
- relayClient.queueDeviceName(name);
1480
+ relayPool.queueDeviceNameAll(name);
1374
1481
  });
1375
1482
  return () => {
1376
1483
  off();
@@ -1381,24 +1488,15 @@ export function apply(ctx, config = {}) {
1381
1488
  return () => { };
1382
1489
  }
1383
1490
  }, 'dsh-zen-remote: relay credentials watcher');
1384
- // One connection attempt at startup (T23a). Failures only log — the
1385
- // status route surfaces the resulting state, and the client's own
1386
- // backoff ladder (T43) takes over from the first failure. An unpaired
1387
- // row is the normal not-configured shape and stays silent.
1388
- void relayClient.connect().catch((error) => {
1389
- if (error instanceof RelayError && error.code === 'unpaired')
1390
- return;
1391
- const code = error instanceof RelayError ? error.code : 'error';
1392
- logWarn('dsh-zen-remote relay client initial connect failed (%s): %s', code, message(error));
1393
- });
1394
1491
  // The typertGateway interception (T23b-1): remote-session calls ride the
1395
1492
  // relay, everything else reaches the local gateway untouched. The live
1396
1493
  // diagnostics flow to the client status route through whichever of the
1397
1494
  // two slots is filled — an installed handle, or the shape verdict that
1398
1495
  // REFUSED an install (both belong on the settings surface). A
1399
1496
  // composition without typertGateway (0.1.7 client) leaves both empty and
1400
- // the status answer simply carries no intercept field.
1401
- let interceptHandle;
1497
+ // the status answer simply carries no intercept field. (The handle's
1498
+ // declaration itself sits above, beside the pool — its callbacks forward
1499
+ // pool events here.)
1402
1500
  let interceptRefused;
1403
1501
  // T62: the browser's current-session report, held here and read live by
1404
1502
  // the intercept (which cannot reach the browser's localStorage — it
@@ -1406,8 +1504,10 @@ export function apply(ctx, config = {}) {
1406
1504
  // first report arrives the value is `unavailable` and the intercept
1407
1505
  // keeps its conservative tombstone behavior.
1408
1506
  let reportedCurrentSession = { kind: 'unavailable' };
1409
- const onCurrentSession = (read) => {
1507
+ const onCurrentSession = (read, reportedLang) => {
1410
1508
  reportedCurrentSession = read;
1509
+ if (reportedLang !== undefined)
1510
+ reportedBrowserLang = reportedLang;
1411
1511
  // Re-judge NOW — the merger dedupes no-change calls, so a report that
1412
1512
  // carries nothing new costs one compare.
1413
1513
  interceptHandle?.currentSessionChanged();
@@ -1425,19 +1525,22 @@ export function apply(ctx, config = {}) {
1425
1525
  // between two stream refreshes waits for the next refresh by design
1426
1526
  // (the documented ceiling in merge-streams.ts). Read-only consumption:
1427
1527
  // nothing below ever mutates what a merger handed over.
1528
+ // MS-5: each share is tagged with the serverId its stream asked the slot
1529
+ // for, so the plugin-route's per-server face can answer the union of ONE
1530
+ // server's ids — a rewrite against server A must never see B's ids.
1428
1531
  const knownSessionShares = new Map();
1429
1532
  const knownRealIds = new Set();
1430
1533
  const rebuildKnownRealIds = () => {
1431
1534
  knownRealIds.clear();
1432
1535
  for (const share of knownSessionShares.values()) {
1433
- for (const id of share)
1536
+ for (const id of share.ids)
1434
1537
  knownRealIds.add(id);
1435
1538
  }
1436
1539
  };
1437
- const createSessionInventorySlot = () => {
1540
+ const createSessionInventorySlot = (serverId) => {
1438
1541
  const slot = {
1439
1542
  replace(ids) {
1440
- knownSessionShares.set(slot, ids);
1543
+ knownSessionShares.set(slot, { serverId, ids });
1441
1544
  rebuildKnownRealIds();
1442
1545
  },
1443
1546
  dispose() {
@@ -1447,6 +1550,19 @@ export function apply(ctx, config = {}) {
1447
1550
  };
1448
1551
  return slot;
1449
1552
  };
1553
+ /** MS-5: one server's share of the union, for the plugin-route rewrite's
1554
+ * per-server face. An entry that never reported (its stream not open
1555
+ * yet) contributes nothing — the request-local scan still runs. */
1556
+ const knownRealIdsForServer = (serverId) => {
1557
+ const out = new Set();
1558
+ for (const share of knownSessionShares.values()) {
1559
+ if (share.serverId !== serverId)
1560
+ continue;
1561
+ for (const id of share.ids)
1562
+ out.add(id);
1563
+ }
1564
+ return out;
1565
+ };
1450
1566
  const interceptDiagnostics = () => {
1451
1567
  if (interceptHandle !== undefined)
1452
1568
  return interceptHandle.diagnostics();
@@ -1475,9 +1591,14 @@ export function apply(ctx, config = {}) {
1475
1591
  }
1476
1592
  const handle = installIntercept({
1477
1593
  raw,
1478
- relay: relayClient,
1479
- getServerId: () => relayClient.handshakeInfo?.serverId,
1594
+ // MS-5 POOL mode: routing and merging read the relay pool — every
1595
+ // call/stream/event rides the virtual id's serverId half to ITS
1596
+ // server, the three global reads merge ALL paired servers in pairing
1597
+ // order. (The single-server `relay` + `getServerId` form is gone
1598
+ // from the wiring with the row client.)
1599
+ pool: relayPool,
1480
1600
  getCurrentSession: () => reportedCurrentSession,
1601
+ lang: resolveActiveLang,
1481
1602
  // PB-25b: each merged stream reports its real-session inventory
1482
1603
  // share into the union above.
1483
1604
  createSessionInventorySlot,
@@ -1572,8 +1693,9 @@ export function apply(ctx, config = {}) {
1572
1693
  }
1573
1694
  // Shape first, like the typert wrap: a failed check leaves every route
1574
1695
  // exactly as it was — local uploads untouched, remote uploads keep
1575
- // failing with the honest local error.
1576
- const shape = checkFetchRouteShape(raw);
1696
+ // failing with the honest local error. (The media note rides along:
1697
+ // this wiring always hands a resolveRemoteFileScope over, P55-2.)
1698
+ const shape = checkFetchRouteShape(raw, true);
1577
1699
  if (!shape.ok) {
1578
1700
  fetchRouteRefused = shape;
1579
1701
  logWarn('dsh-zen-remote fetch-route intercept not installed: %s', shape.reasons.join('; '));
@@ -1581,8 +1703,14 @@ export function apply(ctx, config = {}) {
1581
1703
  }
1582
1704
  const handle = installFetchRouteIntercept({
1583
1705
  connection: raw,
1584
- relay: relayClient,
1585
- getServerId: () => relayClient.handshakeInfo?.serverId,
1706
+ // MS-5 pool mode: each upload's serverId half picks its own client.
1707
+ getRelayForServer: (serverId) => relayPool.getByServerId(serverId)?.client,
1708
+ // P55-2: the /api/file media route forwards to the session the
1709
+ // intercept currently holds — read LIVE per request, so an uninstalled
1710
+ // (or not-yet-installed) typert wrap answers undefined and the media
1711
+ // request passes through to the local route.
1712
+ resolveRemoteFileScope: (filePath) => interceptHandle?.resolveRemoteFileScope(filePath),
1713
+ lang: resolveActiveLang,
1586
1714
  log: (format, ...args) => { logInfo(`dsh-zen-remote ${format}`, ...args); },
1587
1715
  });
1588
1716
  fetchRouteHandle = handle;
@@ -1650,7 +1778,9 @@ export function apply(ctx, config = {}) {
1650
1778
  }
1651
1779
  // Shape against the proxy first (fetchRoutes reads through it); the
1652
1780
  // raw instance comes off the same symbols slot the inject path uses.
1653
- const shape = checkFetchRouteShape(connection);
1781
+ // (The media note rides along — same install options as the inject
1782
+ // path, P55-2.)
1783
+ const shape = checkFetchRouteShape(connection, true);
1654
1784
  if (!shape.ok) {
1655
1785
  // Structural mismatch is a verdict, not a wait: record the attempt
1656
1786
  // and stop. A later inject trigger may still install (it checks the
@@ -1671,8 +1801,12 @@ export function apply(ctx, config = {}) {
1671
1801
  }
1672
1802
  const handle = installFetchRouteIntercept({
1673
1803
  connection: raw,
1674
- relay: relayClient,
1675
- getServerId: () => relayClient.handshakeInfo?.serverId,
1804
+ // MS-5 pool mode: each upload's serverId half picks its own client.
1805
+ getRelayForServer: (serverId) => relayPool.getByServerId(serverId)?.client,
1806
+ // P55-2: same live read as the inject path — the media route follows
1807
+ // whichever typert intercept is currently installed.
1808
+ resolveRemoteFileScope: (filePath) => interceptHandle?.resolveRemoteFileScope(filePath),
1809
+ lang: resolveActiveLang,
1676
1810
  log: (format, ...args) => { logInfo(`dsh-zen-remote ${format}`, ...args); },
1677
1811
  });
1678
1812
  fetchRouteHandle = handle;
@@ -1743,9 +1877,16 @@ export function apply(ctx, config = {}) {
1743
1877
  const handle = installPluginRouteIntercept({
1744
1878
  webServer: raw,
1745
1879
  registry,
1746
- relay: relayClient,
1747
- getServerId: () => relayClient.handshakeInfo?.serverId,
1748
- getStatus: pluginBridgeStatus,
1880
+ // MS-5 pool mode: validation, forwarding and response rewriting are
1881
+ // judged against the located id's OWN server — its relay client, its
1882
+ // bridge verdicts (its handshake's plugin table against the local
1883
+ // registry) and its own known-real-id inventory.
1884
+ getRelayForServer: (serverId) => relayPool.getByServerId(serverId)?.client,
1885
+ getStatusForServer: (serverId) => {
1886
+ const entry = relayPool.getByServerId(serverId);
1887
+ return entry !== undefined ? pluginBridgeStatusFor(entry.serverId, entry.client) : [];
1888
+ },
1889
+ getKnownRealIdsForServer: knownRealIdsForServer,
1749
1890
  adapters,
1750
1891
  // PB-12: the browser's currently open session — a VIRTUAL id means
1751
1892
  // a remote panel is the request's origin, the signal the adapter
@@ -1770,24 +1911,36 @@ export function apply(ctx, config = {}) {
1770
1911
  pluginRouteHandle = undefined;
1771
1912
  }, 'dsh-zen-remote: plugin route intercept');
1772
1913
  });
1914
+ // CLI-1: one options factory, two handler instances — the browser mount
1915
+ // below keeps its admit wall, and the CLI IPC delegate's instance (same
1916
+ // pool, same diagnostics getters, same everything but admit) lets the
1917
+ // local CLI drive THIS pool directly over the 0600 socket.
1918
+ const clientOptionsFor = (admit, probeCtx) => ({
1919
+ admit,
1920
+ getRowConfig: () => config,
1921
+ // MS-5 POOL mode: the paired list is the truth — claim pairs into
1922
+ // the pool (the token never reaches the browser), unpair/reconnect
1923
+ // address pool entries, and status / remote-status report every
1924
+ // server. The row still rides along for the non-connection knobs
1925
+ // (the debug report's config summary) — never for credentials.
1926
+ pool: relayPool,
1927
+ getPluginBridgeStatusForServer: pluginBridgeStatusFor,
1928
+ getIntercept: interceptDiagnostics,
1929
+ getFetchRouteIntercept: fetchRouteDiagnostics,
1930
+ getServiceProbe: uploadServiceProbe(probeCtx),
1931
+ getPluginRouteIntercept: pluginRouteDiagnostics,
1932
+ onCurrentSession,
1933
+ getLogSnapshot: () => ring.snapshot(),
1934
+ getProfileName: profileName,
1935
+ getPluginVersion: () => pluginVersion,
1936
+ lang: resolveActiveLang,
1937
+ });
1938
+ cliClientHandler = createClientHandler(clientOptionsFor(noopAdmit, ctx));
1773
1939
  ctx.inject(['webServer', 'connection'], (clientCtx) => {
1774
1940
  clientCtx.effect(() => clientCtx.webServer.register({
1775
1941
  kind: 'prefix',
1776
1942
  path: CLIENT_ROUTE_PREFIX,
1777
- handler: createClientHandler({
1778
- admit: (req) => clientCtx.connection.admit(req),
1779
- getRowConfig: () => config,
1780
- getRelayClient: () => relayClient,
1781
- getIntercept: interceptDiagnostics,
1782
- getFetchRouteIntercept: fetchRouteDiagnostics,
1783
- getServiceProbe: uploadServiceProbe(clientCtx),
1784
- getPluginRouteIntercept: pluginRouteDiagnostics,
1785
- getPluginBridgeStatus: pluginBridgeStatus,
1786
- onCurrentSession,
1787
- getLogSnapshot: () => ring.snapshot(),
1788
- getProfileName: profileName,
1789
- getPluginVersion: () => pluginVersion,
1790
- }),
1943
+ handler: createClientHandler(clientOptionsFor((req) => clientCtx.connection.admit(req), clientCtx)),
1791
1944
  }), 'dsh-zen-remote: client routes');
1792
1945
  });
1793
1946
  }
@@ -1839,12 +1992,60 @@ export function apply(ctx, config = {}) {
1839
1992
  // Registration failure degrades to a warning, like the session feeds
1840
1993
  // above: the poll trigger keeps the watcher fully functional without it.
1841
1994
  try {
1842
- ctx.on('loader/volatile-update', () => { watcher.checkNow(); });
1995
+ ctx.on('loader/volatile-update', (paths) => {
1996
+ if (Array.isArray(paths)) {
1997
+ const topKeys = paths
1998
+ .map((p) => (Array.isArray(p) && typeof p[0] === 'string' ? p[0] : undefined))
1999
+ .filter((k) => k !== undefined);
2000
+ if (topKeys.length > 0)
2001
+ clearUnshadowedRowKeys(home, topKeys);
2002
+ }
2003
+ watcher.checkNow();
2004
+ });
1843
2005
  }
1844
2006
  catch (error) {
1845
2007
  logWarn('dsh-zen-remote: cannot listen on "loader/volatile-update": %s', message(error));
1846
2008
  }
1847
- return watcher.stop;
2009
+ // CLI-1: hot-swap THIS row generation's delegate into the module-level
2010
+ // IPC server (acquire is synchronous — apply() runs inside cordis; the
2011
+ // first bind completes in the background and the CLI client retries
2012
+ // until then). writeCliConfig's onAfterWrite re-checks the fingerprint
2013
+ // IMMEDIATELY, so a CLI config write to a restart-required field
2014
+ // reloads the row without waiting out the 2s poll. Every role getter
2015
+ // re-resolves per call: ping answers the CURRENT effective role even in
2016
+ // the instant before a pending reload lands. The IPC lifecycle rides
2017
+ // this existing effect on purpose — role-wiring pins the top-level
2018
+ // effect counts, so it may not register its own.
2019
+ const delegate = {
2020
+ role: () => resolveConfig(config, readFileConfig(), process.env).values.role,
2021
+ serverName: () => resolveConfig(config, readFileConfig(), process.env).values.serverName,
2022
+ profileName,
2023
+ pluginVersion: () => pluginVersion,
2024
+ writeConfig: (input) => writeCliConfig({
2025
+ dshHome: home,
2026
+ row: config,
2027
+ env: process.env,
2028
+ set: input.set,
2029
+ unset: input.unset,
2030
+ onAfterWrite: () => { watcher.checkNow(); },
2031
+ }),
2032
+ wizardHandler: cliWizardHandler,
2033
+ adminHandler: cliAdminHandler,
2034
+ clientHandler: cliClientHandler,
2035
+ };
2036
+ const cliIpc = acquireCliIpcServer(home, delegate);
2037
+ return () => {
2038
+ // Release THIS row's hold (a concurrent reload's new acquire keeps
2039
+ // the socket alive — that is the coordinator's grace window). The
2040
+ // managed ingress children die with the row here too: on compositions
2041
+ // WITHOUT a webServer the wizard effect above never fires, so this is
2042
+ // the only stop they would ever get (with webServer both stop — stop
2043
+ // is idempotent, safe before any start).
2044
+ cliIpc.release();
2045
+ watcher.stop();
2046
+ for (const child of cliManagedChildren)
2047
+ child.stop();
2048
+ };
1848
2049
  }, 'dsh-zen-remote: restart watcher');
1849
2050
  // The upload route carries the T17 admission wall too: webServer routes
1850
2051
  // skip DSH's /api authentication, so without the connection service's