@openchambery/web 1.18.4-beta.2 → 1.18.4-beta.20

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 (138) hide show
  1. package/bin/cli.test.js +25 -0
  2. package/bin/lib/DOCUMENTATION.md +2 -1
  3. package/bin/lib/cli-args.js +19 -0
  4. package/bin/lib/commands-serve.js +73 -4
  5. package/dist/assets/{AgentAvatar-BvhXxFvg.js → AgentAvatar-DDcjamcJ.js} +1 -1
  6. package/dist/assets/AssistantView-BKtWtK9a.js +1 -0
  7. package/dist/assets/{ChatContainer-Ba2Qqd2R.js → ChatContainer-CmZuBd4k.js} +42 -42
  8. package/dist/assets/{DiagramView--CkYLtF9.js → DiagramView-C3H0iaE1.js} +1 -1
  9. package/dist/assets/{FadeInOnReveal-98buIVdv.js → FadeInOnReveal-By2F9vcI.js} +1 -1
  10. package/dist/assets/{Icon-BhSwbLaF.js → Icon-DpjYnXxn.js} +1 -1
  11. package/dist/assets/{MarkdownRendererImpl-CuaQbwUx.js → MarkdownRendererImpl-Oq7v-1r2.js} +1 -1
  12. package/dist/assets/{MobileDetailNavigation-ld2mS8hq.js → MobileDetailNavigation-Bh_f-iat.js} +1 -1
  13. package/dist/assets/MobileShareBridge-BvcbH_WJ.js +4 -0
  14. package/dist/assets/MobileSurface-BTMvLLPa.js +2 -0
  15. package/dist/assets/{MultiRunWindow-kryfNvF6.js → MultiRunWindow-Cv4K6i1U.js} +1 -1
  16. package/dist/assets/{OnboardingScreen-C1n_wVb-.js → OnboardingScreen-BKmuT7AL.js} +1 -1
  17. package/dist/assets/{OverlayScrollbar-CXFN6Cqq.js → OverlayScrollbar-Ch3J6ASR.js} +1 -1
  18. package/dist/assets/{SettingsView-BBTUVI-a.js → SettingsView-DZT_nuTt.js} +11 -11
  19. package/dist/assets/{SettingsWindow-4RS_41Wb.js → SettingsWindow-Bjb84x0C.js} +1 -1
  20. package/dist/assets/{TerminalView-B1he4xRe.js → TerminalView-CXq2MDyL.js} +1 -1
  21. package/dist/assets/{ToolOutputDialog-ak07C60J.js → ToolOutputDialog-CTrqAwo1.js} +1 -1
  22. package/dist/assets/appThemeRegistry-D6EEEMQw.js +2 -0
  23. package/dist/assets/{button-BQY5ugjh.js → button-C7OXr4ae.js} +1 -1
  24. package/dist/assets/{clipboard-BvJ7NunE.js → clipboard-DWuULPzo.js} +1 -1
  25. package/dist/assets/{collapsible-CqcZGw3W.js → collapsible-DaEya6b6.js} +1 -1
  26. package/dist/assets/{context-menu-DSUJY2i3.js → context-menu-Cjof3V7x.js} +1 -1
  27. package/dist/assets/desktopHosts-Txp4xOVQ.js +1 -0
  28. package/dist/assets/desktopRelayRestore-C_FSEHKM.js +1 -0
  29. package/dist/assets/{diagram-B8D9Klct.js → diagram-CHfginjS.js} +1 -1
  30. package/dist/assets/{dialog-sGFkIKP2.js → dialog-DPS0W9eS.js} +1 -1
  31. package/dist/assets/{es-BuinIayO.js → es-BX0Vm3Ac.js} +4 -4
  32. package/dist/assets/fileQueries-BrUKKgPW.js +1 -0
  33. package/dist/assets/{fr-CT7us58h.js → fr-CuPzXTYx.js} +4 -4
  34. package/dist/assets/{gitApi-DtV2V_Fp.js → gitApi-mV826uOd.js} +2 -2
  35. package/dist/assets/{input-CgIoxUJc.js → input-BzYA603C.js} +1 -1
  36. package/dist/assets/insertionBoundaries-fFm8sTaK.js +1 -0
  37. package/dist/assets/{ja-CCuAH4nO.js → ja-DGtzjrFO.js} +4 -4
  38. package/dist/assets/{ko-lXbhGt6P.js → ko-DZ9Di0je.js} +4 -4
  39. package/dist/assets/{main-CoNVZvj1.js → main-CqaUoTu5.js} +2 -2
  40. package/dist/assets/main-DaX6y8EG.js +172 -0
  41. package/dist/assets/{markdown-worker-C5Om4TVK.js → markdown-worker-W06-MhF5.js} +1 -1
  42. package/dist/assets/{markdownTheme-BdF4C4-o.js → markdownTheme-B89dbj3N.js} +1 -1
  43. package/dist/assets/{miniChat-CYd1PMdJ.js → miniChat-CX64FVKM.js} +2 -2
  44. package/dist/assets/mobile-BljNuNl6.js +2 -0
  45. package/dist/assets/multirun-Bdv-VeHM.js +4 -0
  46. package/dist/assets/persistence-6sclr7B6.js +3 -0
  47. package/dist/assets/pl-BuCO3qb1.js +15 -0
  48. package/dist/assets/platform-CbeLpKPk.js +2 -0
  49. package/dist/assets/projectMeta-EGeQpzvX.js +3 -0
  50. package/dist/assets/{pt-BR-DWbGs1bc.js → pt-BR-Brf4RMsw.js} +4 -4
  51. package/dist/assets/radio-BPJ5XDOL.js +1 -0
  52. package/dist/assets/renderElectronMiniChatApp-7_MeDuZ2.js +2 -0
  53. package/dist/assets/renderMobileApp-J09WW2wY.js +23 -0
  54. package/dist/assets/runtimeConfig-DbkphGhQ.js +2 -0
  55. package/dist/assets/runtimeEndpointReset-DooWSsoH.js +147 -0
  56. package/dist/assets/{sortable-tabs-strip-pSnPuBXA.js → sortable-tabs-strip-nLsPC06f.js} +1 -1
  57. package/dist/assets/src-Yqt5hwRm.css +2 -0
  58. package/dist/assets/{store-BuXogmxp.js → store-DQY8Fkb0.js} +4 -4
  59. package/dist/assets/{terminalApi-jfes-HIU.js → terminalApi-BpPeXWIu.js} +1 -1
  60. package/dist/assets/{tooltip-rN8LrxMF.js → tooltip-D_orJMYd.js} +1 -1
  61. package/dist/assets/{uk-DgOxtvS7.js → uk-C-wmaPTi.js} +4 -4
  62. package/dist/assets/{url-B0Ak7X6b.js → url-BsaBG5-1.js} +1 -1
  63. package/dist/assets/useAppFontEffects-DFRR7hqN.js +7 -0
  64. package/dist/assets/{useAssistantUIStore-SPh025NE.js → useAssistantUIStore-BHsHXCKE.js} +1 -1
  65. package/dist/assets/{useDesktopWindowControlsLayout-BSHPIcFE.js → useDesktopWindowControlsLayout-7oe0nJkK.js} +1 -1
  66. package/dist/assets/{useEffectiveDirectory-Cbsu48gT.js → useEffectiveDirectory-DEbnpVAu.js} +1 -1
  67. package/dist/assets/{useFontPreferences-Bz3crRRO.js → useFontPreferences-ckzpXQaJ.js} +1 -1
  68. package/dist/assets/useMobileNavigationStore-CPi-bAr0.js +475 -0
  69. package/dist/assets/useSessionAutoCleanup-BFwpZAij.js +1 -0
  70. package/dist/assets/useUIStore-DzwCfRqr.js +1 -0
  71. package/dist/assets/{useWorkerHighlightedLines-D7ZRwkm3.js → useWorkerHighlightedLines-Cb7i5p7s.js} +1 -1
  72. package/dist/assets/{utils-DfPDhqys.js → utils-B1EK5zKa.js} +1 -1
  73. package/dist/assets/{zh-CN-BPGatJp9.js → zh-CN-DueRrIql.js} +4 -4
  74. package/dist/assets/{zh-TW-qUWWsaxK.js → zh-TW-6875X80V.js} +4 -4
  75. package/dist/index.html +9 -9
  76. package/dist/mini-chat.html +9 -9
  77. package/dist/mobile.html +9 -9
  78. package/package.json +1 -1
  79. package/server/index.d.ts +0 -4
  80. package/server/index.js +11 -20
  81. package/server/lib/client-auth/pairing.js +1 -7
  82. package/server/lib/client-auth/pairing.test.js +0 -45
  83. package/server/lib/config-sync/config-sync.test.js +52 -0
  84. package/server/lib/config-sync/scripts.js +10 -2
  85. package/server/lib/notifications/DOCUMENTATION.md +2 -1
  86. package/server/lib/notifications/emitter-runtime.js +78 -1
  87. package/server/lib/notifications/emitter-runtime.test.js +94 -1
  88. package/server/lib/opencode/DOCUMENTATION.md +4 -4
  89. package/server/lib/opencode/bootstrap-runtime.js +0 -6
  90. package/server/lib/opencode/cli-entry-runtime.js +5 -1
  91. package/server/lib/opencode/cli-entry-runtime.test.js +17 -0
  92. package/server/lib/opencode/core-routes.js +0 -3
  93. package/server/lib/opencode/core-routes.test.js +0 -15
  94. package/server/lib/opencode/openchamber-routes.js +0 -11
  95. package/server/lib/relay/DOCUMENTATION.md +3 -45
  96. package/server/lib/relay/host-client.js +1 -4
  97. package/server/lib/relay/service.js +28 -16
  98. package/server/lib/relay/service.test.js +64 -4
  99. package/server/lib/relay/tunnel-host.js +2 -52
  100. package/server/lib/relay/tunnel-host.test.js +2 -250
  101. package/server/lib/scheduled-tasks/DOCUMENTATION.md +4 -1
  102. package/server/lib/scheduled-tasks/routes.js +51 -8
  103. package/server/lib/session-assist/DOCUMENTATION.md +4 -2
  104. package/server/lib/session-assist/runtime.js +67 -30
  105. package/server/lib/session-assist/runtime.test.js +114 -0
  106. package/server/lib/session-title/DOCUMENTATION.md +15 -10
  107. package/server/lib/session-title/runtime.js +24 -0
  108. package/server/lib/session-title/runtime.test.js +143 -0
  109. package/server/lib/ui-auth/ui-auth.js +3 -1
  110. package/server/lib/ui-auth/ui-auth.test.js +25 -0
  111. package/server/sse-routes.test.js +64 -1
  112. package/dist/assets/AssistantView-DMK-KCPX.js +0 -1
  113. package/dist/assets/MobileShareBridge-lTfofNXr.js +0 -4
  114. package/dist/assets/MobileSurface-Bc_pAGrN.js +0 -2
  115. package/dist/assets/appThemeRegistry-ChhJvOPo.js +0 -2
  116. package/dist/assets/desktopHosts-aA40MHzB.js +0 -1
  117. package/dist/assets/desktopRelayRestore-CxUYRC6v.js +0 -1
  118. package/dist/assets/fileQueries-BSE4ycQt.js +0 -1
  119. package/dist/assets/insertionBoundaries-BIS62xbD.js +0 -1
  120. package/dist/assets/main-ClQdYXuu.js +0 -172
  121. package/dist/assets/mobile-CJkbELE1.js +0 -2
  122. package/dist/assets/multirun-DfYluvqf.js +0 -4
  123. package/dist/assets/persistence-DwXJK4-j.js +0 -3
  124. package/dist/assets/pl-DCsKBRAg.js +0 -15
  125. package/dist/assets/platform-CXJwZIZe.js +0 -2
  126. package/dist/assets/projectMeta-jG2rzIks.js +0 -3
  127. package/dist/assets/radio-CIR_xKSG.js +0 -1
  128. package/dist/assets/renderElectronMiniChatApp-D6cOGBOZ.js +0 -2
  129. package/dist/assets/renderMobileApp-BuiAqCNI.js +0 -23
  130. package/dist/assets/runtimeConfig-B9P24SI-.js +0 -2
  131. package/dist/assets/runtimeEndpointReset-BLmUiRJq.js +0 -147
  132. package/dist/assets/src-DF4hiLrB.css +0 -2
  133. package/dist/assets/useAppFontEffects-DCQnaU6M.js +0 -7
  134. package/dist/assets/useMobileNavigationStore-BYiwDqQe.js +0 -476
  135. package/dist/assets/useSessionAutoCleanup-DDdudIWc.js +0 -1
  136. package/dist/assets/useUIStore-BJpkPwky.js +0 -1
  137. package/server/lib/desktop-hosts/routes.js +0 -157
  138. package/server/lib/desktop-hosts/routes.test.js +0 -227
