@openchambery/web 1.17.1-beta.11 → 1.17.1-beta.13

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 (133) hide show
  1. package/dist/assets/{AgentAvatar-C6Hxu5_o.js → AgentAvatar-Coq-zdWI.js} +1 -1
  2. package/dist/assets/AssistantView-BXLxZXL5.js +1 -0
  3. package/dist/assets/{ChatContainer-CCyV53_o.js → ChatContainer-BJGlptbG.js} +22 -22
  4. package/dist/assets/DiagramView-Czx_DVw0.js +1 -0
  5. package/dist/assets/{FadeInOnReveal-CvNA_aGD.js → FadeInOnReveal-1KpYuooP.js} +1 -1
  6. package/dist/assets/{Icon-BPJ_quEe.js → Icon-Vc-aDche.js} +1 -1
  7. package/dist/assets/{MarkdownRendererImpl-DswfuLC-.js → MarkdownRendererImpl-B9Bv95Vd.js} +2 -2
  8. package/dist/assets/MobileDetailNavigation-RupyXzPu.js +1 -0
  9. package/dist/assets/MobileShareBridge-LVT1_q5C.js +4 -0
  10. package/dist/assets/MobileSurface-BVwSEqn4.js +2 -0
  11. package/dist/assets/MultiRunWindow-C7hbEpv9.js +1 -0
  12. package/dist/assets/OnboardingScreen-CrekVj31.js +1 -0
  13. package/dist/assets/OverlayScrollbar-vZXoSFA9.js +1 -0
  14. package/dist/assets/SettingsView-C1sYcHr1.js +25 -0
  15. package/dist/assets/SettingsWindow-MtcqpoiN.js +1 -0
  16. package/dist/assets/TerminalView-DXQZlHNA.js +8 -0
  17. package/dist/assets/{ToolOutputDialog-IVZYEtTZ.js → ToolOutputDialog-_Qzdo46T.js} +1 -1
  18. package/dist/assets/appThemeRegistry-Chv6_pxq.js +2 -0
  19. package/dist/assets/{button-BmNJBT13.js → button-rDAIP5kY.js} +1 -1
  20. package/dist/assets/{collapsible-68IqWBM1.js → collapsible-nLeZm1kw.js} +1 -1
  21. package/dist/assets/{context-menu-m2Ep287d.js → context-menu-DeJdA9F6.js} +1 -1
  22. package/dist/assets/desktopHosts-RE7NIkeq.js +1 -0
  23. package/dist/assets/desktopRelayRestore-BzyaU7-i.js +1 -0
  24. package/dist/assets/{diagram-YN0GZ1Q0.js → diagram-C7qK82ZL.js} +1 -1
  25. package/dist/assets/dialog-DlUK6mj-.js +1 -0
  26. package/dist/assets/es-DMX-Uo1i.js +15 -0
  27. package/dist/assets/{fileQueries-i4oziMnC.js → fileQueries-D8m3PQr0.js} +1 -1
  28. package/dist/assets/{fr-jWNPO91J.js → fr-CSFYzSd8.js} +2 -2
  29. package/dist/assets/{gitApi-CwRDHIOl.js → gitApi-DV6F5KKI.js} +1 -1
  30. package/dist/assets/gitApiHttp-BswZXfrf.js +2 -0
  31. package/dist/assets/i18n-BAqcSln8.js +1 -0
  32. package/dist/assets/{input-BLVhPvhN.js → input-CxulMZ9j.js} +1 -1
  33. package/dist/assets/insertionBoundaries-7-TvMfgn.js +1 -0
  34. package/dist/assets/{ja-Dcb0VtDE.js → ja-C3eCimlB.js} +2 -2
  35. package/dist/assets/{ko-CX71xNDX.js → ko-gnciIU9a.js} +2 -2
  36. package/dist/assets/main-CADHVH9Y.js +172 -0
  37. package/dist/assets/main-q1V-_wWx.js +2 -0
  38. package/dist/assets/{markdown-worker-Cq3Twp1A.js → markdown-worker-DmBYud1m.js} +1 -1
  39. package/dist/assets/{markdownTheme-CCnvI6L0.js → markdownTheme-CNJf24L-.js} +1 -1
  40. package/dist/assets/miniChat-VXuyA8mS.js +2 -0
  41. package/dist/assets/mobile-CaKZM4y3.js +2 -0
  42. package/dist/assets/multirun-QqMnIbXa.js +4 -0
  43. package/dist/assets/persistence-CFtygeYy.js +3 -0
  44. package/dist/assets/{pl-mR_3Sqr8.js → pl-CNk6BfNS.js} +1 -1
  45. package/dist/assets/projectMeta-WJhNyk90.js +3 -0
  46. package/dist/assets/pt-BR-D4G-0rLj.js +15 -0
  47. package/dist/assets/radio-C1riLnF0.js +1 -0
  48. package/dist/assets/renderElectronMiniChatApp-CnmMV1o9.js +2 -0
  49. package/dist/assets/renderMobileApp-Bhe41b2q.js +23 -0
  50. package/dist/assets/runtimeConfig-D4ETkuTj.js +1 -0
  51. package/dist/assets/runtimeEndpointReset-GtJt8YhT.js +145 -0
  52. package/dist/assets/sortable-tabs-strip-Fiq5nrkF.js +1 -0
  53. package/dist/assets/src-DrgsP0XF.css +2 -0
  54. package/dist/assets/{store-oojlrF4d.js → store-CS6l6pH7.js} +3 -3
  55. package/dist/assets/{terminalApi-BYTA045F.js → terminalApi-MQ285A_s.js} +1 -1
  56. package/dist/assets/{tooltip-BYGBBTc2.js → tooltip-CbxKYhKv.js} +1 -1
  57. package/dist/assets/ui-D5j3JxkE.js +475 -0
  58. package/dist/assets/uk-BUry1gqt.js +15 -0
  59. package/dist/assets/{url-CGHw6D-E.js → url-BSalv2ov.js} +1 -1
  60. package/dist/assets/useAppFontEffects-CaGwli-K.js +7 -0
  61. package/dist/assets/{useAssistantUIStore-ESTV1mEY.js → useAssistantUIStore-DGtjM827.js} +1 -1
  62. package/dist/assets/useDesktopWindowControlsLayout-DCZXksMp.js +1 -0
  63. package/dist/assets/useEffectiveDirectory-CssNtlqg.js +1 -0
  64. package/dist/assets/{useFontPreferences-Bu4ME5hk.js → useFontPreferences-C7FEHFns.js} +1 -1
  65. package/dist/assets/useSessionAutoCleanup-B_mSwN7n.js +1 -0
  66. package/dist/assets/useUIStore-WQV8IuFw.js +1 -0
  67. package/dist/assets/{useWorkerHighlightedLines-BGHFpbvZ.js → useWorkerHighlightedLines-C74ZKDtw.js} +1 -1
  68. package/dist/assets/{utils-DyUyiLje.js → utils-Cwlw2GM0.js} +1 -1
  69. package/dist/assets/zh-CN-CJJZzHVW.js +15 -0
  70. package/dist/assets/zh-TW-DxhIXjND.js +15 -0
  71. package/dist/index.html +9 -9
  72. package/dist/mini-chat.html +9 -9
  73. package/dist/mobile.html +9 -9
  74. package/package.json +1 -1
  75. package/server/index.d.ts +2 -0
  76. package/server/index.js +8 -0
  77. package/server/lib/client-auth/pairing.js +7 -1
  78. package/server/lib/client-auth/pairing.test.js +45 -0
  79. package/server/lib/desktop-hosts/routes.js +152 -0
  80. package/server/lib/desktop-hosts/routes.test.js +194 -0
  81. package/server/lib/opencode/bootstrap-runtime.js +5 -0
  82. package/server/lib/opencode/core-routes.js +3 -0
  83. package/server/lib/opencode/core-routes.test.js +15 -0
  84. package/server/lib/opencode/openchamber-routes.js +10 -0
  85. package/server/lib/package-manager.js +3 -1
  86. package/server/lib/relay/DOCUMENTATION.md +43 -0
  87. package/server/lib/relay/host-client.js +4 -1
  88. package/server/lib/relay/service.js +4 -0
  89. package/server/lib/relay/tunnel-host.js +57 -4
  90. package/server/lib/relay/tunnel-host.test.js +250 -2
  91. package/dist/assets/AssistantView-CCYDEoHv.js +0 -1
  92. package/dist/assets/DiagramView-ClKA8FV3.js +0 -1
  93. package/dist/assets/MobileDetailNavigation-DLoODj_O.js +0 -1
  94. package/dist/assets/MobileShareBridge-CAc7AFue.js +0 -4
  95. package/dist/assets/MobileSurface-76JUp8KU.js +0 -2
  96. package/dist/assets/MultiRunWindow-DkKQaIfX.js +0 -1
  97. package/dist/assets/OnboardingScreen-CdvaEBtt.js +0 -1
  98. package/dist/assets/OverlayScrollbar-B-K9NJfL.js +0 -1
  99. package/dist/assets/SettingsView-zu_QFVkf.js +0 -25
  100. package/dist/assets/SettingsWindow-D2q5mZqr.js +0 -1
  101. package/dist/assets/TerminalView-BSkAxeUb.js +0 -8
  102. package/dist/assets/appThemeRegistry-b9Hk2IDb.js +0 -2
  103. package/dist/assets/clipboard-CXWbHCAk.js +0 -1
  104. package/dist/assets/desktopHosts-BpjUSDz4.js +0 -1
  105. package/dist/assets/desktopRelayRestore-BzUEk1i7.js +0 -1
  106. package/dist/assets/dialog-BV1ENlLf.js +0 -1
  107. package/dist/assets/es-EXZUX1sB.js +0 -15
  108. package/dist/assets/gitApiHttp-C1DaEMKR.js +0 -2
  109. package/dist/assets/insertionBoundaries-hP0Vo_9C.js +0 -1
  110. package/dist/assets/main-BLqbjxPt.js +0 -2
  111. package/dist/assets/main-C7LjTesY.js +0 -172
  112. package/dist/assets/miniChat-Cr9SuyZk.js +0 -2
  113. package/dist/assets/mobile-Bp-qiKtn.js +0 -2
  114. package/dist/assets/multirun-_rwgORX-.js +0 -4
  115. package/dist/assets/persistence-C_KCiMcY.js +0 -3
  116. package/dist/assets/projectMeta-7TXLLSNB.js +0 -3
  117. package/dist/assets/pt-BR-D1RpVOvC.js +0 -15
  118. package/dist/assets/radio-CDEqI-yy.js +0 -1
  119. package/dist/assets/renderElectronMiniChatApp-ChsEWTbg.js +0 -2
  120. package/dist/assets/renderMobileApp-B1pX9NK3.js +0 -23
  121. package/dist/assets/runtimeConfig-JSaYFA5x.js +0 -1
  122. package/dist/assets/runtimeEndpointReset-BS3Sd-xn.js +0 -145
  123. package/dist/assets/sortable-tabs-strip-DUk_Td2z.js +0 -1
  124. package/dist/assets/src-8zziWB78.css +0 -2
  125. package/dist/assets/uk-UvnVqWv-.js +0 -15
  126. package/dist/assets/useAppFontEffects-VKLeTcCY.js +0 -7
  127. package/dist/assets/useDesktopWindowControlsLayout-DLuCJzOs.js +0 -1
  128. package/dist/assets/useEffectiveDirectory-Bt1X5uGs.js +0 -1
  129. package/dist/assets/useMobileNavigationStore-DRbvzR8W.js +0 -475
  130. package/dist/assets/useSessionAutoCleanup-C8clHymf.js +0 -1
  131. package/dist/assets/useUIStore-D6BTB4va.js +0 -1
  132. package/dist/assets/zh-CN-9v-RC966.js +0 -15
  133. 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-BLqbjxPt.js"></script>
