@openchambery/web 1.17.1-beta.16 → 1.17.1-beta.17

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 (107) hide show
  1. package/bin/lib/DOCUMENTATION.md +1 -0
  2. package/dist/assets/{AgentAvatar-Bms-mdYQ.js → AgentAvatar-B2kZjgaf.js} +1 -1
  3. package/dist/assets/{AssistantView-DHKLyHkP.js → AssistantView-DoQNwA81.js} +1 -1
  4. package/dist/assets/{ChatContainer-1fYnNjGG.js → ChatContainer-hPrHj6ad.js} +27 -27
  5. package/dist/assets/{DiagramView-CXbxG_-i.js → DiagramView-BNQD0WS7.js} +1 -1
  6. package/dist/assets/{FadeInOnReveal-EV5Qh80w.js → FadeInOnReveal-aeWlaqVB.js} +1 -1
  7. package/dist/assets/{Icon-DFt6p0LW.js → Icon-AXqa-fYB.js} +1 -1
  8. package/dist/assets/{MarkdownRendererImpl-Dou7CVs9.js → MarkdownRendererImpl-TW_0FpXU.js} +2 -2
  9. package/dist/assets/{MobileDetailNavigation-ALYPdaO3.js → MobileDetailNavigation-C-sSSpNV.js} +1 -1
  10. package/dist/assets/{MobileShareBridge-D0ZCpLKx.js → MobileShareBridge-DMmYX2Xd.js} +3 -3
  11. package/dist/assets/MobileSurface-DJZMAArd.js +2 -0
  12. package/dist/assets/{MultiRunWindow-D-OVNf1i.js → MultiRunWindow-D1oq3v6J.js} +1 -1
  13. package/dist/assets/{OnboardingScreen-Ce44652u.js → OnboardingScreen-CMwFZJaq.js} +1 -1
  14. package/dist/assets/{OverlayScrollbar-CAD_nzSL.js → OverlayScrollbar-CM2pSMXK.js} +1 -1
  15. package/dist/assets/SettingsView-Cb2BCf05.js +25 -0
  16. package/dist/assets/{SettingsWindow-FIEA13Wk.js → SettingsWindow-BjAPbhSW.js} +1 -1
  17. package/dist/assets/{TerminalView-DbGtHzRu.js → TerminalView-DieJ3j3Y.js} +1 -1
  18. package/dist/assets/{ToolOutputDialog-BbrTqdF1.js → ToolOutputDialog-DKgXqpjW.js} +1 -1
  19. package/dist/assets/{appThemeRegistry-DaQqmOgO.js → appThemeRegistry-EQBjDQ2h.js} +2 -2
  20. package/dist/assets/{button-TdqFSeGV.js → button-CDvMVaiO.js} +1 -1
  21. package/dist/assets/{clipboard-DZE4HSCz.js → clipboard-D04CUnw9.js} +1 -1
  22. package/dist/assets/{collapsible-BcmzZNos.js → collapsible-C2aamDvD.js} +1 -1
  23. package/dist/assets/{context-menu-C3VR4TLI.js → context-menu-B8BpNQuQ.js} +1 -1
  24. package/dist/assets/{desktopHosts-Ch7nWzqq.js → desktopHosts-CBoBpJ1Z.js} +1 -1
  25. package/dist/assets/{desktopRelayRestore-BhKR5E2i.js → desktopRelayRestore-l2R9muux.js} +1 -1
  26. package/dist/assets/{diagram-DPzoxeQp.js → diagram-CEon0BQs.js} +1 -1
  27. package/dist/assets/{dialog-D9nDFQe9.js → dialog-AGpxn5f-.js} +1 -1
  28. package/dist/assets/es-C25PRSjo.js +15 -0
  29. package/dist/assets/{fileQueries-DO24BEHy.js → fileQueries-CE_DaZWQ.js} +1 -1
  30. package/dist/assets/fr-Cv8ZWQdW.js +13 -0
  31. package/dist/assets/{gitApi-BXPRLZQR.js → gitApi-DJXXGxny.js} +3 -3
  32. package/dist/assets/{gitApiHttp-gCfEig4j.js → gitApiHttp-D8j1qRH4.js} +1 -1
  33. package/dist/assets/{input-CRIOZS4l.js → input-k9DGGyZ6.js} +1 -1
  34. package/dist/assets/insertionBoundaries-CpRrF0GZ.js +1 -0
  35. package/dist/assets/{ja-C3eCimlB.js → ja-Ue9m9eSe.js} +3 -3
  36. package/dist/assets/{ko-gnciIU9a.js → ko-DWBUCCV6.js} +3 -3
  37. package/dist/assets/main-CRAvA11H.js +172 -0
  38. package/dist/assets/{main-C1f_lQFs.js → main-D5kd907Y.js} +2 -2
  39. package/dist/assets/{markdown-worker-Cdsb6HMC.js → markdown-worker-DkeWiER6.js} +1 -1
  40. package/dist/assets/{markdownTheme-DXrASRAr.js → markdownTheme-DNT93i5L.js} +1 -1
  41. package/dist/assets/{miniChat-CJQdJNd8.js → miniChat-BKolvKKd.js} +2 -2
  42. package/dist/assets/mobile-BDxdZvXB.js +2 -0
  43. package/dist/assets/{multirun-C1h8M21g.js → multirun-BxS-zElJ.js} +1 -1
  44. package/dist/assets/{persistence-Dx4t3MFT.js → persistence-DPNMIbF5.js} +1 -1
  45. package/dist/assets/{pl-CNk6BfNS.js → pl-DaBaPCyE.js} +3 -3
  46. package/dist/assets/projectMeta-BFdkXi17.js +3 -0
  47. package/dist/assets/pt-BR-C2cJZ43o.js +15 -0
  48. package/dist/assets/radio-CsvyNwg_.js +1 -0
  49. package/dist/assets/renderElectronMiniChatApp-C8m6hwjc.js +2 -0
  50. package/dist/assets/renderMobileApp-xK3vZEdV.js +23 -0
  51. package/dist/assets/{runtimeConfig-B2uR3gVz.js → runtimeConfig-BtWi74Pm.js} +1 -1
  52. package/dist/assets/runtimeEndpointReset-CDYpQFDL.js +145 -0
  53. package/dist/assets/{sortable-tabs-strip-DwwNeohp.js → sortable-tabs-strip-CG1yUk1H.js} +1 -1
  54. package/dist/assets/src-oHrYIqHF.css +2 -0
  55. package/dist/assets/{store-CS6l6pH7.js → store-ZJMqkVJw.js} +4 -4
  56. package/dist/assets/{terminalApi-DeRDgtLC.js → terminalApi-tB3qLcfa.js} +1 -1
  57. package/dist/assets/{tooltip-L0UVzQuU.js → tooltip-DMNR5rN0.js} +1 -1
  58. package/dist/assets/uk-DJz_P8OZ.js +15 -0
  59. package/dist/assets/{url-DNSNOUnB.js → url-7MqoLs9X.js} +1 -1
  60. package/dist/assets/{useAppFontEffects-B4R-tVf8.js → useAppFontEffects-D3xTg4vd.js} +4 -4
  61. package/dist/assets/{useAssistantUIStore-CKpKMQ7k.js → useAssistantUIStore-Bq3cPj2x.js} +1 -1
  62. package/dist/assets/{useDesktopWindowControlsLayout--VqmCk5m.js → useDesktopWindowControlsLayout-Cx_ZlMeC.js} +1 -1
  63. package/dist/assets/{useEffectiveDirectory-jxn4k6bF.js → useEffectiveDirectory-F2lyzmzo.js} +1 -1
  64. package/dist/assets/{useFontPreferences-DkEh4AWh.js → useFontPreferences-CIurCeNv.js} +1 -1
  65. package/dist/assets/{useMobileNavigationStore-xmz_gJOJ.js → useMobileNavigationStore-Bx0N4taf.js} +10 -10
  66. package/dist/assets/useSessionAutoCleanup-kic65ri4.js +1 -0
  67. package/dist/assets/{useUIStore-CZy3uiW5.js → useUIStore-5agS8bsW.js} +1 -1
  68. package/dist/assets/{useWorkerHighlightedLines-BDrlUEHY.js → useWorkerHighlightedLines-D5xPSrt5.js} +1 -1
  69. package/dist/assets/{utils-DD0PVWqD.js → utils-Co19EJEL.js} +1 -1
  70. package/dist/assets/zh-CN-DRL_-fwp.js +15 -0
  71. package/dist/assets/zh-TW-DyR4EIVs.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.js +18 -15
  77. package/server/lib/message-queue/DOCUMENTATION.md +1 -1
  78. package/server/lib/message-queue/routes.js +1 -1
  79. package/server/lib/message-queue/routes.test.js +1 -0
  80. package/server/lib/message-queue/service.js +12 -1
  81. package/server/lib/message-queue/service.test.js +30 -0
  82. package/server/lib/opencode/DOCUMENTATION.md +3 -3
  83. package/server/lib/opencode/cli-entry-runtime.js +6 -0
  84. package/server/lib/opencode/cli-entry-runtime.test.js +43 -0
  85. package/server/lib/opencode/core-routes.js +1 -1
  86. package/server/lib/opencode/core-routes.test.js +14 -0
  87. package/server/lib/relay/DOCUMENTATION.md +1 -1
  88. package/server/lib/relay/service.js +12 -8
  89. package/server/lib/relay/service.test.js +21 -6
  90. package/dist/assets/MobileSurface-xlCU-y0X.js +0 -2
  91. package/dist/assets/SettingsView-DUgzCvq4.js +0 -25
  92. package/dist/assets/es-DMX-Uo1i.js +0 -15
  93. package/dist/assets/fr-CSFYzSd8.js +0 -13
  94. package/dist/assets/insertionBoundaries-DKCN5im2.js +0 -1
  95. package/dist/assets/main-DoiIrxVe.js +0 -172
  96. package/dist/assets/mobile-DqY5jMn1.js +0 -2
  97. package/dist/assets/projectMeta-C6C6gEta.js +0 -3
  98. package/dist/assets/pt-BR-D4G-0rLj.js +0 -15
  99. package/dist/assets/radio-h0ThANKS.js +0 -1
  100. package/dist/assets/renderElectronMiniChatApp-CJWoGMwl.js +0 -2
  101. package/dist/assets/renderMobileApp-Ncly6X04.js +0 -23
  102. package/dist/assets/runtimeEndpointReset-DA2H9Bis.js +0 -145
  103. package/dist/assets/src-DrgsP0XF.css +0 -2
  104. package/dist/assets/uk-BUry1gqt.js +0 -15
  105. package/dist/assets/useSessionAutoCleanup-Ba5jvHTF.js +0 -1
  106. package/dist/assets/zh-CN-CJJZzHVW.js +0 -15
  107. package/dist/assets/zh-TW-DxhIXjND.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-C1f_lQFs.js"></script>
