@openchambery/web 1.17.1 → 1.18.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 (130) hide show
  1. package/bin/lib/DOCUMENTATION.md +1 -0
  2. package/dist/assets/{AgentAvatar-C6Hxu5_o.js → AgentAvatar-B2kZjgaf.js} +1 -1
  3. package/dist/assets/{AssistantView-CbnxMZU0.js → AssistantView-Dc9MhgcD.js} +1 -1
  4. package/dist/assets/{ChatContainer-DEDGosbz.js → ChatContainer-CA3RO1yP.js} +40 -40
  5. package/dist/assets/{DiagramView-Byxhaduz.js → DiagramView-CpT4q2Jg.js} +1 -1
  6. package/dist/assets/{FadeInOnReveal-CvNA_aGD.js → FadeInOnReveal-aeWlaqVB.js} +1 -1
  7. package/dist/assets/{Icon-BPJ_quEe.js → Icon-AXqa-fYB.js} +1 -1
  8. package/dist/assets/{MarkdownRendererImpl-Cv8V-Q5l.js → MarkdownRendererImpl-DLnXrjCS.js} +2 -2
  9. package/dist/assets/{MobileDetailNavigation-lVtCe-5P.js → MobileDetailNavigation-BYX8jFRg.js} +1 -1
  10. package/dist/assets/MobileShareBridge-BJzGsvUa.js +4 -0
  11. package/dist/assets/MobileSurface-Cfe17dw4.js +2 -0
  12. package/dist/assets/{MultiRunWindow-1kkKTh4h.js → MultiRunWindow-DEYIMkSg.js} +1 -1
  13. package/dist/assets/{OnboardingScreen-CdvaEBtt.js → OnboardingScreen-CCfdEtW6.js} +1 -1
  14. package/dist/assets/{OverlayScrollbar-Dqk1zGJB.js → OverlayScrollbar-CpQvV-qY.js} +1 -1
  15. package/dist/assets/SettingsView-PjA5aLCR.js +25 -0
  16. package/dist/assets/{SettingsWindow-DMQMRgc3.js → SettingsWindow-DOe4sCjl.js} +1 -1
  17. package/dist/assets/{TerminalView-jYq0JQPc.js → TerminalView-Bf-2wMV0.js} +1 -1
  18. package/dist/assets/{ToolOutputDialog-DJefd2N6.js → ToolOutputDialog-DuT5ne12.js} +1 -1
  19. package/dist/assets/{appThemeRegistry-BXeIggYO.js → appThemeRegistry--wNZ5yxf.js} +2 -2
  20. package/dist/assets/{button-BmNJBT13.js → button-CDvMVaiO.js} +1 -1
  21. package/dist/assets/{clipboard-CXWbHCAk.js → clipboard-Jdqe05db.js} +1 -1
  22. package/dist/assets/{collapsible-68IqWBM1.js → collapsible-C2aamDvD.js} +1 -1
  23. package/dist/assets/{context-menu-BbRpMBQh.js → context-menu-CS_u7xxB.js} +1 -1
  24. package/dist/assets/desktopHosts-CBoBpJ1Z.js +1 -0
  25. package/dist/assets/desktopRelayRestore-l2R9muux.js +1 -0
  26. package/dist/assets/{diagram-YN0GZ1Q0.js → diagram-CEon0BQs.js} +1 -1
  27. package/dist/assets/{dialog-BV1ENlLf.js → dialog-BzZ3cKxG.js} +1 -1
  28. package/dist/assets/es-C25PRSjo.js +15 -0
  29. package/dist/assets/{fileQueries-i4oziMnC.js → fileQueries-CE_DaZWQ.js} +1 -1
  30. package/dist/assets/fr-Cv8ZWQdW.js +13 -0
  31. package/dist/assets/{gitApi-Bs_2iBAm.js → gitApi-DSLGdswL.js} +3 -3
  32. package/dist/assets/gitApiHttp-D8j1qRH4.js +2 -0
  33. package/dist/assets/{input-BLVhPvhN.js → input-k9DGGyZ6.js} +1 -1
  34. package/dist/assets/insertionBoundaries-BkoTIR-0.js +1 -0
  35. package/dist/assets/{ja-Dcb0VtDE.js → ja-Ue9m9eSe.js} +3 -3
  36. package/dist/assets/{ko-CX71xNDX.js → ko-DWBUCCV6.js} +3 -3
  37. package/dist/assets/main-CTnxV3y3.js +172 -0
  38. package/dist/assets/{main-CT0DuuLm.js → main-D1Xze0xA.js} +2 -2
  39. package/dist/assets/{markdown-worker-Cq3Twp1A.js → markdown-worker-DkeWiER6.js} +1 -1
  40. package/dist/assets/{markdownTheme-CCnvI6L0.js → markdownTheme-DNT93i5L.js} +1 -1
  41. package/dist/assets/miniChat-Dxajshg_.js +2 -0
  42. package/dist/assets/mobile-CisSv-ca.js +2 -0
  43. package/dist/assets/multirun-BOb2dW9T.js +4 -0
  44. package/dist/assets/persistence-DPNMIbF5.js +3 -0
  45. package/dist/assets/{pl-mR_3Sqr8.js → pl-DaBaPCyE.js} +3 -3
  46. package/dist/assets/projectMeta-WTDhB__y.js +3 -0
  47. package/dist/assets/pt-BR-C2cJZ43o.js +15 -0
  48. package/dist/assets/radio-DZwuHb7-.js +1 -0
  49. package/dist/assets/renderElectronMiniChatApp-DpYFpBSy.js +2 -0
  50. package/dist/assets/renderMobileApp-DB1TXtLg.js +23 -0
  51. package/dist/assets/runtimeConfig-BtWi74Pm.js +1 -0
  52. package/dist/assets/runtimeEndpointReset-BnA1DoNG.js +145 -0
  53. package/dist/assets/{sortable-tabs-strip-oKJ700WG.js → sortable-tabs-strip-DADoz-JR.js} +1 -1
  54. package/dist/assets/src-oHrYIqHF.css +2 -0
  55. package/dist/assets/{store-oojlrF4d.js → store-SqV3zfE8.js} +4 -4
  56. package/dist/assets/{terminalApi-BYTA045F.js → terminalApi-tB3qLcfa.js} +1 -1
  57. package/dist/assets/{tooltip-BYGBBTc2.js → tooltip-DMNR5rN0.js} +1 -1
  58. package/dist/assets/uk-DJz_P8OZ.js +15 -0
  59. package/dist/assets/{url-CGHw6D-E.js → url-7MqoLs9X.js} +1 -1
  60. package/dist/assets/useAppFontEffects-CeS0e0FK.js +7 -0
  61. package/dist/assets/{useAssistantUIStore-ESTV1mEY.js → useAssistantUIStore-Bq3cPj2x.js} +1 -1
  62. package/dist/assets/{useDesktopWindowControlsLayout-Cq5a2gcJ.js → useDesktopWindowControlsLayout-DwFMUFtC.js} +1 -1
  63. package/dist/assets/{useEffectiveDirectory-DG6uPzBG.js → useEffectiveDirectory-Dp8McufI.js} +1 -1
  64. package/dist/assets/{useFontPreferences-Bu4ME5hk.js → useFontPreferences-CIurCeNv.js} +1 -1
  65. package/dist/assets/useMobileNavigationStore-kWPnKBP1.js +475 -0
  66. package/dist/assets/useSessionAutoCleanup-BLiIZW0x.js +1 -0
  67. package/dist/assets/useUIStore-5agS8bsW.js +1 -0
  68. package/dist/assets/{useWorkerHighlightedLines-ADgu9578.js → useWorkerHighlightedLines-Cnxz7HaG.js} +1 -1
  69. package/dist/assets/{utils-DyUyiLje.js → utils-Co19EJEL.js} +1 -1
  70. package/dist/assets/zh-CN-DLAKDOM9.js +15 -0
  71. package/dist/assets/zh-TW-s3Jjx1_n.js +15 -0
  72. package/dist/index.html +9 -9
  73. package/dist/mini-chat.html +9 -9
  74. package/dist/mobile.html +9 -9
  75. package/package.json +1 -1
  76. package/server/index.d.ts +4 -0
  77. package/server/index.js +29 -15
  78. package/server/lib/client-auth/pairing.js +7 -1
  79. package/server/lib/client-auth/pairing.test.js +45 -0
  80. package/server/lib/desktop-hosts/routes.js +157 -0
  81. package/server/lib/desktop-hosts/routes.test.js +227 -0
  82. package/server/lib/message-queue/DOCUMENTATION.md +1 -1
  83. package/server/lib/message-queue/routes.js +1 -1
  84. package/server/lib/message-queue/routes.test.js +1 -0
  85. package/server/lib/message-queue/service.js +12 -1
  86. package/server/lib/message-queue/service.test.js +30 -0
  87. package/server/lib/opencode/DOCUMENTATION.md +4 -4
  88. package/server/lib/opencode/bootstrap-runtime.js +7 -0
  89. package/server/lib/opencode/cli-entry-runtime.js +6 -0
  90. package/server/lib/opencode/cli-entry-runtime.test.js +43 -0
  91. package/server/lib/opencode/core-routes.js +4 -1
  92. package/server/lib/opencode/core-routes.test.js +29 -0
  93. package/server/lib/opencode/openchamber-routes.js +12 -0
  94. package/server/lib/opencode/settings-runtime.js +1 -1
  95. package/server/lib/package-manager.js +3 -1
  96. package/server/lib/relay/DOCUMENTATION.md +44 -1
  97. package/server/lib/relay/host-client.js +4 -1
  98. package/server/lib/relay/service.js +16 -8
  99. package/server/lib/relay/service.test.js +21 -6
  100. package/server/lib/relay/tunnel-host.js +57 -4
  101. package/server/lib/relay/tunnel-host.test.js +250 -2
  102. package/dist/assets/MobileShareBridge-DAlvgjo1.js +0 -4
  103. package/dist/assets/MobileSurface-Bde8yLWo.js +0 -2
  104. package/dist/assets/SettingsView-D0dhTIur.js +0 -25
  105. package/dist/assets/desktopHosts-BpjUSDz4.js +0 -1
  106. package/dist/assets/desktopRelayRestore-BzUEk1i7.js +0 -1
  107. package/dist/assets/es-EXZUX1sB.js +0 -15
  108. package/dist/assets/fr-jWNPO91J.js +0 -13
  109. package/dist/assets/gitApiHttp-C1DaEMKR.js +0 -2
  110. package/dist/assets/insertionBoundaries-BAFtAotq.js +0 -1
  111. package/dist/assets/main-G8tKFcTV.js +0 -172
  112. package/dist/assets/miniChat-BP3OHqNF.js +0 -2
  113. package/dist/assets/mobile-BtwuDs-C.js +0 -2
  114. package/dist/assets/multirun-B-L3JH60.js +0 -4
  115. package/dist/assets/persistence-C_KCiMcY.js +0 -3
  116. package/dist/assets/projectMeta-Cp74cItv.js +0 -3
  117. package/dist/assets/pt-BR-D1RpVOvC.js +0 -15
  118. package/dist/assets/radio-Bs4mdPv5.js +0 -1
  119. package/dist/assets/renderElectronMiniChatApp-dgirEOtR.js +0 -2
  120. package/dist/assets/renderMobileApp-DK9qzy0P.js +0 -23
  121. package/dist/assets/runtimeConfig-JSaYFA5x.js +0 -1
  122. package/dist/assets/runtimeEndpointReset-C0eCWIE6.js +0 -145
  123. package/dist/assets/src-8zziWB78.css +0 -2
  124. package/dist/assets/uk-UvnVqWv-.js +0 -15
  125. package/dist/assets/useAppFontEffects-BhLcUfRB.js +0 -7
  126. package/dist/assets/useMobileNavigationStore-1xHjnAsg.js +0 -475
  127. package/dist/assets/useSessionAutoCleanup-BFletAeV.js +0 -1
  128. package/dist/assets/useUIStore-D6BTB4va.js +0 -1
  129. package/dist/assets/zh-CN-9v-RC966.js +0 -15
  130. package/dist/assets/zh-TW-BZ_wZdC-.js +0 -15
