@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.
- package/bin/lib/DOCUMENTATION.md +1 -0
- package/dist/assets/{AgentAvatar-Bms-mdYQ.js → AgentAvatar-B2kZjgaf.js} +1 -1
- package/dist/assets/{AssistantView-DHKLyHkP.js → AssistantView-DoQNwA81.js} +1 -1
- package/dist/assets/{ChatContainer-1fYnNjGG.js → ChatContainer-hPrHj6ad.js} +27 -27
- package/dist/assets/{DiagramView-CXbxG_-i.js → DiagramView-BNQD0WS7.js} +1 -1
- package/dist/assets/{FadeInOnReveal-EV5Qh80w.js → FadeInOnReveal-aeWlaqVB.js} +1 -1
- package/dist/assets/{Icon-DFt6p0LW.js → Icon-AXqa-fYB.js} +1 -1
- package/dist/assets/{MarkdownRendererImpl-Dou7CVs9.js → MarkdownRendererImpl-TW_0FpXU.js} +2 -2
- package/dist/assets/{MobileDetailNavigation-ALYPdaO3.js → MobileDetailNavigation-C-sSSpNV.js} +1 -1
- package/dist/assets/{MobileShareBridge-D0ZCpLKx.js → MobileShareBridge-DMmYX2Xd.js} +3 -3
- package/dist/assets/MobileSurface-DJZMAArd.js +2 -0
- package/dist/assets/{MultiRunWindow-D-OVNf1i.js → MultiRunWindow-D1oq3v6J.js} +1 -1
- package/dist/assets/{OnboardingScreen-Ce44652u.js → OnboardingScreen-CMwFZJaq.js} +1 -1
- package/dist/assets/{OverlayScrollbar-CAD_nzSL.js → OverlayScrollbar-CM2pSMXK.js} +1 -1
- package/dist/assets/SettingsView-Cb2BCf05.js +25 -0
- package/dist/assets/{SettingsWindow-FIEA13Wk.js → SettingsWindow-BjAPbhSW.js} +1 -1
- package/dist/assets/{TerminalView-DbGtHzRu.js → TerminalView-DieJ3j3Y.js} +1 -1
- package/dist/assets/{ToolOutputDialog-BbrTqdF1.js → ToolOutputDialog-DKgXqpjW.js} +1 -1
- package/dist/assets/{appThemeRegistry-DaQqmOgO.js → appThemeRegistry-EQBjDQ2h.js} +2 -2
- package/dist/assets/{button-TdqFSeGV.js → button-CDvMVaiO.js} +1 -1
- package/dist/assets/{clipboard-DZE4HSCz.js → clipboard-D04CUnw9.js} +1 -1
- package/dist/assets/{collapsible-BcmzZNos.js → collapsible-C2aamDvD.js} +1 -1
- package/dist/assets/{context-menu-C3VR4TLI.js → context-menu-B8BpNQuQ.js} +1 -1
- package/dist/assets/{desktopHosts-Ch7nWzqq.js → desktopHosts-CBoBpJ1Z.js} +1 -1
- package/dist/assets/{desktopRelayRestore-BhKR5E2i.js → desktopRelayRestore-l2R9muux.js} +1 -1
- package/dist/assets/{diagram-DPzoxeQp.js → diagram-CEon0BQs.js} +1 -1
- package/dist/assets/{dialog-D9nDFQe9.js → dialog-AGpxn5f-.js} +1 -1
- package/dist/assets/es-C25PRSjo.js +15 -0
- package/dist/assets/{fileQueries-DO24BEHy.js → fileQueries-CE_DaZWQ.js} +1 -1
- package/dist/assets/fr-Cv8ZWQdW.js +13 -0
- package/dist/assets/{gitApi-BXPRLZQR.js → gitApi-DJXXGxny.js} +3 -3
- package/dist/assets/{gitApiHttp-gCfEig4j.js → gitApiHttp-D8j1qRH4.js} +1 -1
- package/dist/assets/{input-CRIOZS4l.js → input-k9DGGyZ6.js} +1 -1
- package/dist/assets/insertionBoundaries-CpRrF0GZ.js +1 -0
- package/dist/assets/{ja-C3eCimlB.js → ja-Ue9m9eSe.js} +3 -3
- package/dist/assets/{ko-gnciIU9a.js → ko-DWBUCCV6.js} +3 -3
- package/dist/assets/main-CRAvA11H.js +172 -0
- package/dist/assets/{main-C1f_lQFs.js → main-D5kd907Y.js} +2 -2
- package/dist/assets/{markdown-worker-Cdsb6HMC.js → markdown-worker-DkeWiER6.js} +1 -1
- package/dist/assets/{markdownTheme-DXrASRAr.js → markdownTheme-DNT93i5L.js} +1 -1
- package/dist/assets/{miniChat-CJQdJNd8.js → miniChat-BKolvKKd.js} +2 -2
- package/dist/assets/mobile-BDxdZvXB.js +2 -0
- package/dist/assets/{multirun-C1h8M21g.js → multirun-BxS-zElJ.js} +1 -1
- package/dist/assets/{persistence-Dx4t3MFT.js → persistence-DPNMIbF5.js} +1 -1
- package/dist/assets/{pl-CNk6BfNS.js → pl-DaBaPCyE.js} +3 -3
- package/dist/assets/projectMeta-BFdkXi17.js +3 -0
- package/dist/assets/pt-BR-C2cJZ43o.js +15 -0
- package/dist/assets/radio-CsvyNwg_.js +1 -0
- package/dist/assets/renderElectronMiniChatApp-C8m6hwjc.js +2 -0
- package/dist/assets/renderMobileApp-xK3vZEdV.js +23 -0
- package/dist/assets/{runtimeConfig-B2uR3gVz.js → runtimeConfig-BtWi74Pm.js} +1 -1
- package/dist/assets/runtimeEndpointReset-CDYpQFDL.js +145 -0
- package/dist/assets/{sortable-tabs-strip-DwwNeohp.js → sortable-tabs-strip-CG1yUk1H.js} +1 -1
- package/dist/assets/src-oHrYIqHF.css +2 -0
- package/dist/assets/{store-CS6l6pH7.js → store-ZJMqkVJw.js} +4 -4
- package/dist/assets/{terminalApi-DeRDgtLC.js → terminalApi-tB3qLcfa.js} +1 -1
- package/dist/assets/{tooltip-L0UVzQuU.js → tooltip-DMNR5rN0.js} +1 -1
- package/dist/assets/uk-DJz_P8OZ.js +15 -0
- package/dist/assets/{url-DNSNOUnB.js → url-7MqoLs9X.js} +1 -1
- package/dist/assets/{useAppFontEffects-B4R-tVf8.js → useAppFontEffects-D3xTg4vd.js} +4 -4
- package/dist/assets/{useAssistantUIStore-CKpKMQ7k.js → useAssistantUIStore-Bq3cPj2x.js} +1 -1
- package/dist/assets/{useDesktopWindowControlsLayout--VqmCk5m.js → useDesktopWindowControlsLayout-Cx_ZlMeC.js} +1 -1
- package/dist/assets/{useEffectiveDirectory-jxn4k6bF.js → useEffectiveDirectory-F2lyzmzo.js} +1 -1
- package/dist/assets/{useFontPreferences-DkEh4AWh.js → useFontPreferences-CIurCeNv.js} +1 -1
- package/dist/assets/{useMobileNavigationStore-xmz_gJOJ.js → useMobileNavigationStore-Bx0N4taf.js} +10 -10
- package/dist/assets/useSessionAutoCleanup-kic65ri4.js +1 -0
- package/dist/assets/{useUIStore-CZy3uiW5.js → useUIStore-5agS8bsW.js} +1 -1
- package/dist/assets/{useWorkerHighlightedLines-BDrlUEHY.js → useWorkerHighlightedLines-D5xPSrt5.js} +1 -1
- package/dist/assets/{utils-DD0PVWqD.js → utils-Co19EJEL.js} +1 -1
- package/dist/assets/zh-CN-DRL_-fwp.js +15 -0
- package/dist/assets/zh-TW-DyR4EIVs.js +15 -0
- package/dist/index.html +9 -9
- package/dist/mini-chat.html +9 -9
- package/dist/mobile.html +9 -9
- package/package.json +1 -1
- package/server/index.js +18 -15
- package/server/lib/message-queue/DOCUMENTATION.md +1 -1
- package/server/lib/message-queue/routes.js +1 -1
- package/server/lib/message-queue/routes.test.js +1 -0
- package/server/lib/message-queue/service.js +12 -1
- package/server/lib/message-queue/service.test.js +30 -0
- package/server/lib/opencode/DOCUMENTATION.md +3 -3
- package/server/lib/opencode/cli-entry-runtime.js +6 -0
- package/server/lib/opencode/cli-entry-runtime.test.js +43 -0
- package/server/lib/opencode/core-routes.js +1 -1
- package/server/lib/opencode/core-routes.test.js +14 -0
- package/server/lib/relay/DOCUMENTATION.md +1 -1
- package/server/lib/relay/service.js +12 -8
- package/server/lib/relay/service.test.js +21 -6
- package/dist/assets/MobileSurface-xlCU-y0X.js +0 -2
- package/dist/assets/SettingsView-DUgzCvq4.js +0 -25
- package/dist/assets/es-DMX-Uo1i.js +0 -15
- package/dist/assets/fr-CSFYzSd8.js +0 -13
- package/dist/assets/insertionBoundaries-DKCN5im2.js +0 -1
- package/dist/assets/main-DoiIrxVe.js +0 -172
- package/dist/assets/mobile-DqY5jMn1.js +0 -2
- package/dist/assets/projectMeta-C6C6gEta.js +0 -3
- package/dist/assets/pt-BR-D4G-0rLj.js +0 -15
- package/dist/assets/radio-h0ThANKS.js +0 -1
- package/dist/assets/renderElectronMiniChatApp-CJWoGMwl.js +0 -2
- package/dist/assets/renderMobileApp-Ncly6X04.js +0 -23
- package/dist/assets/runtimeEndpointReset-DA2H9Bis.js +0 -145
- package/dist/assets/src-DrgsP0XF.css +0 -2
- package/dist/assets/uk-BUry1gqt.js +0 -15
- package/dist/assets/useSessionAutoCleanup-Ba5jvHTF.js +0 -1
- package/dist/assets/zh-CN-CJJZzHVW.js +0 -15
- 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-
|
|
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-
|
|
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-
|
|
564
|
-
<link rel="modulepreload" crossorigin href="/assets/desktopHosts-
|
|
565
|
-
<link rel="modulepreload" crossorigin href="/assets/desktopRelayRestore-
|
|
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-
|
|
569
|
-
<link rel="modulepreload" crossorigin href="/assets/terminalApi-
|
|
570
|
-
<link rel="modulepreload" crossorigin href="/assets/runtimeConfig-
|
|
571
|
-
<link rel="stylesheet" crossorigin href="/assets/src-
|
|
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
|
package/dist/mini-chat.html
CHANGED
|
@@ -74,23 +74,23 @@
|
|
|
74
74
|
}
|
|
75
75
|
</style>
|
|
76
76
|
|
|
77
|
-
<script type="module" crossorigin src="/assets/miniChat-
|
|
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-
|
|
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-
|
|
86
|
-
<link rel="modulepreload" crossorigin href="/assets/desktopHosts-
|
|
87
|
-
<link rel="modulepreload" crossorigin href="/assets/desktopRelayRestore-
|
|
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-
|
|
91
|
-
<link rel="modulepreload" crossorigin href="/assets/terminalApi-
|
|
92
|
-
<link rel="modulepreload" crossorigin href="/assets/runtimeConfig-
|
|
93
|
-
<link rel="stylesheet" crossorigin href="/assets/src-
|
|
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-
|
|
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-
|
|
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-
|
|
75
|
-
<link rel="modulepreload" crossorigin href="/assets/desktopHosts-
|
|
76
|
-
<link rel="modulepreload" crossorigin href="/assets/desktopRelayRestore-
|
|
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-
|
|
80
|
-
<link rel="modulepreload" crossorigin href="/assets/terminalApi-
|
|
81
|
-
<link rel="modulepreload" crossorigin href="/assets/runtimeConfig-
|
|
82
|
-
<link rel="stylesheet" crossorigin href="/assets/src-
|
|
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
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
|
-
|
|
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
|
|
1641
|
-
//
|
|
1642
|
-
|
|
1643
|
-
|
|
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
|
-
|
|
1651
|
-
|
|
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)
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
13
|
-
// They may still be relay *clients*
|
|
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:
|
|
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
|
-
|
|
14
|
-
|
|
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.
|
|
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
|
});
|