555
+ <script type="module" crossorigin src="/assets/main-D5kd907Y.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-CZy3uiW5.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-Dx4t3MFT.js">
564
- <link rel="modulepreload" crossorigin href="/assets/desktopHosts-Ch7nWzqq.js">
565
- <link rel="modulepreload" crossorigin href="/assets/desktopRelayRestore-BhKR5E2i.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-gCfEig4j.js">
569
- <link rel="modulepreload" crossorigin href="/assets/terminalApi-DeRDgtLC.js">
570
- <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-B2uR3gVz.js">
571
- <link rel="stylesheet" crossorigin href="/assets/src-DrgsP0XF.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-CJQdJNd8.js"></script>
77
+ <script type="module" crossorigin src="/assets/miniChat-BKolvKKd.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-CZy3uiW5.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-Dx4t3MFT.js">
86
- <link rel="modulepreload" crossorigin href="/assets/desktopHosts-Ch7nWzqq.js">
87
- <link rel="modulepreload" crossorigin href="/assets/desktopRelayRestore-BhKR5E2i.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-gCfEig4j.js">
91
- <link rel="modulepreload" crossorigin href="/assets/terminalApi-DeRDgtLC.js">
92
- <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-B2uR3gVz.js">
93
- <link rel="stylesheet" crossorigin href="/assets/src-DrgsP0XF.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-DqY5jMn1.js"></script>
66
+ <script type="module" crossorigin src="/assets/mobile-BDxdZvXB.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-CZy3uiW5.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-Dx4t3MFT.js">
75
- <link rel="modulepreload" crossorigin href="/assets/desktopHosts-Ch7nWzqq.js">
76
- <link rel="modulepreload" crossorigin href="/assets/desktopRelayRestore-BhKR5E2i.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-gCfEig4j.js">
80
- <link rel="modulepreload" crossorigin href="/assets/terminalApi-DeRDgtLC.js">
81
- <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-B2uR3gVz.js">
82
- <link rel="stylesheet" crossorigin href="/assets/src-DrgsP0XF.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-beta.16",
3
+ "version": "1.17.1-beta.17",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./server/index.js",
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
@@ -1422,7 +1424,7 @@ async function main(options = {}) {
1422
1424
  reconcileRelay: () => (relayServiceInstance ? relayServiceInstance.reconcile() : Promise.resolve()),
1423
1425
  getPairingTransports: async (req) => {
1424
1426
  const transports = resolvePairingTransports(req);
1425
- if (!relayServiceInstance) return transports;
1427
+ if (!relayServiceInstance || !transports.relayAvailable) return transports;
1426
1428
  const relay = await relayServiceInstance.getStatus();
1427
1429
  return {
1428
1430
  ...transports,
@@ -1637,18 +1639,19 @@ async function main(options = {}) {
1637
1639
  }
1638
1640
 
1639
1641
  // Only Electron (OPENCHAMBER_RUNTIME=desktop) opens a relay host-control
1640
- // socket. Other runtimes no-op reconcile. When allowed, demand-driven: run if
1641
- // any relay device/session exists, stop (and clear a stale enabled flag)
1642
- // otherwise.
1643
- void relayService.reconcile();
1644
-
1645
- // Relay demand can change outside our routes: pending sessions expire, or a
1646
- // paired device is revoked, without hitting our management routes. Poll so
1647
- // the desktop host picks demand up (or drops it) within a minute.
1648
- 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()) {
1649
1646
  void relayService.reconcile();
1650
- }, 60_000);
1651
- 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
+ }
1652
1655
 