package/dist/index.html CHANGED
@@ -552,23 +552,23 @@
552
552
  opacity: 0.72;
553
553
  }
554
554
  </style>
555
- <script type="module" crossorigin src="/assets/main-CT0DuuLm.js"></script>
555
+ <script type="module" crossorigin src="/assets/main-D1Xze0xA.js"></script>
556
556
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-C0FnF6B9.js">
557
557
  <link rel="modulepreload" crossorigin href="/assets/vendor-base-ui-CnHfUmDN.js">
558
558
  <link rel="modulepreload" crossorigin href="/assets/vendor-zustand-B4dlGEM2.js">
559
- <link rel="modulepreload" crossorigin href="/assets/useUIStore-D6BTB4va.js">
559
+ <link rel="modulepreload" crossorigin href="/assets/useUIStore-5agS8bsW.js">
560
560
  <link rel="modulepreload" crossorigin href="/assets/vendor-pathe-vBmvQE8f.js">
561
561
  <link rel="modulepreload" crossorigin href="/assets/vendor-react-DLPElpoQ.js">
562
562
  <link rel="modulepreload" crossorigin href="/assets/vendor-tanstack-query-core-BWz0ose6.js">
563
- <link rel="modulepreload" crossorigin href="/assets/persistence-C_KCiMcY.js">
564
- <link rel="modulepreload" crossorigin href="/assets/desktopHosts-BpjUSDz4.js">
565
- <link rel="modulepreload" crossorigin href="/assets/desktopRelayRestore-BzUEk1i7.js">
563
+ <link rel="modulepreload" crossorigin href="/assets/persistence-DPNMIbF5.js">
564
+ <link rel="modulepreload" crossorigin href="/assets/desktopHosts-CBoBpJ1Z.js">
565
+ <link rel="modulepreload" crossorigin href="/assets/desktopRelayRestore-l2R9muux.js">
566
566
  <link rel="modulepreload" crossorigin href="/assets/vendor-opencode-sdk-C07HQypa.js">