555
+ <script type="module" crossorigin src="/assets/main-q1V-_wWx.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-WQV8IuFw.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-CFtygeYy.js">
564
+ <link rel="modulepreload" crossorigin href="/assets/desktopHosts-RE7NIkeq.js">
565
+ <link rel="modulepreload" crossorigin href="/assets/desktopRelayRestore-BzyaU7-i.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-BswZXfrf.js">
569
+ <link rel="modulepreload" crossorigin href="/assets/terminalApi-MQ285A_s.js">
570
+ <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-D4ETkuTj.js">
571
+ <link rel="stylesheet" crossorigin href="/assets/src-DrgsP0XF.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-Cr9SuyZk.js"></script>
77
+ <script type="module" crossorigin src="/assets/miniChat-VXuyA8mS.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-WQV8IuFw.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-CFtygeYy.js">
86
+ <link rel="modulepreload" crossorigin href="/assets/desktopHosts-RE7NIkeq.js">
87
+ <link rel="modulepreload" crossorigin href="/assets/desktopRelayRestore-BzyaU7-i.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-BswZXfrf.js">
91
+ <link rel="modulepreload" crossorigin href="/assets/terminalApi-MQ285A_s.js">
92
+ <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-D4ETkuTj.js">
93
+ <link rel="stylesheet" crossorigin href="/assets/src-DrgsP0XF.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-Bp-qiKtn.js"></script>
66
+ <script type="module" crossorigin src="/assets/mobile-CaKZM4y3.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-WQV8IuFw.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-CFtygeYy.js">
75
+ <link rel="modulepreload" crossorigin href="/assets/desktopHosts-RE7NIkeq.js">
76
+ <link rel="modulepreload" crossorigin href="/assets/desktopRelayRestore-BzyaU7-i.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-BswZXfrf.js">
80
+ <link rel="modulepreload" crossorigin href="/assets/terminalApi-MQ285A_s.js">
81
+ <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-D4ETkuTj.js">
82
+ <link rel="stylesheet" crossorigin href="/assets/src-DrgsP0XF.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-beta.11",
3
+ "version": "1.17.1-beta.13",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./server/index.js",
package/server/index.d.ts CHANGED
@@ -21,6 +21,8 @@ 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 }[];
24
26
  }