1653
1656
  return {
1654
1657
  expressApp: app,
@@ -1694,7 +1697,7 @@ async function main(options = {}) {
1694
1697
  console.warn('[message-queue] Failed to close durable database during shutdown');
1695
1698
  }
1696
1699
  realtimeProxyRuntime.stop();
1697
- clearInterval(relayReconcileTimer);
1700
+ if (relayReconcileTimer) clearInterval(relayReconcileTimer);
1698
1701
  try {
1699
1702
  relayService.stop();
1700
1703
  } catch {
@@ -20,7 +20,7 @@ Import staging accepts canonical payloads with scope/item ordinals, payload hash
20
20
 
21
21
  There is no long-lived "dormant module" state in production: cutover is explicit `shadow` → `active` (with optional `paused`). Documentation that previously described the production v3 queue as authoritative while this module stayed dormant is obsolete once authority is `active`.
22
22
 
23
- Scopes key records by the server-derived runtime key, normalized directory, and session ID. Stateless Assistant clients use the stable synthetic session ID `assistant:<assistantID>` for this existing scope contract; execution still uses a fresh captured-workspace OpenCode Session per item. Client transport identity stays outside durable keys. Each scope exposes a stable `scopeID`, revision, and worktree lifecycle state. Edit reservations fence ordinary edit, removal, and manual-send mutations for their exact queue item only; they never fence reorder or unrelated rows in the scope. Reservation renewal binds queue item ID, token, and current authority generation; it accepts a bounded TTL until its durable expiry timestamp, commits only the expiry field in an immediate transaction, and leaves scope and global revisions unchanged. Reserved removal presents its token and authority generation in the same SQLite delete CAS. Releasing a reservation wakes the worker.
23
+ Scopes key records by the server-derived runtime key, normalized directory, and session ID. Stateless Assistant clients use the stable synthetic session ID `assistant:<assistantID>` for this existing scope contract; execution still uses a fresh captured-workspace OpenCode Session per item. Client transport identity stays outside durable keys. Each scope exposes a stable `scopeID`, revision, and worktree lifecycle state. A runtime holds at most 128 scopes; creating a new scope at the cap first evicts the oldest empty scopes (LRU by `updated_at`, scope ID tiebreak — safe because `queue_item` is the only foreign key into `queue_scope`), and admission fails with `scope_limit` (HTTP 409) only when every scope still holds items. Edit reservations fence ordinary edit, removal, and manual-send mutations for their exact queue item only; they never fence reorder or unrelated rows in the scope. Reservation renewal binds queue item ID, token, and current authority generation; it accepts a bounded TTL until its durable expiry timestamp, commits only the expiry field in an immediate transaction, and leaves scope and global revisions unchanged. Reserved removal presents its token and authority generation in the same SQLite delete CAS. Releasing a reservation wakes the worker.
24
24
 
25
25
  ## Per-item sendConfig
26
26
 
@@ -1,5 +1,5 @@
1
1
  const prefix = '/api/openchamber/message-queue';
2
- const statusFor = (error) => ({ validation_error: 400, not_found: 404, revision_conflict: 409, row_version_conflict: 409, idempotency_conflict: 409, generation_conflict: 409, authority_conflict: 409, scope_locked: 409, reserved: 409, reservation_expired: 409, reservation_generation_conflict: 409, reservation_token_mismatch: 409, attachment_unavailable: 404, attachment_total_limit: 413, admission_payload_limit: 413, attachment_store_not_found: 404, attachment_store_max_bytes: 413, attachment_store_size_mismatch: 400, attachment_store_hash_mismatch: 400, attachment_store_aborted: 499 })[error?.code] ?? 500;
2
+ const statusFor = (error) => ({ validation_error: 400, not_found: 404, revision_conflict: 409, row_version_conflict: 409, idempotency_conflict: 409, generation_conflict: 409, authority_conflict: 409, scope_locked: 409, scope_limit: 409, reserved: 409, reservation_expired: 409, reservation_generation_conflict: 409, reservation_token_mismatch: 409, attachment_unavailable: 404, attachment_total_limit: 413, admission_payload_limit: 413, attachment_store_not_found: 404, attachment_store_max_bytes: 413, attachment_store_size_mismatch: 400, attachment_store_hash_mismatch: 400, attachment_store_aborted: 499 })[error?.code] ?? 500;
3
3
  const publicCode = (error) => statusFor(error) === 500 ? 'internal_error' : error.code;
4
4
  const unsupported = (res) => res.status(501).json({ code: 'unavailable' });
5
5
  const send = (res, action) => { try { res.json(action()); } catch (error) { const status = statusFor(error); if (status === 500) console.error('[message-queue] route failed', error); res.status(status).json({ code: publicCode(error) }); } };
@@ -20,6 +20,7 @@ describe('message queue routes', () => {
20
20
  });
21
21
  it('maps stable errors to code-only responses', () => { const { app, route } = registry(); registerMessageQueueRoutes(app, { messageQueueService: { admit: () => { const error = new Error('private detail'); error.code = 'attachment_total_limit'; throw error; } } }); const res = response(); route('POST', `${prefix}/items`)({ body: {} }, res); expect(res).toMatchObject({ statusCode: 413, body: { code: 'attachment_total_limit' } }); });
22
22
  it('maps admission payload limits to 413', () => { const { app, route } = registry(); registerMessageQueueRoutes(app, { messageQueueService: { admit: () => { const error = new Error('private detail'); error.code = 'admission_payload_limit'; throw error; } } }); const res = response(); route('POST', `${prefix}/items`)({ body: {} }, res); expect(res).toMatchObject({ statusCode: 413, body: { code: 'admission_payload_limit' } }); });
23
+ it('maps scope capacity exhaustion to 409 scope_limit', () => { const { app, route } = registry(); registerMessageQueueRoutes(app, { messageQueueService: { admit: () => { const error = new Error('private detail'); error.code = 'scope_limit'; throw error; } } }); const res = response(); route('POST', `${prefix}/items`)({ body: {} }, res); expect(res).toMatchObject({ statusCode: 409, body: { code: 'scope_limit' } }); });
23
24
  it('wakes the runtime after a successful admit', () => {
24
25
  const { app, route } = registry();
25
26
  const admit = vi.fn(() => ({ revision: 1, queueItemID: 'item' }));
@@ -371,11 +371,22 @@ export const createMessageQueueService = ({ dbPath, getRuntimeConfig = () => nul
371
371
  if (excess) db.prepare('DELETE FROM operation_receipt WHERE rowid IN (SELECT rowid FROM operation_receipt WHERE runtime_key = ? ORDER BY created_at, rowid LIMIT ?)').run(key, excess);
372
372
  db.prepare('INSERT INTO operation_receipt(runtime_key,request_id,operation_type,payload_hash,response_json,committed_revision,created_at) VALUES (?,?,?,?,?,?,?)').run(key, requestID, operationType, payloadHash, JSON.stringify(response), revision, now());
373
373
  };
374
+ // Empty scopes carry no durable queue rows (queue_item is the sole FK into
375
+ // queue_scope), so LRU eviction cannot strand items, attempts, receipts, or
376
+ // completions; worktree lifecycle state lives in its own table and scopes
377
+ // re-create from it on demand.
378
+ const evictEmptyScopes = (key, slots) => db.prepare(`DELETE FROM queue_scope WHERE runtime_key = ? AND scope_id IN (
379
+ SELECT s.scope_id FROM queue_scope s WHERE s.runtime_key = ? AND NOT EXISTS (SELECT 1 FROM queue_item i WHERE i.scope_id = s.scope_id)
380
+ ORDER BY s.updated_at, s.scope_id LIMIT ?
381
+ )`).run(key, key, slots);
374
382
  const ensureScope = (scope, key) => {
375
383
  const directory = normalizeDirectory(scope.directory); const scopeID = scopeIDFor(key, directory, scope.sessionID);
376
384
  const state = lifecycle(key, directory)?.state ?? 'active';
377
385
  db.prepare('INSERT OR IGNORE INTO worktree_lifecycle(runtime_key,directory,state,deletion_token,updated_at) VALUES (?,?,?,NULL,?)').run(key, directory, state, now());
378
- if (!scopeRow(scopeID) && Number(db.prepare('SELECT COUNT(*) AS count FROM queue_scope WHERE runtime_key = ?').get(key).count) >= MAX_SCOPES) fail('validation_error');
386
+ if (!scopeRow(scopeID)) {
387
+ const count = db.prepare('SELECT COUNT(*) AS count FROM queue_scope WHERE runtime_key = ?').get(key).count;
388
+ if (count >= MAX_SCOPES) { evictEmptyScopes(key, count - MAX_SCOPES + 1); if (Number(db.prepare('SELECT COUNT(*) AS count FROM queue_scope WHERE runtime_key = ?').get(key).count) >= MAX_SCOPES) fail('scope_limit'); }
389
+ }
379
390
  db.prepare('INSERT OR IGNORE INTO queue_scope(scope_id,runtime_key,directory,session_id,revision,worktree_state,created_at,updated_at) VALUES (?,?,?,?,0,?,?,?)').run(scopeID, key, directory, scope.sessionID, state, now(), now());
380
391
  return scopeRow(scopeID);
381
392
  };
@@ -969,4 +969,34 @@ describe('message queue service', () => {
969
969
  const overflow = stage('late-overflow', [addition('three')]); expect(() => service.commitLateImport({ requestID: 'late-overflow-commit', importID: overflow.created.importID, expectedGeneration: 1, manifestHash: overflow.sealed.manifestHash, protocol: 4 })).toThrow(expect.objectContaining({ code: 'validation_error' }));
970
970
  service.close();
971
971
  });
972
+
973
+ it('evicts oldest empty scopes when admission reaches the scope cap', () => {
974
+ const service = createService(dbPath());
975
+ const admissionFor = (suffix) => ({ requestID: `request-${suffix}`, scope: { directory: '/repo', sessionID: `session-${suffix}` }, item: { queueItemID: `item-${suffix}`, operationID: `operation-${suffix}`, messageID: `message-${suffix}`, content: 'hello', attachments: [], attachmentIssues: [], createdAt: 100 } });
976
+ let retained; const emptied = [];
977
+ for (let index = 0; index < 128; index += 1) {
978
+ const result = service.admit(admissionFor(index));
979
+ if (index === 0) { retained = result; continue; }
980
+ service.remove({ requestID: `remove-${index}`, queueItemID: result.queueItemID, expectedRevision: result.revision, expectedRowVersion: 1 });
981
+ emptied.push(result.scopeID);
982
+ }
983
+ expect(service.snapshot().scopes).toHaveLength(128);
984
+ const fresh = service.admit(admissionFor('fresh'));
985
+ expect(service.getScope(fresh.scopeID).items).toHaveLength(1);
986
+ expect(service.snapshot().scopes).toHaveLength(128);
987
+ const remaining = new Set(service.snapshot().scopes.map((scope) => scope.scopeID));
988
+ expect(remaining.has(fresh.scopeID)).toBe(true);
989
+ expect(remaining.has(retained.scopeID)).toBe(true);
990
+ expect(emptied.filter((scopeID) => remaining.has(scopeID))).toHaveLength(emptied.length - 1);
991
+ expect(service.getScope(retained.scopeID).items[0]).toMatchObject({ queueItemID: retained.queueItemID });
992
+ service.close();
993
+ });
994
+
995
+ it('fails admission with scope_limit when every scope at the cap still holds items', () => {
996
+ const service = createService(dbPath());
997
+ const admissionFor = (suffix) => ({ requestID: `request-${suffix}`, scope: { directory: '/repo', sessionID: `session-${suffix}` }, item: { queueItemID: `item-${suffix}`, operationID: `operation-${suffix}`, messageID: `message-${suffix}`, content: 'hello', attachments: [], attachmentIssues: [], createdAt: 100 } });
998
+ for (let index = 0; index < 128; index += 1) service.admit(admissionFor(index));
999
+ expect(() => service.admit(admissionFor('overflow'))).toThrow(expect.objectContaining({ code: 'scope_limit' }));
1000
+ service.close();
1001
+ });
972
1002
  });
@@ -8,7 +8,7 @@ This module provides OpenCode server integration utilities for the web server ru
8
8
  - `packages/web/server/lib/opencode/auth.js`: provider authentication file operations.
9
9
  - `packages/web/server/lib/opencode/auth-state-runtime.js`: managed OpenCode server auth password/header runtime.
10
10
  - `packages/web/server/lib/opencode/cli-options.js`: CLI/environment option parsing for server startup arguments.
11
- - `packages/web/server/lib/opencode/cli-entry-runtime.js`: CLI entrypoint runtime that detects direct execution, parses CLI options, and starts server bootstrap.
11
+ - `packages/web/server/lib/opencode/cli-entry-runtime.js`: CLI entrypoint runtime that detects direct execution, forces `OPENCHAMBER_RUNTIME=web` (so `dev:server` cannot inherit a leftover desktop env and host a relay), parses CLI options, and starts server bootstrap.
12
12
  - `packages/web/server/lib/opencode/routes.js`: OpenCode/provider settings and auth-related route registration.
13
13
  - `packages/web/server/lib/opencode/lifecycle.js`: OpenCode process lifecycle runtime (startup, restart, readiness, health monitoring). Managed child env inherits the user shell environment unchanged; experimental flags such as `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` follow the user's own environment and are never injected by OpenChamber.
14
14
  - `packages/web/server/lib/opencode/managed-capabilities-runtime.js`: managed-child scheduled-task resources, config injection, rotating bridge identity, and bridge authorization.
@@ -273,7 +273,7 @@ When adding or changing Host HTTP APIs that mobile/desktop clients reach over Pr
273
273
  - `DELETE /api/passkeys/:id`
274
274
  - `POST /api/auth/reset`
275
275
  - `POST /api/client-auth/pairing/sessions`: accepts an optional `relayUrl` when Relay is included. An owner UI session or the local `desktop-local` shell client may set a custom endpoint (other client bearers receive HTTP 403). Canonical form is `ws://`/`wss://` scheme/host/path: userinfo is rejected (HTTP 400); query and fragment are stripped and are not part of endpoint identity. The Host persists the switch and reconnects its control connection before returning the pairing-v2 Relay candidate; an `OPENCHAMBER_RELAY_URL` override stays authoritative.
276
- - `GET /api/client-auth/pairing/transports`: returns direct transport availability plus the effective Relay URL and whether the environment pins it.
276
+ - `GET /api/client-auth/pairing/transports`: returns direct transport availability. `relayAvailable` is true only for the Electron desktop host; local `dev` / `web` / CLI servers report false and omit Relay URL fields so the create-device dialog never defaults to Anywhere or probes the relay.
277
277
  - `GET /connect`
278
278
  - `POST /api/system/probe-url`
279
279
  - `app.use('/api', ...)` auth/tunnel guard
@@ -294,7 +294,7 @@ When adding or changing Host HTTP APIs that mobile/desktop clients reach over Pr
294
294
  - Legacy `--tunnel` shorthand normalization
295
295
 
296
296
  ## Public exports (cli-entry-runtime.js)
297
- - `runCliEntryIfMain(dependencies)`: detects direct CLI execution and runs server startup with parsed CLI options.
297
+ - `runCliEntryIfMain(dependencies)`: detects direct CLI execution, forces `OPENCHAMBER_RUNTIME=web` so a leftover desktop env cannot host or probe a relay, and runs server startup with parsed CLI options.
298
298
 
299
299
  ## Public exports (server-utils-runtime.js)
300
300
  - `createServerUtilsRuntime(dependencies)`: creates server utility runtime for OpenCode orchestration helpers.
@@ -13,6 +13,12 @@ export const runCliEntryIfMain = (dependencies) => {
13
13
  return;
14
14
  }
15
15
 
16
+ // Direct `node server/index.js` is the standalone web/dev server. Never inherit
17
+ // OPENCHAMBER_RUNTIME=desktop from the parent shell — that would open a relay
18
+ // host-control socket from `bun run dev` / `dev:server`. Electron sets desktop
19
+ // before importing this module and never takes this CLI path.
20
+ process.env.OPENCHAMBER_RUNTIME = 'web';
21
+
16
22
  const cliOptions = parseServeCliOptions({
17
23
  argv: process.argv.slice(2),
18
24
  env: process.env,
@@ -0,0 +1,43 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { runCliEntryIfMain } from './cli-entry-runtime.js';
3
+
4
+ describe('cli-entry-runtime', () => {
5
+ const previousRuntime = process.env.OPENCHAMBER_RUNTIME;
6
+
7
+ afterEach(() => {
8
+ if (previousRuntime === undefined) delete process.env.OPENCHAMBER_RUNTIME;
9
+ else process.env.OPENCHAMBER_RUNTIME = previousRuntime;
10
+ });
11
+
12
+ it('forces OPENCHAMBER_RUNTIME=web on the direct node server path even if the parent shell leaked desktop', () => {
13
+ process.env.OPENCHAMBER_RUNTIME = 'desktop';
14
+ const startServer = vi.fn(async () => {});
15
+
16
+ runCliEntryIfMain({
17
+ process: { argv: ['node', '/tmp/server/index.js', '--port', '3001'], env: process.env, exit: vi.fn() },
18
+ currentFilename: '/tmp/server/index.js',
19
+ parseServeCliOptions: () => ({ port: 3001, host: undefined, uiPassword: null, apiOnly: false }),
20
+ defaultPort: 3001,
21
+ setExitOnShutdown: vi.fn(),
22
+ startServer,
23
+ });
24
+
25
+ expect(process.env.OPENCHAMBER_RUNTIME).toBe('web');
26
+ expect(startServer).toHaveBeenCalledTimes(1);
27
+ });
28
+
29
+ it('does not rewrite OPENCHAMBER_RUNTIME when imported as a library (Electron in-process)', () => {
30
+ process.env.OPENCHAMBER_RUNTIME = 'desktop';
31
+
32
+ runCliEntryIfMain({
33
+ process: { argv: ['electron', '/tmp/electron/main.mjs'], env: process.env, exit: vi.fn() },
34
+ currentFilename: '/tmp/server/index.js',
35
+ parseServeCliOptions: vi.fn(),
36
+ defaultPort: 3001,
37
+ setExitOnShutdown: vi.fn(),
38
+ startServer: vi.fn(),
39
+ });
40
+
41
+ expect(process.env.OPENCHAMBER_RUNTIME).toBe('desktop');
42
+ });
43
+ });
@@ -395,7 +395,7 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
395
395
  // Returns { local, lan, relayAvailable } — the direct transport URLs the
396
396
  // server can actually be reached on (LAN derived from the server bind, not
397
397
  // the UI origin), for the create-device dialog.
398
- getPairingTransports = () => ({ local: null, lan: null, relayAvailable: true }),
398
+ getPairingTransports = () => ({ local: null, lan: null, relayAvailable: false }),
399
399
  // Returns ALL direct LAN URLs the server is currently reachable on (client-
400
400
  // reached address first, then interface scan) for the candidates-refresh
401
401
  // endpoint. Empty when the server is loopback-only.
@@ -1003,6 +1003,20 @@ describe('core-routes', () => {
1003
1003
  expect(dependencies.clientPairingRuntime.createPairingSession).toHaveBeenCalled();
1004
1004
  });
1005
1005
 
1006
+ it('defaults pairing transports to relay unavailable so web/dev never advertise Anywhere', async () => {
1007
+ const { app } = createPairingRouteApp();
1008
+
1009
+ const response = await request(app)
1010
+ .get('/api/client-auth/pairing/transports')
1011
+ .expect(200);
1012
+
1013
+ expect(response.body).toEqual({
1014
+ local: null,
1015
+ lan: null,
1016
+ relayAvailable: false,
1017
+ });
1018
+ });
1019
+
1006
1020
  it('reports the effective Relay endpoint and environment lock to the pairing dialog', async () => {
1007
1021
  const { app } = createPairingRouteApp({
1008
1022
  getPairingTransports: vi.fn(async () => ({
@@ -19,7 +19,7 @@ Traffic is modeled as three stacked layers. The relay understands only Layer 1;
19
19
  ## Entrypoints and structure
20
20
 
21
21
  Host side (`packages/web/server/lib/relay/`):
22
- - `service.js` — thin entrypoint: relay config (enabled flag + relay URL), the management routes (`GET/POST /api/openchamber/relay/{status,enable,disable}`), a `getPairingCandidate()` accessor (the relay transport candidate folded into pairing-v2 links when enabled, consumed by the pairing-session route in `core-routes.js`), and lifecycle wiring. Host lifecycle is gated to Electron: non-desktop runtimes report `state: 'unavailable'`, refuse `/relay/enable` and relay pairing with 403, and never call `startRelayHost`. On desktop, started from `packages/web/server/index.js` only when demand/opt-in enables the relay. The relay endpoint defaults to the OpenChamber-hosted relay but can be pinned to a self-hosted relay via the `OPENCHAMBER_RELAY_URL` env var; when set it overrides the stored setting for the host connection, the pairing candidate, and status, so paired clients inherit the endpoint automatically. Endpoint identity is scheme/host/path only: accepted schemes are `ws://` and `wss://`; URLs with userinfo are rejected (no silent default fallback); query and fragment are stripped before persistence and pairing candidates. Custom endpoint persistence and Host control-connection switching are explicit authenticated management actions. Pairing creation with `relayUrl` requires an owner UI session or the local `desktop-local` shell client; `/relay/enable` follows its API auth gate.
22
+ - `service.js` — thin entrypoint: relay config (enabled flag + relay URL), the management routes (`GET/POST /api/openchamber/relay/{status,enable,disable}`), a `getPairingCandidate()` accessor (the relay transport candidate folded into pairing-v2 links when enabled, consumed by the pairing-session route in `core-routes.js`), and lifecycle wiring. Host lifecycle is gated to Electron: non-desktop runtimes (`bun run dev`, `dev:web:hmr`, CLI `serve`, VS Code, plain `node server`) report `state: 'unavailable'`, refuse `/relay/enable`, `/relay/disable`, and relay pairing with 403, never mint a relay identity, never advertise `relayAvailable`, and never call `startRelayHost`. Direct `node server/index.js` forces `OPENCHAMBER_RUNTIME=web` so a leftover desktop env cannot open the host-control socket. On desktop, started from `packages/web/server/index.js` only when demand/opt-in enables the relay. The relay endpoint defaults to the OpenChamber-hosted relay but can be pinned to a self-hosted relay via the `OPENCHAMBER_RELAY_URL` env var; when set it overrides the stored setting for the host connection, the pairing candidate, and status, so paired clients inherit the endpoint automatically. Endpoint identity is scheme/host/path only: accepted schemes are `ws://` and `wss://`; URLs with userinfo are rejected (no silent default fallback); query and fragment are stripped before persistence and pairing candidates. Custom endpoint persistence and Host control-connection switching are explicit authenticated management actions. Pairing creation with `relayUrl` requires an owner UI session or the local `desktop-local` shell client; `/relay/enable` follows its API auth gate.
23
23
  - `identity.js` — the host's stable identity: the long-lived signing keypair (shared with the push relay, defines the routing id) plus a long-lived encryption keypair (the E2EE trust anchor). Reused across restarts; never rotated implicitly.
24
24
  - `signing-key.js` — storage/derivation of the signing keypair and the routing id, shared with the notifications runtime.
25
25
  - `host-client.js` — the long-lived connection manager: one outbound control connection to the relay, a per-client data connection for each connected device, reconnect/backoff, and the E2EE responder handshake per connection.
@@ -9,8 +9,9 @@
9
9
  // Host gate: only the Electron desktop runtime may run a relay host
10
10
  // (OPENCHAMBER_RUNTIME=desktop, set before the in-process web server starts).
11
11
  // Plain `node server`, `dev:web:hmr`, VS Code, CLI, and browser clients must
12
- // never open the host-control socket or advertise a host pairing candidate.
13
- // They may still be relay *clients* when connecting to another host.
12
+ // never open the host-control socket, mint a relay identity, advertise a host
13
+ // pairing candidate, or probe the relay. They may still be relay *clients*
14
+ // when connecting to another host.
14
15
 
15
16
  import express from 'express';
16
17
 
@@ -269,27 +270,27 @@ export const createRelayService = ({
269
270
  // Stable server identity (base64url SHA-256 of the canonical public signing
270
271
  // JWK). Derived from a public key, so it is not a secret; clients use it to
271
272
  // verify that a learned/probed address belongs to this server before trusting
272
- // it. Independent of whether the relay host is currently enabled.
273
+ // it. Independent of whether the relay host is currently enabled. Off
274
+ // desktop this is null — minting identity is a desktop-host concern.
273
275
  const getServerId = async () => {
276
+ if (!hostAllowed()) return null;
274
277
  const identity = await identityRuntime.getRelayIdentity();
275
278
  return identity.serverId;
276
279
  };
277
280
 
278
281
  const getStatus = async () => {
279
- const config = await readConfig();
280
- const identity = await identityRuntime.getRelayIdentity();
281
282
  if (!hostAllowed()) {
282
283
  return {
283
284
  enabled: false,
284
285
  hostAllowed: false,
285
286
  state: 'unavailable',
286
- serverId: identity.serverId,
287
+ serverId: null,
287
288
  connectedClients: 0,
288
- relayUrl: config.relayUrl,
289
- relayUrlLocked: config.relayUrlLocked,
290
289
  lastError: RELAY_HOST_DESKTOP_ONLY_MESSAGE,
291
290
  };
292
291
  }
292
+ const config = await readConfig();
293
+ const identity = await identityRuntime.getRelayIdentity();
293
294
  const live = hostClient ? hostClient.getStatus() : status;
294
295
  return {
295
296
  enabled: config.enabled,
@@ -408,6 +409,9 @@ export const createRelayService = ({
408
409
 
409
410
  app.post('/api/openchamber/relay/disable', async (_req, res) => {
410
411
  try {
412
+ if (!hostAllowed()) {
413
+ return res.status(403).json({ error: RELAY_HOST_DESKTOP_ONLY_MESSAGE });
414
+ }
411
415
  const current = await readConfig();
412
416
  await writeConfig({ enabled: false, relayUrl: current.relayUrl });
413
417
  stop();
@@ -5,14 +5,18 @@ import request from 'supertest';
5
5
  const relayMocks = vi.hoisted(() => ({
6
6
  starts: [],
7
7
  stops: [],
8
+ identityReads: 0,
8
9
  }));
9
10
 
10
11
  vi.mock('./identity.js', () => ({
11
12
  createRelayIdentityRuntime: () => ({
12
- getRelayIdentity: async () => ({
13
- serverId: 'server-test',
14
- hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: 'x', y: 'y' },
15
- }),
13
+ getRelayIdentity: async () => {
14
+ relayMocks.identityReads += 1;
15
+ return {
16
+ serverId: 'server-test',
17
+ hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: 'x', y: 'y' },
18
+ };
19
+ },
16
20
  }),
17
21
  }));
18
22
 
@@ -59,6 +63,7 @@ describe('relay service pairing endpoint', () => {
59
63
  afterEach(() => {
60
64
  relayMocks.starts.length = 0;
61
65
  relayMocks.stops.length = 0;
66
+ relayMocks.identityReads = 0;
62
67
  delete process.env.OPENCHAMBER_RELAY_URL;
63
68
  });
64
69
 
@@ -142,9 +147,10 @@ describe('relay host desktop-only gate', () => {
142
147
  afterEach(() => {
143
148
  relayMocks.starts.length = 0;
144
149
  relayMocks.stops.length = 0;
150
+ relayMocks.identityReads = 0;
145
151
  });
146
152
 
147
- it('does not start a host, claim, or rewrite settings when reconcile runs off desktop', async () => {
153
+ it('does not start a host, claim, mint identity, or rewrite settings when reconcile runs off desktop', async () => {
148
154
  const write = vi.fn(async (next) => next);
149
155
  const { service, settings } = createTestRelayService({
150
156
  canHostRelay: false,
@@ -157,12 +163,16 @@ describe('relay host desktop-only gate', () => {
157
163
  expect(relayMocks.starts).toEqual([]);
158
164
  expect(write).not.toHaveBeenCalled();
159
165
  expect(settings.current()).toEqual({});
160
- await expect(service.getStatus()).resolves.toMatchObject({
166
+ await expect(service.getStatus()).resolves.toEqual({
161
167
  enabled: false,
162
168
  hostAllowed: false,
163
169
  state: 'unavailable',
170
+ serverId: null,
171
+ connectedClients: 0,
164
172
  lastError: RELAY_HOST_DESKTOP_ONLY_MESSAGE,
165
173
  });
174
+ await expect(service.getServerId()).resolves.toBeNull();
175
+ expect(relayMocks.identityReads).toBe(0);
166
176
  });
167
177
 
168
178
  it('refuses relay pairing and enable off desktop', async () => {
@@ -181,6 +191,11 @@ describe('relay host desktop-only gate', () => {
181
191
  .send({})
182
192
  .expect(403, { error: RELAY_HOST_DESKTOP_ONLY_MESSAGE });
183
193
 
194
+ await request(app)
195
+ .post('/api/openchamber/relay/disable')
196
+ .send({})
197
+ .expect(403, { error: RELAY_HOST_DESKTOP_ONLY_MESSAGE });
198
+
184
199
  expect(relayMocks.starts).toEqual([]);
185
200
  expect(settings.current()).toEqual({});
186
201
  });