567
567
  <link rel="modulepreload" crossorigin href="/assets/vendor-aparajita-capacitor-secure-storage-CjQdwfjS.js">
568
- <link rel="modulepreload" crossorigin href="/assets/gitApiHttp-C1DaEMKR.js">
569
- <link rel="modulepreload" crossorigin href="/assets/terminalApi-BYTA045F.js">
570
- <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-JSaYFA5x.js">
571
- <link rel="stylesheet" crossorigin href="/assets/src-8zziWB78.css">
568
+ <link rel="modulepreload" crossorigin href="/assets/gitApiHttp-D8j1qRH4.js">
569
+ <link rel="modulepreload" crossorigin href="/assets/terminalApi-tB3qLcfa.js">
570
+ <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-BtWi74Pm.js">
571
+ <link rel="stylesheet" crossorigin href="/assets/src-oHrYIqHF.css">
572
572
  </head>
573
573
  <body class="h-full bg-background text-foreground">
574
574
  <!-- Keep the startup layer outside #root. React replaces every child of
@@ -74,23 +74,23 @@
74
74
  }
75
75
  </style>
76
76
 
77
- <script type="module" crossorigin src="/assets/miniChat-BP3OHqNF.js"></script>
77
+ <script type="module" crossorigin src="/assets/miniChat-Dxajshg_.js"></script>
78
78
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-C0FnF6B9.js">
79
79
  <link rel="modulepreload" crossorigin href="/assets/vendor-base-ui-CnHfUmDN.js">