@@ -62,9 +62,6 @@ export const createBootstrapRuntime = (dependencies) => {
62
62
  sessionIndexService,
63
63
  sessionIndexSyncRuntime,
64
64
  transcriptCacheService,
65
- getSshRoutingTable,
66
- mintSshHostToken,
67
- getPairingSession,
68
65
  } = options;
69
66
 
70
67
  const uiAuthController = createUiAuth({
@@ -154,9 +151,6 @@ export const createBootstrapRuntime = (dependencies) => {
154
151
  sessionIndexService,
155
152
  sessionIndexSyncRuntime,
156
153
  transcriptCacheService,
157
- getSshRoutingTable,
158
- mintSshHostToken,
159
- getPairingSession,
160
154
  express,
161
155
  });
162
156
 
@@ -17,7 +17,11 @@ export const runCliEntryIfMain = (dependencies) => {
17
17
  // OPENCHAMBER_RUNTIME=desktop from the parent shell — that would open a relay
18
18
  // host-control socket from `bun run dev` / `dev:server`. Electron sets desktop
19
19
  // before importing this module and never takes this CLI path.
20
- process.env.OPENCHAMBER_RUNTIME = 'web';
20
+ // SSH-manager remotes set OPENCHAMBER_RUNTIME=ssh-remote (and may pass
21
+ // --relay-host); preserve that so the remote can host a private relay.
22
+ if (process.env.OPENCHAMBER_RUNTIME !== 'ssh-remote') {
23
+ process.env.OPENCHAMBER_RUNTIME = 'web';
24
+ }
21
25
 
22
26
  const cliOptions = parseServeCliOptions({
23
27
  argv: process.argv.slice(2),
@@ -26,6 +26,23 @@ describe('cli-entry-runtime', () => {
26
26
  expect(startServer).toHaveBeenCalledTimes(1);
27
27
  });
28
28
 
29
+ it('preserves OPENCHAMBER_RUNTIME=ssh-remote on the direct node server path', () => {
30
+ process.env.OPENCHAMBER_RUNTIME = 'ssh-remote';
31
+ const startServer = vi.fn(async () => {});
32
+
33
+ runCliEntryIfMain({
34
+ process: { argv: ['node', '/tmp/server/index.js', '--port', '3001'], env: process.env, exit: vi.fn() },
35
+ currentFilename: '/tmp/server/index.js',
36
+ parseServeCliOptions: () => ({ port: 3001, host: undefined, uiPassword: null, apiOnly: false }),
37
+ defaultPort: 3001,
38
+ setExitOnShutdown: vi.fn(),
39
+ startServer,
40
+ });
41
+
42
+ expect(process.env.OPENCHAMBER_RUNTIME).toBe('ssh-remote');
43
+ expect(startServer).toHaveBeenCalledTimes(1);
44
+ });
45
+
29
46
  it('does not rewrite OPENCHAMBER_RUNTIME when imported as a library (Electron in-process)', () => {
30
47
  process.env.OPENCHAMBER_RUNTIME = 'desktop';
31
48
 
@@ -871,9 +871,6 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
871
871
  allowedClientKinds: req.body?.allowedClientKinds,
872
872
  createdByClientId: clientIdFromAuthContext(authContext),
873
873
  usesRelay,
874
- // Optional SSH instance id: binds mobile ssh-host-token mint to this
875
- // pairing after redeem. Does not affect redeem itself.
876
- sshHostId: req.body?.sshHostId,
877
874
  });
878
875
  void reconcileRelay();
879
876
  res.setHeader('Cache-Control', 'no-store');
@@ -774,24 +774,9 @@ describe('core-routes', () => {
774
774
  allowedClientKinds: ['mobile'],
775
775
  createdByClientId: null,
776
776
  usesRelay: false,
777
- sshHostId: undefined,
778
777
  });
779
778
  });
780
779
 
781
- it('forwards optional sshHostId into createPairingSession', async () => {
782
- const { app, dependencies } = createPairingRouteApp();
783
-
784
- await request(app)
785
- .post('/api/client-auth/pairing/sessions')
786
- .set('Host', 'runtime.example')
787
- .send({ label: 'SSH phone', allowedClientKinds: ['mobile'], sshHostId: 'ssh-1', includeRelay: true })
788
- .expect(201);
789
-
790
- expect(dependencies.clientPairingRuntime.createPairingSession).toHaveBeenCalledWith(
791
- expect.objectContaining({ sshHostId: 'ssh-1' }),
792
- );
793
- });
794
-
795
780
  it('advertises the caller-supplied serverUrl as the direct candidate over the request origin', async () => {
796
781
  const { app } = createPairingRouteApp();
797
782
 
@@ -1,6 +1,5 @@
1
1
  import { registerSessionIndexRoutes } from '../session-index/routes.js';
2
2
  import { registerTranscriptCacheRoutes } from '../transcript-cache/routes.js';
3
- import { registerDesktopHostRoutes } from '../desktop-hosts/routes.js';
4
3
  import { registerConfigSyncRoutes } from '../config-sync/routes.js';
5
4
 
6
5
  export const registerOpenChamberRoutes = (app, dependencies) => {
@@ -18,21 +17,11 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
18
17
  sessionIndexService,
19
18
  sessionIndexSyncRuntime,
20
19
  transcriptCacheService,
21
- getSshRoutingTable,
22
- mintSshHostToken,
23
- getPairingSession,
24
20
  express,
25
21
  } = dependencies;
26
22
 
27
23
  registerSessionIndexRoutes(app, { sessionIndexService, sessionIndexSyncRuntime });
28
24
  registerTranscriptCacheRoutes(app, { transcriptCacheService });
29
- registerDesktopHostRoutes(app, {
30
- readSettingsFromDiskMigrated,
31
- getSshRoutingTable,
32
- mintSshHostToken,
33
- getPairingSession,
34
- express,
35
- });
36
25
  registerConfigSyncRoutes(app, {
37
26
  readSettingsFromDiskMigrated,
38
27
  persistSettings,
@@ -6,7 +6,7 @@ The private relay lets an OpenChamber client (mobile app, browser, or another de
6
6
 
7
7
  Traffic is **end-to-end encrypted between the two endpoints** (client and host instance). The relay infrastructure forwards opaque ciphertext and cannot read application traffic — it is an untrusted transport, not a trusted middlebox.
8
8
 
9
- This module (`packages/web/server/lib/relay/`) is the **Host side** of the private relay. **Only the Electron desktop runtime may act as a relay host** (`OPENCHAMBER_RUNTIME=desktop`, set by Electron before it starts the in-process web server). Plain `node` / `dev:web:hmr` / CLI / VS Code web servers must not open the host-control socket, claim `relay-host.lock`, or advertise a host pairing candidate — even when they share the same data dir as desktop. Those processes may still run the API that *clients* use; mobile and other desktops remain **relay clients**. The **Client side** lives in `packages/ui/src/lib/relay/`. Hosted Relay instances may run in a Worker. The self-hosted **Relay server** belongs to `packages/relay-server/` and brokers Layer 1 connections.
9
+ This module (`packages/web/server/lib/relay/`) is the **Host side** of the private relay. **Only the Electron desktop runtime or an SSH-managed remote server may act as a relay host** (`OPENCHAMBER_RUNTIME=desktop` or `ssh-remote`). Electron sets `desktop` before it starts the in-process web server; SSH manager sets `ssh-remote` when launching a managed remote `openchamber serve` (optionally with `--relay-host`). Plain `node` / `dev:web:hmr` / ordinary CLI / VS Code web servers must not open the host-control socket, claim `relay-host.lock`, or advertise a host pairing candidate — even when they share the same data dir as desktop. Those processes may still run the API that *clients* use; mobile and other desktops remain **relay clients**. The **Client side** lives in `packages/ui/src/lib/relay/`. Hosted Relay instances may run in a Worker. The self-hosted **Relay server** belongs to `packages/relay-server/` and brokers Layer 1 connections.
10
10
 
11
11
  ## The three layers
12
12
 
@@ -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 (`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.
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 by `isRelayHostRuntime()` (`desktop` or `ssh-remote`): other runtimes (`bun run dev`, `dev:web:hmr`, ordinary 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` unless it is already `ssh-remote`, so a leftover desktop env cannot open the host-control socket. On host runtimes, started from `packages/web/server/index.js` when demand/opt-in enables the relay. Desktop auto-clears `privateRelay.enabled` when demand drops; `ssh-remote` keeps an explicit opt-in (e.g. `openchamber serve --relay-host`) sticky until disabled. 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.
@@ -104,48 +104,6 @@ Relay mode plugs into the existing client transport layer rather than a parallel
104
104
 
105
105
  Catalog loaders (`loadProviders` / `loadAgents`) and assistant Query keys gate writes/caches on the **transport fingerprint**, not `runtimeKey`. On endpoint reset and same-device transport switch, `runtimeEndpointReset.ts` must set `useConfigStore.catalogTransportIdentity` to `getRuntimeTransportIdentity()`. Writing `detail.runtimeKey` there silently discards provider/agent catalog refreshes under Relay, which then hides capability-gated surfaces such as Assistants.
106
106
 
107
- ## SSH host routing
108
-
109
- Mobile (and other relay clients) can reach a desktop's **SSH-forwarded remote OpenChamber instances** through the same private-relay tunnel that already reaches the desktop's local loopback origin. Routing is host-side only: the client selects a target local-forward port; the tunnel dispatcher dials that port on `127.0.0.1` when — and only when — it is present in the live SSH routing table.
110
-
111
- ### Header contract
112
-
113
- - Header name: `x-openchamber-target-port`
114
- - Value: decimal port number string (e.g. `"41234"`)
115
- - HTTP: carried on the tunneled request headers
116
- - WebSocket: optional `headers` field on the `WsOpen` payload (same header name/value)
117
- - The dispatcher **strips** this header before forwarding to loopback (it is routing metadata, not an upstream header)
118
-
119
- ### Resolution rules (`resolveTargetPort` in `tunnel-host.js`)
120
-
121
- | Condition | Result |
122
- |---|---|
123
- | Header absent | Dial `getLocalPort()` (desktop local origin) — unchanged default behavior |
124
- | Header present and `localPort` is in `getSshRoutingTable()` | Dial that port |
125
- | Header present but port missing/invalid/not in table | **Do not** fall back to the default port |
126
-
127
- HTTP miss → synthetic **503** with body `{ error: 'ssh-host-unreachable', …, source: 'relay-tunnel-host' }`.
128
- WS miss → stream abort with reason `ssh-host-unreachable`.
129
-
130
- ### Routing table (authoritative in memory)
131
-
132
- - Electron injects `getSshRoutingTable: () => sshManager.getRoutingTable()` into `startWebUiServer`.
133
- - Table entries are `{ id, localPort }` for SSH sessions whose status phase is **`ready`** and whose `localPort` is finite.
134
- - Degraded / connecting / disconnected sessions are absent. Settings.json is **not** the allowlist for dialing — only the live table is.
135
- - Non-desktop runtimes default to `() => []`.
136
-
137
- ### Companion HTTP APIs (authenticated `/api` gate)
138
-
139
- - `GET /api/openchamber/desktop-hosts` → `{ hosts: [{ id, label, localPort, reachable }] }` for configured SSH instances. `reachable` is true iff the host id is in the routing table. **Never returns `clientToken`.**
140
- - `POST /api/openchamber/ssh-host-token` body `{ hostId }` → `{ token }` from `settings.json` `desktopHosts[].clientToken` for SSH instance hosts only. Missing / non-SSH / no token → **404**. Response includes `Cache-Control: no-store`.
141
-
142
- ### Security invariants (do not regress)
143
-
144
- - Path allowlists (`isAllowedHttpPath` / `ALLOWED_WS_PATHS`) are **not** relaxed for SSH targets.
145
- - The dispatcher **never injects credentials**; the client still authenticates with its own bearer / `oc_url_token` against the target instance.
146
- - Port legitimacy is solely the in-memory routing table, not client-supplied trust and not settings alone.
147
- - List endpoints must not leak `clientToken`; token mint is a separate authenticated POST.
148
-
149
107
  ## Design invariants (do not regress)
150
108
 
151
109
  - The relay never sees plaintext application traffic; it sees only routing metadata (routing id, connection identifiers, timestamps, coarse counts).
@@ -154,6 +112,6 @@ WS miss → stream abort with reason `ssh-host-unreachable`.
154
112
  - The host dispatcher never injects credentials; the server authenticates each tunneled request.
155
113
  - The tunnel is transparent to the app: adding relay support to a feature should not require the feature to know the relay exists — it goes through the shared runtime transport helpers.
156
114
  - The two implementations stay byte-compatible and the wire format is versioned/negotiated so mixed client/host app versions degrade gracefully rather than break.
157
- - SSH target-port routing never falls back to the default local port on a routing-table miss (see "SSH host routing").
115
+ - Tunneled HTTP/WS always dial `getLocalPort()` on loopback; there is no client-selected target-port override on the host dispatcher.
158
116
 
159
117
  For the operational rules that keep future changes (new WebSocket endpoints, transport refactors, terminal/voice porting) from breaking this, load the `relay-transport` skill.
@@ -54,14 +54,12 @@ const resolveBatchWindowMs = (option) => {
54
54
  * identity: { serverId: string, hostEncPrivateKey: CryptoKey, signRelayAuth: (role: string, connectionId?: string | null) => { ts: number, sig: string, pk: string } },
55
55
  * localPort?: number,
56
56
  * getLocalPort?: () => number,
57
- * getSshRoutingTable?: () => { id: string, localPort: number }[],
58
57
  * onStatus?: (status: { state: string, lastError: string | null, connectedClients: number }) => void,
59
58
  * logger?: Pick<Console, 'warn'>,
60
59
  * }} options
61
60
  */
62
- export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, getSshRoutingTable = () => [], onStatus, logger = console, batchWindowMs, batch }) => {
61
+ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, onStatus, logger = console, batchWindowMs, batch }) => {
63
62
  const resolveLocalPort = typeof getLocalPort === 'function' ? getLocalPort : () => localPort;
64
- const resolveSshRoutingTable = typeof getSshRoutingTable === 'function' ? getSshRoutingTable : () => [];
65
63
  const localBatch = batch !== false;
66
64
  const resolvedBatchWindowMs = resolveBatchWindowMs(batchWindowMs);
67
65
 
@@ -190,7 +188,6 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, ge
190
188
  entry.tunnel = createTunnelHost({
191
189
  connectionId,
192
190
  getLocalPort: resolveLocalPort,
193
- getSshRoutingTable: resolveSshRoutingTable,
194
191
  getBufferedAmount: () => socket.bufferedAmount,
195
192
  sendFrame: (plaintextFrame) => {
196
193
  if (dataSockets.get(connectionId) !== entry || socket.readyState !== WebSocket.OPEN) return;
@@ -6,11 +6,11 @@
6
6
  // Routes are registered with the other OpenChamber feature routes, before the
7
7
  // generic OpenCode proxy, and are covered by the same global UI auth gate.
8
8
  //
9
- // Host gate: only the Electron desktop runtime may run a relay host
10
- // (OPENCHAMBER_RUNTIME=desktop, set before the in-process web server starts).
11
- // Plain `node server`, `dev:web:hmr`, VS Code, CLI, and browser clients must
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*
9
+ // Host gate: only the Electron desktop runtime or an SSH-managed remote server
10
+ // may run a relay host (OPENCHAMBER_RUNTIME=desktop | ssh-remote).
11
+ // Plain `node server`, `dev:web:hmr`, VS Code, ordinary CLI, and browser clients
12
+ // must never open the host-control socket, mint a relay identity, advertise a
13
+ // host pairing candidate, or probe the relay. They may still be relay *clients*
14
14
  // when connecting to another host.
15
15
 
16
16
  import express from 'express';
@@ -21,16 +21,22 @@ import { startRelayHost } from './host-client.js';
21
21
  export const DEFAULT_RELAY_URL = 'wss://relay.openchamber.dev/ws';
22
22
 
23
23
  export const RELAY_HOST_DESKTOP_ONLY_MESSAGE =
24
- 'Relay host is only available in the OpenChamber desktop app';
24
+ 'Relay host is only available in the OpenChamber desktop app or an SSH-managed remote server';
25
25
 
26
26
  // Electron main sets this before importing the in-process web server.
27
27
  export const isDesktopRelayHostRuntime = (env = process.env) =>
28
28
  env?.OPENCHAMBER_RUNTIME === 'desktop';
29
29
 
30
+ // Desktop app, or an SSH-manager-started remote (`OPENCHAMBER_RUNTIME=ssh-remote`).
31
+ export const isRelayHostRuntime = (env = process.env) => {
32
+ const runtime = env?.OPENCHAMBER_RUNTIME;
33
+ return runtime === 'desktop' || runtime === 'ssh-remote';
34
+ };
35
+
30
36
  // Canonical form: ws(s)://host[:port]/path only. Reject credentials (userinfo)
31
37
  // so settings and pairing candidates never store secrets in the endpoint URL.
32
38
  // Strip query/fragment so persistence and candidates stay scheme/host/path.
33
- const canonicalizeRelayUrl = (value) => {
39
+ export const canonicalizeRelayUrl = (value) => {
34
40
  if (typeof value !== 'string') return null;
35
41
  const trimmed = value.trim();
36
42
  if (!trimmed) return null;
@@ -69,7 +75,6 @@ const envRelayUrlOverride = () => {
69
75
  * readSettingsFromDiskMigrated: () => Promise<object>,
70
76
  * writeSettingsToDisk: (settings: object) => Promise<void>,
71
77
  * getLocalPort: () => number,
72
- * getSshRoutingTable?: () => { id: string, localPort: number }[],
73
78
  * logger?: Pick<Console, 'warn'>,
74
79
  * canHostRelay?: () => boolean,
75
80
  * }} deps
@@ -82,8 +87,6 @@ export const createRelayService = ({
82
87
  // regeneration — see identity.js/signing-key.js.
83
88
  readSettingsStrict,
84
89
  getLocalPort,
85
- // Live SSH local-forward ports (Electron). Empty outside desktop.
86
- getSshRoutingTable = () => [],
87
90
  // Returns true when any paired device or pending pairing session uses the
88
91
  // relay transport. The relay lifecycle is driven purely by this demand.
89
92
  hasRelayDemand = async () => false,
@@ -92,8 +95,8 @@ export const createRelayService = ({
92
95
  // evict each other at the relay worker ("Control replaced") and devices land
93
96
  // on a random instance. Optional: without it, behavior is pre-lock.
94
97
  hostLock = null,
95
- // Host gate: false for non-Electron runtimes (dev server, CLI, VS Code, …).
96
- canHostRelay = () => isDesktopRelayHostRuntime(),
98
+ // Host gate: false for non-host runtimes (dev server, ordinary CLI, VS Code, …).
99
+ canHostRelay = () => isRelayHostRuntime(),
97
100
  logger = console,
98
101
  }) => {
99
102
  const identityRuntime = createRelayIdentityRuntime({ crypto, readSettingsFromDiskMigrated, writeSettingsToDisk, readSettingsStrict });
@@ -211,7 +214,6 @@ export const createRelayService = ({
211
214
  relayUrl,
212
215
  identity,
213
216
  getLocalPort,
214
- getSshRoutingTable,
215
217
  logger,
216
218
  onStatus: (next) => {
217
219
  status = next;
@@ -242,8 +244,12 @@ export const createRelayService = ({
242
244
  // Drive the relay lifecycle from demand: run it when a device or pending
243
245
  // session uses the relay, stop it when none remain. Called on startup and after
244
246
  // pairing/device changes, so the operator never toggles it manually.
245
- // Non-desktop runtimes never host: skip entirely so a shared data dir with
247
+ // Non-host runtimes never host: skip entirely so a shared data dir with
246
248
  // Electron is not rewritten or claim-contested by dev/CLI servers.
249
+ // SSH-managed remotes (`ssh-remote`) may also keep an explicit
250
+ // `privateRelay.enabled` opt-in (e.g. `openchamber serve --relay-host`) even
251
+ // when no paired device is present yet — desktop still auto-clears enabled
252
+ // when demand drops.
247
253
  const reconcile = async () => {
248
254
  if (!hostAllowed()) {
249
255
  refuseHost();
@@ -258,6 +264,9 @@ export const createRelayService = ({
258
264
  const next = await readConfig();
259
265
  await start(next.relayUrl);
260
266
  }
267
+ } else if (config.enabled && !isDesktopRelayHostRuntime()) {
268
+ // Sticky opt-in on ssh-remote (and any future non-desktop host runtime).
269
+ if (!hostClient) await start(config.relayUrl);
261
270
  } else {
262
271
  if (config.enabled) await writeConfig({ enabled: false, relayUrl: config.relayUrl });
263
272
  stop();
@@ -270,8 +279,8 @@ export const createRelayService = ({
270
279
  // Stable server identity (base64url SHA-256 of the canonical public signing
271
280
  // JWK). Derived from a public key, so it is not a secret; clients use it to
272
281
  // verify that a learned/probed address belongs to this server before trusting
273
- // it. Independent of whether the relay host is currently enabled. Off
274
- // desktop this is null — minting identity is a desktop-host concern.
282
+ // it. Independent of whether the relay host is currently enabled. Off a host
283
+ // runtime this is null — minting identity is a desktop / ssh-remote concern.
275
284
  const getServerId = async () => {
276
285
  if (!hostAllowed()) return null;
277
286
  const identity = await identityRuntime.getRelayIdentity();
@@ -299,6 +308,9 @@ export const createRelayService = ({
299
308
  // another local process owns the machine's relay host claim.
300
309
  state: hostClient ? live.state : (status.state === 'standby' ? 'standby' : 'disabled'),
301
310
  serverId: identity.serverId,
311
+ // Public E2EE trust anchor. Desktop SSH attach uses this to persist a
312
+ // multi-transport host (local-forward + relay) without a pairing round-trip.
313
+ hostEncPubJwk: identity.hostEncPubJwk,
302
314
  connectedClients: live.connectedClients,
303
315
  relayUrl: config.relayUrl,
304
316
  relayUrlLocked: config.relayUrlLocked,
@@ -34,6 +34,7 @@ vi.mock('./host-client.js', () => ({
34
34
  import {
35
35
  createRelayService,
36
36
  DEFAULT_RELAY_URL,
37
+ isRelayHostRuntime,
37
38
  RELAY_HOST_DESKTOP_ONLY_MESSAGE,
38
39
  } from './service.js';
39
40
 
@@ -143,14 +144,14 @@ describe('relay service pairing endpoint', () => {
143
144
  });
144
145
  });
145
146
 
146
- describe('relay host desktop-only gate', () => {
147
+ describe('relay host runtime gate', () => {
147
148
  afterEach(() => {
148
149
  relayMocks.starts.length = 0;
149
150
  relayMocks.stops.length = 0;
150
151
  relayMocks.identityReads = 0;
151
152
  });
152
153
 
153
- it('does not start a host, claim, mint identity, or rewrite settings when reconcile runs off desktop', async () => {
154
+ it('does not start a host, claim, mint identity, or rewrite settings when reconcile runs off host runtime', async () => {
154
155
  const write = vi.fn(async (next) => next);
155
156
  const { service, settings } = createTestRelayService({
156
157
  canHostRelay: false,
@@ -175,7 +176,16 @@ describe('relay host desktop-only gate', () => {
175
176
  expect(relayMocks.identityReads).toBe(0);
176
177
  });
177
178
 
178
- it('refuses relay pairing and enable off desktop', async () => {
179
+ it('includes the public E2EE trust anchor on host-capable status', async () => {
180
+ const { service } = createTestRelayService();
181
+ await expect(service.getStatus()).resolves.toMatchObject({
182
+ hostAllowed: true,
183
+ serverId: 'server-test',
184
+ hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: 'x', y: 'y' },
185
+ });
186
+ });
187
+
188
+ it('refuses relay pairing and enable off host runtime', async () => {
179
189
  const { service, settings } = createTestRelayService({ canHostRelay: false });
180
190
  const app = express();
181
191
  service.registerRoutes(app);
@@ -204,6 +214,27 @@ describe('relay host desktop-only gate', () => {
204
214
  const previous = process.env.OPENCHAMBER_RUNTIME;
205
215
  process.env.OPENCHAMBER_RUNTIME = 'desktop';
206
216
  try {
217
+ expect(isRelayHostRuntime()).toBe(true);
218
+ const settings = createSettingsHarness();
219
+ const service = createRelayService({
220
+ crypto: {},
221
+ readSettingsFromDiskMigrated: settings.read,
222
+ writeSettingsToDisk: settings.write,
223
+ getLocalPort: () => 3000,
224
+ });
225
+ await service.ensureEnabledForPairing();
226
+ expect(relayMocks.starts).toEqual([DEFAULT_RELAY_URL]);
227
+ } finally {
228
+ if (previous === undefined) delete process.env.OPENCHAMBER_RUNTIME;
229
+ else process.env.OPENCHAMBER_RUNTIME = previous;
230
+ }
231
+ });
232
+
233
+ it('defaults canHostRelay from OPENCHAMBER_RUNTIME=ssh-remote', async () => {
234
+ const previous = process.env.OPENCHAMBER_RUNTIME;
235
+ process.env.OPENCHAMBER_RUNTIME = 'ssh-remote';
236
+ try {
237
+ expect(isRelayHostRuntime()).toBe(true);
207
238
  const settings = createSettingsHarness();
208
239
  const service = createRelayService({
209
240
  crypto: {},
@@ -219,10 +250,39 @@ describe('relay host desktop-only gate', () => {
219
250
  }
220
251
  });
221
252
 
222
- it('defaults canHostRelay to false when OPENCHAMBER_RUNTIME is not desktop', async () => {
253
+ it('keeps an ssh-remote opt-in host running when demand is absent', async () => {
254
+ const previous = process.env.OPENCHAMBER_RUNTIME;
255
+ process.env.OPENCHAMBER_RUNTIME = 'ssh-remote';
256
+ try {
257
+ const settings = createSettingsHarness();
258
+ await settings.write({
259
+ privateRelay: { enabled: true, relayUrl: DEFAULT_RELAY_URL },
260
+ });
261
+ const service = createRelayService({
262
+ crypto: {},
263
+ readSettingsFromDiskMigrated: settings.read,
264
+ writeSettingsToDisk: settings.write,
265
+ getLocalPort: () => 3000,
266
+ hasRelayDemand: async () => false,
267
+ });
268
+
269
+ await service.reconcile();
270
+
271
+ expect(relayMocks.starts).toEqual([DEFAULT_RELAY_URL]);
272
+ expect(settings.current()).toMatchObject({
273
+ privateRelay: { enabled: true, relayUrl: DEFAULT_RELAY_URL },
274
+ });
275
+ } finally {
276
+ if (previous === undefined) delete process.env.OPENCHAMBER_RUNTIME;
277
+ else process.env.OPENCHAMBER_RUNTIME = previous;
278
+ }
279
+ });
280
+
281
+ it('defaults canHostRelay to false when OPENCHAMBER_RUNTIME is not a host runtime', async () => {
223
282
  const previous = process.env.OPENCHAMBER_RUNTIME;
224
283
  delete process.env.OPENCHAMBER_RUNTIME;
225
284
  try {
285
+ expect(isRelayHostRuntime()).toBe(false);
226
286
  const settings = createSettingsHarness();
227
287
  const service = createRelayService({
228
288
  crypto: {},
@@ -35,14 +35,9 @@ const ALLOWED_WS_PATHS = new Set([
35
35
  '/api/dictation/ws',
36
36
  ]);
37
37
 
38
- // Client-selected SSH local-forward target (decimal port string). Valid only
39
- // when present in the injected routing table; never falls back to default port.
40
- const TARGET_PORT_HEADER = 'x-openchamber-target-port';
41
-
42
38
  // Hop-by-hop headers stripped from tunneled requests; `host` is set by fetch
43
39
  // to the loopback origin. content-length is dropped too because the body is
44
40
  // re-chunked through the tunnel and undici computes framing itself.
45
- // Target-port is host-routing metadata and must not reach the loopback origin.
46
41
  const STRIPPED_REQUEST_HEADERS = new Set([
47
42
  'connection',
48
43
  'keep-alive',
@@ -50,7 +45,6 @@ const STRIPPED_REQUEST_HEADERS = new Set([
50
45
  'upgrade',
51
46
  'host',
52
47
  'content-length',
53
- TARGET_PORT_HEADER,
54
48
  ]);
55
49
 
56
50
  // Response framing headers that no longer apply once the body crosses the
@@ -86,40 +80,10 @@ const isWsOpenPayload = (parsed) =>
86
80
 
87
81
  const isWsClosePayload = (parsed) => Boolean(parsed && typeof parsed === 'object');
88
82
 
89
- const readHeaderValue = (headers, name) => {
90
- if (!headers || typeof headers !== 'object') return undefined;
91
- const want = name.toLowerCase();
92
- for (const [key, value] of Object.entries(headers)) {
93
- if (typeof key === 'string' && key.toLowerCase() === want) return value;
94
- }
95
- return undefined;
96
- };
97
-
98
- /**
99
- * Resolve loopback dial port from optional x-openchamber-target-port.
100
- * No header → defaultPort. Header present → only a routingTable hit is accepted;
101
- * miss/invalid returns null (caller must not fall back to defaultPort).
102
- * @param {Record<string, string> | undefined} requestHeaders
103
- * @param {number} defaultPort
104
- * @param {() => { id?: string, localPort?: number }[]} getSshRoutingTable
105
- * @returns {number | null}
106
- */
107
- export const resolveTargetPort = (requestHeaders, defaultPort, getSshRoutingTable) => {
108
- const raw = readHeaderValue(requestHeaders, TARGET_PORT_HEADER);
109
- if (raw === undefined || raw === null || raw === '') return defaultPort;
110
- const parsed = typeof raw === 'number' ? raw : Number.parseInt(String(raw).trim(), 10);
111
- if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 65535) return null;
112
- const table = typeof getSshRoutingTable === 'function' ? getSshRoutingTable() : [];
113
- if (!Array.isArray(table)) return null;
114
- const hit = table.some((entry) => Number(entry?.localPort) === parsed);
115
- return hit ? parsed : null;
116
- };
117
-
118
83
  /**
119
84
  * @param {{
120
85
  * connectionId: string,
121
86
  * getLocalPort: () => number,
122
- * getSshRoutingTable?: () => { id: string, localPort: number }[],
123
87
  * sendFrame: (plaintextFrame: Uint8Array) => void | Promise<void>,
124
88
  * getBufferedAmount: () => number,
125
89
  * createWebSocket?: (url: string, protocols: string[] | undefined, options: { headers: Record<string, string> }) => import('ws').WebSocket,
@@ -128,7 +92,6 @@ export const resolveTargetPort = (requestHeaders, defaultPort, getSshRoutingTabl
128
92
  export const createTunnelHost = ({
129
93
  connectionId,
130
94
  getLocalPort,
131
- getSshRoutingTable = () => [],
132
95
  sendFrame,
133
96
  getBufferedAmount,
134
97
  createWebSocket = (url, protocols, options) => new WebSocket(url, protocols, options),
@@ -235,13 +198,7 @@ export const createTunnelHost = ({
235
198
  const stream = streams.get(streamId);
236
199
  if (!stream || stream.kind !== 'http') return;
237
200
 
238
- const targetPort = resolveTargetPort(request.headers, getLocalPort(), getSshRoutingTable);
239
- if (targetPort === null) {
240
- dropStream(streamId);
241
- await syntheticResponse(streamId, 503, 'ssh-host-unreachable');
242
- return;
243
- }
244
-
201
+ const targetPort = getLocalPort();
245
202
  const hasBody = method !== 'GET' && method !== 'HEAD';
246
203
  let requestBody;
247
204
  if (hasBody) {
@@ -368,14 +325,7 @@ export const createTunnelHost = ({
368
325
  return;
369
326
  }
370
327
 
371
- // Optional headers on WsOpen carry routing metadata (e.g. target-port).
372
- // Same resolve rules as HTTP; miss refuses without falling back to default.
373
- const targetPort = resolveTargetPort(open.headers, getLocalPort(), getSshRoutingTable);
374
- if (targetPort === null) {
375
- void sendAbort(streamId, 'ssh-host-unreachable');
376
- return;
377
- }
378
-
328
+ const targetPort = getLocalPort();
379
329
  const url = `ws://127.0.0.1:${targetPort}${open.path}${open.query ? `?${open.query}` : ''}`;
380
330
  // Present the loopback origin we're actually dialing. The server derives this
381
331
  // as a trusted same-origin candidate from the Host header (127.0.0.1:<port>),