25
27
 
26
28
  export declare function startWebUiServer(
package/server/index.js CHANGED
@@ -1241,6 +1241,11 @@ async function main(options = {}) {
1241
1241
  const getDesktopRuntimeConfig = typeof options.getDesktopRuntimeConfig === 'function'
1242
1242
  ? options.getDesktopRuntimeConfig
1243
1243
  : null;
1244
+ // Electron injects live SSH local-forward ports; non-desktop defaults to empty
1245
+ // so relay target-port routing never falls back to an unvalidated port.
1246
+ const getSshRoutingTable = typeof options.getSshRoutingTable === 'function'
1247
+ ? options.getSshRoutingTable
1248
+ : () => [];
1244
1249
  const sessionIndexDbPath = options.sessionIndexDbPath !== undefined
1245
1250
  ? options.sessionIndexDbPath
1246
1251
  : process.env.OPENCHAMBER_SESSION_INDEX_DB_PATH;
@@ -1465,6 +1470,8 @@ async function main(options = {}) {
1465
1470
  sessionIndexService,
1466
1471
  sessionIndexSyncRuntime,
1467
1472
  transcriptCacheService,
1473
+ getSshRoutingTable,
1474
+ getPairingSession: (id) => clientPairingRuntime.getPairingSession(id),
1468
1475
  });
1469
1476
  uiAuthController = bootstrapResult.uiAuthController;
1470
1477
  realtimeProxyRuntime = attachRealtimeProxy({
@@ -1488,6 +1495,7 @@ async function main(options = {}) {
1488
1495
  readSettingsStrict: readSettingsFromDiskStrict,
1489
1496
  remoteClientAuthRuntime,
1490
1497
  getLocalPort: () => tunnelRuntimeContext.getActivePort(),
1498
+ getSshRoutingTable,
1491
1499
  // One relay host per machine: every instance sharing this data dir shares
1492
1500
  // the relay identity (serverId), so concurrent hosts evict each other at
1493
1501
  // the relay worker and devices land on a random local instance.
@@ -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,152 @@
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 for SSH instance hosts that already store one.
5
+
6
+ /**
7
+ * @param {import('express').Express} app
8
+ * @param {{
9
+ * readSettingsFromDiskMigrated: () => Promise<object>,
10
+ * getSshRoutingTable?: () => { id: string, localPort: number }[],
11
+ * getPairingSession?: (id: string) => Promise<{ id: string, sshHostId?: string | null, usedAt?: string | null } | null>,
12
+ * express?: typeof import('express'),
13
+ * logger?: Pick<Console, 'warn' | 'error'>,
14
+ * }} deps
15
+ */
16
+ export const registerDesktopHostRoutes = (app, deps) => {
17
+ const {
18
+ readSettingsFromDiskMigrated,
19
+ getSshRoutingTable = () => [],
20
+ getPairingSession,
21
+ express,
22
+ logger = console,
23
+ } = deps;
24
+
25
+ const readSshInstanceIds = (settings) => {
26
+ const instances = Array.isArray(settings?.desktopSshInstances) ? settings.desktopSshInstances : [];
27
+ const ids = new Set();
28
+ for (const entry of instances) {
29
+ const id = typeof entry?.id === 'string' ? entry.id.trim() : '';
30
+ if (id) ids.add(id);
31
+ }
32
+ return ids;
33
+ };
34
+
35
+ const routingPortById = () => {
36
+ const table = typeof getSshRoutingTable === 'function' ? getSshRoutingTable() : [];
37
+ /** @type {Map<string, number>} */
38
+ const map = new Map();
39
+ if (!Array.isArray(table)) return map;
40
+ for (const entry of table) {
41
+ const id = typeof entry?.id === 'string' ? entry.id.trim() : '';
42
+ const localPort = Number(entry?.localPort);
43
+ if (!id || !Number.isFinite(localPort)) continue;
44
+ map.set(id, localPort);
45
+ }
46
+ return map;
47
+ };
48
+
49
+ app.get('/api/openchamber/desktop-hosts', async (_req, res) => {
50
+ try {
51
+ const settings = await readSettingsFromDiskMigrated();
52
+ const sshIds = readSshInstanceIds(settings);
53
+ const instances = Array.isArray(settings?.desktopSshInstances) ? settings.desktopSshInstances : [];
54
+ const hostsRaw = Array.isArray(settings?.desktopHosts) ? settings.desktopHosts : [];
55
+ /** @type {Map<string, { label?: string }>} */
56
+ const hostById = new Map();
57
+ for (const entry of hostsRaw) {
58
+ const id = typeof entry?.id === 'string' ? entry.id.trim() : '';
59
+ if (id) hostById.set(id, entry);
60
+ }
61
+ const livePorts = routingPortById();
62
+
63
+ const hosts = [];
64
+ for (const instance of instances) {
65
+ const id = typeof instance?.id === 'string' ? instance.id.trim() : '';
66
+ if (!id || !sshIds.has(id)) continue;
67
+ const stored = hostById.get(id);
68
+ const nickname = typeof instance?.nickname === 'string' ? instance.nickname.trim() : '';
69
+ const labelFromHost = typeof stored?.label === 'string' ? stored.label.trim() : '';
70
+ const label = labelFromHost || nickname || id;
71
+ const livePort = livePorts.get(id);
72
+ const preferred = Number(instance?.localForward?.preferredLocalPort);
73
+ const localPort = Number.isFinite(livePort)
74
+ ? livePort
75
+ : (Number.isFinite(preferred) ? preferred : 0);
76
+ hosts.push({
77
+ id,
78
+ label,
79
+ localPort,
80
+ reachable: Number.isFinite(livePort),
81
+ });
82
+ }
83
+
84
+ res.json({ hosts });
85
+ } catch (error) {
86
+ console.error('Failed to list desktop hosts:', error);
87
+ res.status(500).json({
88
+ error: error instanceof Error ? error.message : 'Failed to list desktop hosts',
89
+ });
90
+ }
91
+ });
92
+
93
+ const jsonParser = express && typeof express.json === 'function'
94
+ ? express.json({ limit: '16kb' })
95
+ : (_req, _res, next) => next();
96
+
97
+ app.post('/api/openchamber/ssh-host-token', jsonParser, async (req, res) => {
98
+ try {
99
+ res.setHeader('Cache-Control', 'no-store');
100
+ const hostId = typeof req.body?.hostId === 'string' ? req.body.hostId.trim() : '';
101
+ if (!hostId) {
102
+ return res.status(404).json({ error: 'host_not_found' });
103
+ }
104
+
105
+ const settings = await readSettingsFromDiskMigrated();
106
+ const sshIds = readSshInstanceIds(settings);
107
+ if (!sshIds.has(hostId)) {
108
+ return res.status(404).json({ error: 'host_not_found' });
109
+ }
110
+
111
+ const pairingId = typeof req.body?.pairingId === 'string' ? req.body.pairingId.trim() : '';
112
+ if (pairingId) {
113
+ // Bound mint: pairing must exist, target this host, and already be redeemed.
114
+ // Redeem itself still issues the desktop token; this gate only authorizes
115
+ // reading the SSH instance's stored clientToken.
116
+ const session = typeof getPairingSession === 'function'
117
+ ? await getPairingSession(pairingId)
118
+ : null;
119
+ const sessionHostId = typeof session?.sshHostId === 'string' ? session.sshHostId.trim() : '';
120
+ const redeemed = Boolean(session?.usedAt);
121
+ if (!session || sessionHostId !== hostId || !redeemed) {
122
+ return res.status(403).json({ error: 'pairing-mismatch' });
123
+ }
124
+ } else {
125
+ // Legacy unscoped mint — any authenticated bearer. Remove next cycle.
126
+ logger.warn?.(
127
+ '[desktop-hosts] POST /api/openchamber/ssh-host-token without pairingId is deprecated; bind mint to a redeemed pairing session',
128
+ );
129
+ }
130
+
131
+ const hostsRaw = Array.isArray(settings?.desktopHosts) ? settings.desktopHosts : [];
132
+ const host = hostsRaw.find((entry) => typeof entry?.id === 'string' && entry.id.trim() === hostId);
133
+ const token = typeof host?.clientToken === 'string' ? host.clientToken.trim() : '';
134
+ if (!token) {
135
+ return res.status(404).json({ error: 'host_not_found' });
136
+ }
137
+
138
+ const livePort = routingPortById().get(hostId);
139
+ const reachable = Number.isFinite(livePort);
140
+ res.json({
141
+ token,
142
+ localPort: reachable ? livePort : null,
143
+ reachable,
144
+ });
145
+ } catch (error) {
146
+ logger.error?.('Failed to issue ssh host token:', error);
147
+ res.status(500).json({
148
+ error: error instanceof Error ? error.message : 'Failed to issue ssh host token',
149
+ });
150
+ }
151
+ });
152
+ };
@@ -0,0 +1,194 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import express from 'express';
3
+ import request from 'supertest';
4
+
5
+ import { registerDesktopHostRoutes } from './routes.js';
6
+
7
+ const baseSettings = {
8
+ desktopSshInstances: [{ id: 'ssh-1' }, { id: 'ssh-2' }],
9
+ desktopHosts: [
10
+ { id: 'ssh-1', clientToken: 'ssh-token-1' },
11
+ { id: 'ssh-2', clientToken: 'ssh-token-2' },
12
+ { id: 'lan-other', clientToken: 'other-token' },
13
+ ],
14
+ };
15
+
16
+ const createApp = ({
17
+ settings = baseSettings,
18
+ getSshRoutingTable = () => [],
19
+ getPairingSession,
20
+ logger = { warn: vi.fn(), error: vi.fn() },
21
+ } = {}) => {
22
+ const app = express();
23
+ registerDesktopHostRoutes(app, {
24
+ express,
25
+ readSettingsFromDiskMigrated: async () => settings,
26
+ getSshRoutingTable,
27
+ getPairingSession,
28
+ logger,
29
+ });
30
+ return { app, logger };
31
+ };
32
+
33
+ describe('desktop host routes', () => {
34
+ it('lists SSH hosts with reachable from the routing table and never returns clientToken', async () => {
35
+ const { app } = createApp({
36
+ settings: {
37
+ desktopSshInstances: [
38
+ { id: 'ssh-1', nickname: 'Remote A', localForward: { preferredLocalPort: 41001 } },
39
+ { id: 'ssh-2', nickname: 'Remote B', localForward: { preferredLocalPort: 41002 } },
40
+ ],
41
+ desktopHosts: [
42
+ { id: 'ssh-1', label: 'Remote A', clientToken: 'secret-a' },
43
+ { id: 'ssh-2', label: 'Remote B', clientToken: 'secret-b' },
44
+ { id: 'lan-other', label: 'Other', clientToken: 'secret-other' },
45
+ ],
46
+ },
47
+ getSshRoutingTable: () => [{ id: 'ssh-1', localPort: 41234 }],
48
+ });
49
+
50
+ const res = await request(app).get('/api/openchamber/desktop-hosts');
51
+ expect(res.status).toBe(200);
52
+ expect(res.body).toEqual({
53
+ hosts: [
54
+ { id: 'ssh-1', label: 'Remote A', localPort: 41234, reachable: true },
55
+ { id: 'ssh-2', label: 'Remote B', localPort: 41002, reachable: false },
56
+ ],
57
+ });
58
+ expect(JSON.stringify(res.body)).not.toContain('secret');
59
+ expect(JSON.stringify(res.body)).not.toContain('clientToken');
60
+ });
61
+
62
+ it('issues token with localPort/reachable when pairingId matches a redeemed session', async () => {
63
+ const getPairingSession = vi.fn(async (id) => {
64
+ if (id !== 'pair_ok') return null;
65
+ return { id: 'pair_ok', sshHostId: 'ssh-1', usedAt: '2026-01-01T00:00:00.000Z' };
66
+ });
67
+ const { app, logger } = createApp({
68
+ getSshRoutingTable: () => [{ id: 'ssh-1', localPort: 41234 }],
69
+ getPairingSession,
70
+ });
71
+
72
+ const ok = await request(app)
73
+ .post('/api/openchamber/ssh-host-token')
74
+ .send({ hostId: 'ssh-1', pairingId: 'pair_ok' });
75
+ expect(ok.status).toBe(200);
76
+ expect(ok.headers['cache-control']).toBe('no-store');
77
+ expect(ok.body).toEqual({
78
+ token: 'ssh-token-1',
79
+ localPort: 41234,
80
+ reachable: true,
81
+ });
82
+ expect(getPairingSession).toHaveBeenCalledWith('pair_ok');
83
+ expect(logger.warn).not.toHaveBeenCalled();
84
+ });
85
+
86
+ it('returns 403 when pairingId sshHostId does not match hostId', async () => {
87
+ const { app } = createApp({
88
+ getPairingSession: async () => ({
89
+ id: 'pair_x',
90
+ sshHostId: 'ssh-2',
91
+ usedAt: '2026-01-01T00:00:00.000Z',
92
+ }),
93
+ });
94
+
95
+ const res = await request(app)
96
+ .post('/api/openchamber/ssh-host-token')
97
+ .send({ hostId: 'ssh-1', pairingId: 'pair_x' });
98
+ expect(res.status).toBe(403);
99
+ expect(res.body).toEqual({ error: 'pairing-mismatch' });
100
+ });
101
+
102
+ it('returns 403 when pairing session is not redeemed', async () => {
103
+ const { app } = createApp({
104
+ getPairingSession: async () => ({
105
+ id: 'pair_pending',
106
+ sshHostId: 'ssh-1',
107
+ usedAt: null,
108
+ }),
109
+ });
110
+
111
+ const res = await request(app)
112
+ .post('/api/openchamber/ssh-host-token')
113
+ .send({ hostId: 'ssh-1', pairingId: 'pair_pending' });
114
+ expect(res.status).toBe(403);
115
+ expect(res.body).toEqual({ error: 'pairing-mismatch' });
116
+ });
117
+
118
+ it('returns 403 when pairingId is unknown', async () => {
119
+ const { app } = createApp({
120
+ getPairingSession: async () => null,
121
+ });
122
+
123
+ const res = await request(app)
124
+ .post('/api/openchamber/ssh-host-token')
125
+ .send({ hostId: 'ssh-1', pairingId: 'pair_missing' });
126
+ expect(res.status).toBe(403);
127
+ expect(res.body).toEqual({ error: 'pairing-mismatch' });
128
+ });
129
+
130
+ it('allows legacy mint without pairingId and logs a deprecation warning', async () => {
131
+ const { app, logger } = createApp({
132
+ getSshRoutingTable: () => [{ id: 'ssh-1', localPort: 41234 }],
133
+ });
134
+
135
+ const ok = await request(app)
136
+ .post('/api/openchamber/ssh-host-token')
137
+ .send({ hostId: 'ssh-1' });
138
+ expect(ok.status).toBe(200);
139
+ expect(ok.headers['cache-control']).toBe('no-store');
140
+ expect(ok.body).toEqual({
141
+ token: 'ssh-token-1',
142
+ localPort: 41234,
143
+ reachable: true,
144
+ });
145
+ expect(logger.warn).toHaveBeenCalledTimes(1);
146
+ expect(String(logger.warn.mock.calls[0][0])).toMatch(/deprecated/i);
147
+ });
148
+
149
+ it('returns reachable false and localPort null when routing table misses', async () => {
150
+ const { app } = createApp({
151
+ getSshRoutingTable: () => [],
152
+ getPairingSession: async () => ({
153
+ id: 'pair_ok',
154
+ sshHostId: 'ssh-1',
155
+ usedAt: '2026-01-01T00:00:00.000Z',
156
+ }),
157
+ });
158
+
159
+ const res = await request(app)
160
+ .post('/api/openchamber/ssh-host-token')
161
+ .send({ hostId: 'ssh-1', pairingId: 'pair_ok' });
162
+ expect(res.status).toBe(200);
163
+ expect(res.body).toEqual({
164
+ token: 'ssh-token-1',
165
+ localPort: null,
166
+ reachable: false,
167
+ });
168
+ });
169
+
170
+ it('returns 404 for missing / non-SSH / tokenless hosts', async () => {
171
+ const { app } = createApp();
172
+
173
+ const missing = await request(app)
174
+ .post('/api/openchamber/ssh-host-token')
175
+ .send({ hostId: 'missing' });
176
+ expect(missing.status).toBe(404);
177
+
178
+ const nonSsh = await request(app)
179
+ .post('/api/openchamber/ssh-host-token')
180
+ .send({ hostId: 'lan-other' });
181
+ expect(nonSsh.status).toBe(404);
182
+
183
+ const { app: noTokenApp } = createApp({
184
+ settings: {
185
+ desktopSshInstances: [{ id: 'ssh-empty' }],
186
+ desktopHosts: [{ id: 'ssh-empty' }],
187
+ },
188
+ });
189
+ const noToken = await request(noTokenApp)
190
+ .post('/api/openchamber/ssh-host-token')
191
+ .send({ hostId: 'ssh-empty' });
192
+ expect(noToken.status).toBe(404);
193
+ });
194
+ });
@@ -61,6 +61,8 @@ export const createBootstrapRuntime = (dependencies) => {
61
61
  sessionIndexService,
62
62
  sessionIndexSyncRuntime,
63
63
  transcriptCacheService,
64
+ getSshRoutingTable,
65
+ getPairingSession,
64
66
  } = options;
65
67
 
66
68
  const uiAuthController = createUiAuth({
@@ -149,6 +151,9 @@ export const createBootstrapRuntime = (dependencies) => {
149
151
  sessionIndexService,
150
152
  sessionIndexSyncRuntime,
151
153
  transcriptCacheService,
154
+ getSshRoutingTable,
155
+ getPairingSession,
156
+ express,
152
157
  });
153
158
 
154
159
  return {
@@ -871,6 +871,9 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
871
871
  allowedClientKinds: req.body?.allowedClientKinds,
872
872
  createdByClientId: clientIdFromAuthContext(authContext),
873
873
  usesRelay,
874
+ // Optional SSH instance id: binds mobile ssh-host-token mint to this
875
+ // pairing after redeem. Does not affect redeem itself.
876
+ sshHostId: req.body?.sshHostId,
874
877
  });
875
878
  void reconcileRelay();
876
879
  res.setHeader('Cache-Control', 'no-store');
@@ -774,9 +774,24 @@ describe('core-routes', () => {
774
774
  allowedClientKinds: ['mobile'],
775
775
  createdByClientId: null,
776
776
  usesRelay: false,
777
+ sshHostId: undefined,
777
778
  });
778
779
  });
779
780
 
781
+ it('forwards optional sshHostId into createPairingSession', async () => {
782
+ const { app, dependencies } = createPairingRouteApp();
783
+
784
+ await request(app)
785
+ .post('/api/client-auth/pairing/sessions')
786
+ .set('Host', 'runtime.example')
787
+ .send({ label: 'SSH phone', allowedClientKinds: ['mobile'], sshHostId: 'ssh-1', includeRelay: true })
788
+ .expect(201);
789
+
790
+ expect(dependencies.clientPairingRuntime.createPairingSession).toHaveBeenCalledWith(
791
+ expect.objectContaining({ sshHostId: 'ssh-1' }),
792
+ );
793
+ });
794
+
780
795
  it('advertises the caller-supplied serverUrl as the direct candidate over the request origin', async () => {
781
796
  const { app } = createPairingRouteApp();
782
797