80
80
  <link rel="modulepreload" crossorigin href="/assets/vendor-zustand-B4dlGEM2.js">
81
- <link rel="modulepreload" crossorigin href="/assets/useUIStore-D6BTB4va.js">
81
+ <link rel="modulepreload" crossorigin href="/assets/useUIStore-5agS8bsW.js">
82
82
  <link rel="modulepreload" crossorigin href="/assets/vendor-pathe-vBmvQE8f.js">
83
83
  <link rel="modulepreload" crossorigin href="/assets/vendor-react-DLPElpoQ.js">
84
84
  <link rel="modulepreload" crossorigin href="/assets/vendor-tanstack-query-core-BWz0ose6.js">
85
- <link rel="modulepreload" crossorigin href="/assets/persistence-C_KCiMcY.js">
86
- <link rel="modulepreload" crossorigin href="/assets/desktopHosts-BpjUSDz4.js">
87
- <link rel="modulepreload" crossorigin href="/assets/desktopRelayRestore-BzUEk1i7.js">
85
+ <link rel="modulepreload" crossorigin href="/assets/persistence-DPNMIbF5.js">
86
+ <link rel="modulepreload" crossorigin href="/assets/desktopHosts-CBoBpJ1Z.js">
87
+ <link rel="modulepreload" crossorigin href="/assets/desktopRelayRestore-l2R9muux.js">
88
88
  <link rel="modulepreload" crossorigin href="/assets/vendor-opencode-sdk-C07HQypa.js">
89
89
  <link rel="modulepreload" crossorigin href="/assets/vendor-aparajita-capacitor-secure-storage-CjQdwfjS.js">
90
- <link rel="modulepreload" crossorigin href="/assets/gitApiHttp-C1DaEMKR.js">
91
- <link rel="modulepreload" crossorigin href="/assets/terminalApi-BYTA045F.js">
92
- <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-JSaYFA5x.js">
93
- <link rel="stylesheet" crossorigin href="/assets/src-8zziWB78.css">
90
+ <link rel="modulepreload" crossorigin href="/assets/gitApiHttp-D8j1qRH4.js">
91
+ <link rel="modulepreload" crossorigin href="/assets/terminalApi-tB3qLcfa.js">
92
+ <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-BtWi74Pm.js">
93
+ <link rel="stylesheet" crossorigin href="/assets/src-oHrYIqHF.css">
94
94
  </head>
95
95
  <body class="h-full bg-background text-foreground">
96
96
  <div id="root" class="h-full">
package/dist/mobile.html CHANGED
@@ -63,23 +63,23 @@
63
63
  })();
64
64
  </script>
65
65
 
66
- <script type="module" crossorigin src="/assets/mobile-BtwuDs-C.js"></script>
66
+ <script type="module" crossorigin src="/assets/mobile-CisSv-ca.js"></script>
67
67
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-C0FnF6B9.js">
68
68
  <link rel="modulepreload" crossorigin href="/assets/vendor-base-ui-CnHfUmDN.js">
69
69
  <link rel="modulepreload" crossorigin href="/assets/vendor-zustand-B4dlGEM2.js">
70
- <link rel="modulepreload" crossorigin href="/assets/useUIStore-D6BTB4va.js">
70
+ <link rel="modulepreload" crossorigin href="/assets/useUIStore-5agS8bsW.js">
71
71
  <link rel="modulepreload" crossorigin href="/assets/vendor-pathe-vBmvQE8f.js">
72
72
  <link rel="modulepreload" crossorigin href="/assets/vendor-react-DLPElpoQ.js">
73
73
  <link rel="modulepreload" crossorigin href="/assets/vendor-tanstack-query-core-BWz0ose6.js">
74
- <link rel="modulepreload" crossorigin href="/assets/persistence-C_KCiMcY.js">
75
- <link rel="modulepreload" crossorigin href="/assets/desktopHosts-BpjUSDz4.js">
76
- <link rel="modulepreload" crossorigin href="/assets/desktopRelayRestore-BzUEk1i7.js">
74
+ <link rel="modulepreload" crossorigin href="/assets/persistence-DPNMIbF5.js">
75
+ <link rel="modulepreload" crossorigin href="/assets/desktopHosts-CBoBpJ1Z.js">
76
+ <link rel="modulepreload" crossorigin href="/assets/desktopRelayRestore-l2R9muux.js">
77
77
  <link rel="modulepreload" crossorigin href="/assets/vendor-opencode-sdk-C07HQypa.js">
78
78
  <link rel="modulepreload" crossorigin href="/assets/vendor-aparajita-capacitor-secure-storage-CjQdwfjS.js">
79
- <link rel="modulepreload" crossorigin href="/assets/gitApiHttp-C1DaEMKR.js">
80
- <link rel="modulepreload" crossorigin href="/assets/terminalApi-BYTA045F.js">
81
- <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-JSaYFA5x.js">
82
- <link rel="stylesheet" crossorigin href="/assets/src-8zziWB78.css">
79
+ <link rel="modulepreload" crossorigin href="/assets/gitApiHttp-D8j1qRH4.js">
80
+ <link rel="modulepreload" crossorigin href="/assets/terminalApi-tB3qLcfa.js">
81
+ <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-BtWi74Pm.js">
82
+ <link rel="stylesheet" crossorigin href="/assets/src-oHrYIqHF.css">
83
83
  </head>
84
84
  <body class="h-full bg-background text-foreground">
85
85
  <div id="root" class="h-full"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openchambery/web",
3
- "version": "1.17.1",
3
+ "version": "1.18.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./server/index.js",
package/server/index.d.ts CHANGED
@@ -21,6 +21,10 @@ export interface StartWebUiServerOptions {
21
21
  transcriptCacheDbPath?: string | null;
22
22
  messageQueueDbPath?: string | null;
23
23
  messageQueueAttachmentRoot?: string | null;
24
+ /** Live SSH local-forward ports for relay target-port routing (Electron). */
25
+ getSshRoutingTable?: () => { id: string; localPort: number }[];
26
+ /** Mint a stored SSH host clientToken for a ready session (Electron). */
27
+ mintSshHostToken?: (hostId: string) => Promise<string>;
24
28
  }
25
29
 
26
30
  export declare function startWebUiServer(
package/server/index.js CHANGED
@@ -90,7 +90,7 @@ import { applyRuntimeCorsHeaders } from './lib/request-cors.js';
90
90
  import { createClientPairingRuntime } from './lib/client-auth/pairing.js';
91
91
  import { createPreviewProxyRuntime } from './lib/preview/proxy-runtime.js';
92
92
  import { attachRealtimeProxy } from './lib/realtime-proxy.js';
93
- import { createRelayService } from './lib/relay/service.js';
93
+ import { createRelayService, isDesktopRelayHostRuntime } from './lib/relay/service.js';
94
94
  import { createRelayHostLock } from './lib/relay/host-lock.js';
95
95
  import { createProxyMiddleware, responseInterceptor } from 'http-proxy-middleware';
96
96
  import webPush from 'web-push';
@@ -1184,7 +1184,9 @@ async function main(options = {}) {
1184
1184
  if (h && h !== '127.0.0.1' && h !== 'localhost' && h !== '::1') lanHost = effectiveBindHost;
1185
1185
  }
1186
1186
  const lan = lanHost ? `http://${lanHost.includes(':') ? `[${lanHost}]` : lanHost}:${activePort}` : null;
1187
- return { local, lan, relayAvailable: true };
1187
+ // Only the desktop host may advertise relay. Local `dev` / `web` / CLI
1188
+ // servers must not present "Anywhere" as available or trigger a probe.
1189
+ return { local, lan, relayAvailable: isDesktopRelayHostRuntime() };
1188
1190
  };
1189
1191
  // ALL direct LAN URLs this server is currently reachable on, for the
1190
1192
  // candidates-refresh endpoint: the address the requesting client already
@@ -1241,6 +1243,11 @@ async function main(options = {}) {
1241
1243
  const getDesktopRuntimeConfig = typeof options.getDesktopRuntimeConfig === 'function'
1242
1244
  ? options.getDesktopRuntimeConfig
1243
1245
  : null;
1246
+ // Electron injects live SSH local-forward ports; non-desktop defaults to empty
1247
+ // so relay target-port routing never falls back to an unvalidated port.
1248
+ const getSshRoutingTable = typeof options.getSshRoutingTable === 'function'
1249
+ ? options.getSshRoutingTable
1250
+ : () => [];
1244
1251
  const sessionIndexDbPath = options.sessionIndexDbPath !== undefined
1245
1252
  ? options.sessionIndexDbPath
1246
1253
  : process.env.OPENCHAMBER_SESSION_INDEX_DB_PATH;
@@ -1417,7 +1424,7 @@ async function main(options = {}) {
1417
1424
  reconcileRelay: () => (relayServiceInstance ? relayServiceInstance.reconcile() : Promise.resolve()),
1418
1425
  getPairingTransports: async (req) => {
1419
1426
  const transports = resolvePairingTransports(req);
1420
- if (!relayServiceInstance) return transports;
1427
+ if (!relayServiceInstance || !transports.relayAvailable) return transports;
1421
1428
  const relay = await relayServiceInstance.getStatus();
1422
1429
  return {
1423
1430
  ...transports,
@@ -1465,6 +1472,11 @@ async function main(options = {}) {
1465
1472
  sessionIndexService,
1466
1473
  sessionIndexSyncRuntime,
1467
1474
  transcriptCacheService,
1475
+ getSshRoutingTable,
1476
+ mintSshHostToken: typeof options.mintSshHostToken === 'function'
1477
+ ? options.mintSshHostToken
1478
+ : undefined,
1479
+ getPairingSession: (id) => clientPairingRuntime.getPairingSession(id),
1468
1480
  });
1469
1481
  uiAuthController = bootstrapResult.uiAuthController;
1470
1482
  realtimeProxyRuntime = attachRealtimeProxy({
@@ -1488,6 +1500,7 @@ async function main(options = {}) {
1488
1500
  readSettingsStrict: readSettingsFromDiskStrict,
1489
1501
  remoteClientAuthRuntime,
1490
1502
  getLocalPort: () => tunnelRuntimeContext.getActivePort(),
1503
+ getSshRoutingTable,
1491
1504
  // One relay host per machine: every instance sharing this data dir shares
1492
1505
  // the relay identity (serverId), so concurrent hosts evict each other at
1493
1506
  // the relay worker and devices land on a random local instance.
@@ -1626,18 +1639,19 @@ async function main(options = {}) {
1626
1639
  }
1627
1640
 
1628
1641
  // Only Electron (OPENCHAMBER_RUNTIME=desktop) opens a relay host-control
1629
- // socket. Other runtimes no-op reconcile. When allowed, demand-driven: run if
1630
- // any relay device/session exists, stop (and clear a stale enabled flag)
1631
- // otherwise.
1632
- void relayService.reconcile();
1633
-
1634
- // Relay demand can change outside our routes: pending sessions expire, or a
1635
- // paired device is revoked, without hitting our management routes. Poll so
1636
- // the desktop host picks demand up (or drops it) within a minute.
1637
- const relayReconcileTimer = setInterval(() => {
1642
+ // socket or polls relay demand. Local `dev` / `web` never reconcile, so they
1643
+ // cannot inherit a leftover desktop env and start probing the relay.
1644
+ let relayReconcileTimer = null;
1645
+ if (isDesktopRelayHostRuntime()) {
1638
1646
  void relayService.reconcile();
1639
- }, 60_000);
1640
- relayReconcileTimer.unref?.();
1647
+ // Relay demand can change outside our routes: pending sessions expire, or a
1648
+ // paired device is revoked, without hitting our management routes. Poll so
1649
+ // the desktop host picks demand up (or drops it) within a minute.
1650
+ relayReconcileTimer = setInterval(() => {
1651
+ void relayService.reconcile();
1652
+ }, 60_000);
1653
+ relayReconcileTimer.unref?.();
1654
+ }
1641
1655
 
1642
1656
  return {
1643
1657
  expressApp: app,
@@ -1683,7 +1697,7 @@ async function main(options = {}) {
1683
1697
  console.warn('[message-queue] Failed to close durable database during shutdown');
1684
1698
  }
1685
1699
  realtimeProxyRuntime.stop();
1686
- clearInterval(relayReconcileTimer);
1700
+ if (relayReconcileTimer) clearInterval(relayReconcileTimer);
1687
1701
  try {
1688
1702
  relayService.stop();
1689
1703
  } catch {
@@ -72,6 +72,9 @@ const publicSession = (session) => ({
72
72
  allowedClientKinds: session.allowedClientKinds,
73
73
  createdByClientId: session.createdByClientId,
74
74
  usesRelay: session.usesRelay === true,
75
+ // Optional SSH instance binding for mobile-over-relay host-token mint.
76
+ // Null when the pairing is for the desktop local origin (default).
77
+ sshHostId: session.sshHostId ?? null,
75
78
  });
76
79
 
77
80
  // A pending session is one that can still be redeemed: not used, not cancelled,
@@ -138,6 +141,7 @@ export const createClientPairingRuntime = ({
138
141
  allowedClientKinds: normalizeAllowedClientKinds(session.allowedClientKinds),
139
142
  createdByClientId: normalizeOptionalString(session.createdByClientId),
140
143
  usesRelay: session.usesRelay === true,
144
+ sshHostId: normalizeOptionalString(session.sshHostId),
141
145
  }))
142
146
  .filter((session) => session.secretHash.length > 0)
143
147
  : [],
@@ -176,7 +180,7 @@ export const createClientPairingRuntime = ({
176
180
  });
177
181
  };
178
182
 
179
- const createPairingSession = async ({ label, allowedClientKinds, createdByClientId, usesRelay } = {}) => {
183
+ const createPairingSession = async ({ label, allowedClientKinds, createdByClientId, usesRelay, sshHostId } = {}) => {
180
184
  return withStoreMutation(async () => {
181
185
  const store = await readStore();
182
186
  sweepExpiredSessionsFromStore(store);
@@ -194,6 +198,8 @@ export const createClientPairingRuntime = ({
194
198
  allowedClientKinds: normalizeAllowedClientKinds(allowedClientKinds),
195
199
  createdByClientId: normalizeOptionalString(createdByClientId),
196
200
  usesRelay: usesRelay === true,
201
+ // Binds later ssh-host-token mint to this pairing; not used by redeem.
202
+ sshHostId: normalizeOptionalString(sshHostId),
197
203
  };
198
204
  store.sessions.push(session);
199
205
  await writeStore(store);
@@ -139,4 +139,49 @@ describe('client auth pairing runtime', () => {
139
139
  expect(ids).not.toContain(expired.pairing.id);
140
140
  expect(ids).toHaveLength(1);
141
141
  });
142
+
143
+ it('persists optional sshHostId and exposes it on query after redeem', async () => {
144
+ const { dir, runtime } = await makeRuntime();
145
+ const created = await runtime.createPairingSession({
146
+ allowedClientKinds: ['mobile'],
147
+ sshHostId: 'ssh-remote-1',
148
+ });
149
+ expect(created.pairing.sshHostId).toBe('ssh-remote-1');
150
+
151
+ const pending = await runtime.listPendingSessions();
152
+ expect(pending).toEqual(expect.arrayContaining([
153
+ expect.objectContaining({ id: created.pairing.id, sshHostId: 'ssh-remote-1', usedAt: null }),
154
+ ]));
155
+
156
+ const lookedUp = await runtime.getPairingSession(created.pairing.id);
157
+ expect(lookedUp).toMatchObject({ id: created.pairing.id, sshHostId: 'ssh-remote-1', usedAt: null });
158
+
159
+ const storePath = path.join(dir, 'pairing.json');
160
+ const storeBefore = JSON.parse(await fs.readFile(storePath, 'utf8'));
161
+ expect(storeBefore.sessions.find((s) => s.id === created.pairing.id)?.sshHostId).toBe('ssh-remote-1');
162
+
163
+ const redeemed = await runtime.redeemPairingSession({
164
+ pairingId: created.pairing.id,
165
+ secret: created.pairing.secret,
166
+ clientKind: 'mobile',
167
+ });
168
+ // Redeem still returns the desktop client token; sshHostId is metadata only.
169
+ expect(redeemed.token).toBe('token-1');
170
+ expect(redeemed.pairing).toMatchObject({
171
+ id: created.pairing.id,
172
+ sshHostId: 'ssh-remote-1',
173
+ });
174
+ expect(redeemed.pairing.usedAt).toBeTruthy();
175
+
176
+ const after = await runtime.getPairingSession(created.pairing.id);
177
+ expect(after).toMatchObject({
178
+ id: created.pairing.id,
179
+ sshHostId: 'ssh-remote-1',
180
+ });
181
+ expect(after.usedAt).toBeTruthy();
182
+
183
+ // Default pairing has null sshHostId.
184
+ const plain = await runtime.createPairingSession();
185
+ expect(plain.pairing.sshHostId).toBeNull();
186
+ });
142
187
  });
@@ -0,0 +1,157 @@
1
+ // Desktop SSH host listing + short-lived token mint for mobile-over-relay.
2
+ // Protected by the global /api UI auth gate (registerAuthAndAccessRoutes).
3
+ // Never returns clientToken from the list endpoint; token is only issued via
4
+ // POST /ssh-host-token. Tokenless ready hosts may mint on demand.
5
+
6
+ /**
7
+ * @param {import('express').Express} app
8
+ * @param {{
9
+ * readSettingsFromDiskMigrated: () => Promise<object>,
10
+ * getSshRoutingTable?: () => { id: string, localPort: number }[],
11
+ * mintSshHostToken?: (hostId: string) => Promise<string>,
12
+ * getPairingSession?: (id: string) => Promise<{ id: string, sshHostId?: string | null, usedAt?: string | null } | null>,
13
+ * express?: typeof import('express'),
14
+ * logger?: Pick<Console, 'warn' | 'error'>,
15
+ * }} deps
16
+ */
17
+ export const registerDesktopHostRoutes = (app, deps) => {
18
+ const {
19
+ readSettingsFromDiskMigrated,
20
+ getSshRoutingTable = () => [],
21
+ mintSshHostToken,
22
+ getPairingSession,
23
+ express,
24
+ logger = console,
25
+ } = deps;
26
+
27
+ const readSshInstanceIds = (settings) => {
28
+ const instances = Array.isArray(settings?.desktopSshInstances) ? settings.desktopSshInstances : [];
29
+ const ids = new Set();
30
+ for (const entry of instances) {
31
+ const id = typeof entry?.id === 'string' ? entry.id.trim() : '';
32
+ if (id) ids.add(id);
33
+ }
34
+ return ids;
35
+ };
36
+
37
+ const routingPortById = () => {
38
+ const table = typeof getSshRoutingTable === 'function' ? getSshRoutingTable() : [];
39
+ /** @type {Map<string, number>} */
40
+ const map = new Map();
41
+ if (!Array.isArray(table)) return map;
42
+ for (const entry of table) {
43
+ const id = typeof entry?.id === 'string' ? entry.id.trim() : '';
44
+ const localPort = Number(entry?.localPort);
45
+ if (!id || !Number.isFinite(localPort)) continue;
46
+ map.set(id, localPort);
47
+ }
48
+ return map;
49
+ };
50
+
51
+ app.get('/api/openchamber/desktop-hosts', async (_req, res) => {
52
+ try {
53
+ const settings = await readSettingsFromDiskMigrated();
54
+ const sshIds = readSshInstanceIds(settings);
55
+ const instances = Array.isArray(settings?.desktopSshInstances) ? settings.desktopSshInstances : [];
56
+ const hostsRaw = Array.isArray(settings?.desktopHosts) ? settings.desktopHosts : [];
57
+ /** @type {Map<string, { label?: string }>} */
58
+ const hostById = new Map();
59
+ for (const entry of hostsRaw) {
60
+ const id = typeof entry?.id === 'string' ? entry.id.trim() : '';
61
+ if (id) hostById.set(id, entry);
62
+ }
63
+ const livePorts = routingPortById();
64
+
65
+ const hosts = [];
66
+ for (const instance of instances) {
67
+ const id = typeof instance?.id === 'string' ? instance.id.trim() : '';
68
+ if (!id || !sshIds.has(id)) continue;
69
+ const stored = hostById.get(id);
70
+ const nickname = typeof instance?.nickname === 'string' ? instance.nickname.trim() : '';
71
+ const labelFromHost = typeof stored?.label === 'string' ? stored.label.trim() : '';
72
+ const label = labelFromHost || nickname || id;
73
+ const livePort = livePorts.get(id);
74
+ const preferred = Number(instance?.localForward?.preferredLocalPort);
75
+ const localPort = Number.isFinite(livePort)
76
+ ? livePort
77
+ : (Number.isFinite(preferred) ? preferred : 0);
78
+ hosts.push({
79
+ id,
80
+ label,
81
+ localPort,
82
+ reachable: Number.isFinite(livePort),
83
+ });
84
+ }
85
+
86
+ res.json({ hosts });
87
+ } catch (error) {
88
+ console.error('Failed to list desktop hosts:', error);
89
+ res.status(500).json({
90
+ error: error instanceof Error ? error.message : 'Failed to list desktop hosts',
91
+ });
92
+ }
93
+ });
94
+
95
+ const jsonParser = express && typeof express.json === 'function'
96
+ ? express.json({ limit: '16kb' })
97
+ : (_req, _res, next) => next();
98
+
99
+ app.post('/api/openchamber/ssh-host-token', jsonParser, async (req, res) => {
100
+ try {
101
+ res.setHeader('Cache-Control', 'no-store');
102
+ const hostId = typeof req.body?.hostId === 'string' ? req.body.hostId.trim() : '';
103
+ if (!hostId) {
104
+ return res.status(404).json({ error: 'host_not_found' });
105
+ }
106
+
107
+ const settings = await readSettingsFromDiskMigrated();
108
+ const sshIds = readSshInstanceIds(settings);
109
+ if (!sshIds.has(hostId)) {
110
+ return res.status(404).json({ error: 'host_not_found' });
111
+ }
112
+
113
+ const pairingId = typeof req.body?.pairingId === 'string' ? req.body.pairingId.trim() : '';
114
+ if (pairingId) {
115
+ // Bound mint: pairing must exist, target this host, and already be redeemed.
116
+ // Redeem itself still issues the desktop token; this gate only authorizes
117
+ // reading the SSH instance's stored clientToken.
118
+ const session = typeof getPairingSession === 'function'
119
+ ? await getPairingSession(pairingId)
120
+ : null;
121
+ const sessionHostId = typeof session?.sshHostId === 'string' ? session.sshHostId.trim() : '';
122
+ const redeemed = Boolean(session?.usedAt);
123
+ if (!session || sessionHostId !== hostId || !redeemed) {
124
+ return res.status(403).json({ error: 'pairing-mismatch' });
125
+ }
126
+ } else {
127
+ // Legacy unscoped mint — any authenticated bearer. Remove next cycle.
128
+ logger.warn?.(
129
+ '[desktop-hosts] POST /api/openchamber/ssh-host-token without pairingId is deprecated; bind mint to a redeemed pairing session',
130
+ );
131
+ }
132
+
133
+ const hostsRaw = Array.isArray(settings?.desktopHosts) ? settings.desktopHosts : [];
134
+ const host = hostsRaw.find((entry) => typeof entry?.id === 'string' && entry.id.trim() === hostId);
135
+ let token = typeof host?.clientToken === 'string' ? host.clientToken.trim() : '';
136
+ if (!token && typeof mintSshHostToken === 'function') {
137
+ token = String(await mintSshHostToken(hostId) || '').trim();
138
+ }
139
+ if (!token) {
140
+ return res.status(404).json({ error: 'host_not_found' });
141
+ }
142
+
143
+ const livePort = routingPortById().get(hostId);
144
+ const reachable = Number.isFinite(livePort);
145
+ res.json({
146
+ token,
147
+ localPort: reachable ? livePort : null,
148
+ reachable,
149
+ });
150
+ } catch (error) {
151
+ logger.error?.('Failed to issue ssh host token:', error);
152
+ res.status(500).json({
153
+ error: error instanceof Error ? error.message : 'Failed to issue ssh host token',
154
+ });
155
+ }
156
+ });
157
+ };