@openchambery/web 1.18.4-beta.1 → 1.18.4-beta.11

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 (113) 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-DAnelAnB.js} +1 -1
  6. package/dist/assets/{AssistantView-hlKg0oWu.js → AssistantView-Dl2rwQzF.js} +1 -1
  7. package/dist/assets/{ChatContainer-DNED1Svm.js → ChatContainer-CtKyTQ5-.js} +20 -20
  8. package/dist/assets/{DiagramView-XeulOlNU.js → DiagramView-DvuaSPQZ.js} +1 -1
  9. package/dist/assets/{FadeInOnReveal-98buIVdv.js → FadeInOnReveal-QxF1vuqX.js} +1 -1
  10. package/dist/assets/{Icon-BhSwbLaF.js → Icon-D-X70irR.js} +1 -1
  11. package/dist/assets/{MarkdownRendererImpl-d9QBiK5G.js → MarkdownRendererImpl-DsFULa_y.js} +1 -1
  12. package/dist/assets/{MobileDetailNavigation-B4jWbHYc.js → MobileDetailNavigation-CPnuqBcg.js} +1 -1
  13. package/dist/assets/MobileShareBridge-XYq6kB0v.js +4 -0
  14. package/dist/assets/MobileSurface-Cv0dGADo.js +2 -0
  15. package/dist/assets/{MultiRunWindow-D_arSb26.js → MultiRunWindow-BsZfQibc.js} +1 -1
  16. package/dist/assets/{OnboardingScreen-C1n_wVb-.js → OnboardingScreen-Cx79VlTN.js} +1 -1
  17. package/dist/assets/{OverlayScrollbar-CXFN6Cqq.js → OverlayScrollbar-CHXk_jvF.js} +1 -1
  18. package/dist/assets/{SettingsView-DXfuFYmm.js → SettingsView-DygIwq6r.js} +10 -10
  19. package/dist/assets/{SettingsWindow-Doxf71u1.js → SettingsWindow-BNSN8Wn9.js} +1 -1
  20. package/dist/assets/{TerminalView-DUViq7pn.js → TerminalView-DQEZ93k2.js} +1 -1
  21. package/dist/assets/{ToolOutputDialog-CqI4XTnv.js → ToolOutputDialog-Gywk9TrI.js} +1 -1
  22. package/dist/assets/appThemeRegistry-BBfIDhTO.js +2 -0
  23. package/dist/assets/{button-BQY5ugjh.js → button-CwLFtUNT.js} +1 -1
  24. package/dist/assets/{clipboard-BvJ7NunE.js → clipboard-BfgFef1i.js} +1 -1
  25. package/dist/assets/{collapsible-CqcZGw3W.js → collapsible-CP1-_pY6.js} +1 -1
  26. package/dist/assets/{context-menu-ouoIbvaf.js → context-menu-uFxYrVjJ.js} +1 -1
  27. package/dist/assets/desktopHosts-Cr3LE55y.js +1 -0
  28. package/dist/assets/desktopRelayRestore-VfMK9fdk.js +1 -0
  29. package/dist/assets/{diagram-B8D9Klct.js → diagram-rYL80jaS.js} +1 -1
  30. package/dist/assets/{dialog-sGFkIKP2.js → dialog-GR7KoP1o.js} +1 -1
  31. package/dist/assets/{es-BuinIayO.js → es-Bi4m8Mm6.js} +3 -3
  32. package/dist/assets/{fileQueries-BSE4ycQt.js → fileQueries-CXaw4b1W.js} +1 -1
  33. package/dist/assets/{fr-CT7us58h.js → fr-B5UdepyS.js} +3 -3
  34. package/dist/assets/{gitApi-DOxZ3K8G.js → gitApi-CD0YoqcT.js} +1 -1
  35. package/dist/assets/{input-CgIoxUJc.js → input-D52Jicb1.js} +1 -1
  36. package/dist/assets/{insertionBoundaries-AR76O7-L.js → insertionBoundaries-CJoJ4JXE.js} +1 -1
  37. package/dist/assets/{ja-CCuAH4nO.js → ja-KtFuRjvm.js} +3 -3
  38. package/dist/assets/{ko-lXbhGt6P.js → ko-CWpFVSCH.js} +3 -3
  39. package/dist/assets/{main-DD_phlTS.js → main-AVNu4l5O.js} +2 -2
  40. package/dist/assets/{main-Ddk6x18e.js → main-flOPQhT-.js} +13 -13
  41. package/dist/assets/{markdown-worker-C5Om4TVK.js → markdown-worker-N9d_Z-7s.js} +1 -1
  42. package/dist/assets/{markdownTheme-BdF4C4-o.js → markdownTheme-Bl0Sy0Lf.js} +1 -1
  43. package/dist/assets/{miniChat-CZJW95dj.js → miniChat-Cw_ARk5V.js} +2 -2
  44. package/dist/assets/mobile-BMfsseTr.js +2 -0
  45. package/dist/assets/{multirun-kbDx1h6a.js → multirun-EElrKfPK.js} +1 -1
  46. package/dist/assets/persistence-vZ7H8WKh.js +3 -0
  47. package/dist/assets/pl-30X8TD5i.js +15 -0
  48. package/dist/assets/platform-CTaznShj.js +2 -0
  49. package/dist/assets/{projectMeta-CjgYEZy8.js → projectMeta-CYskpkl3.js} +2 -2
  50. package/dist/assets/{pt-BR-DWbGs1bc.js → pt-BR-Cm-Klf0E.js} +3 -3
  51. package/dist/assets/radio-Bjy3jy78.js +1 -0
  52. package/dist/assets/{renderElectronMiniChatApp-Cy5wT-ok.js → renderElectronMiniChatApp-DJXYRt_H.js} +2 -2
  53. package/dist/assets/{renderMobileApp-B_sU9Gpw.js → renderMobileApp-CpHujrim.js} +5 -5
  54. package/dist/assets/runtimeConfig-B6Y2BazF.js +2 -0
  55. package/dist/assets/{runtimeEndpointReset-DB5K_fH8.js → runtimeEndpointReset-B8mlfaXw.js} +2 -2
  56. package/dist/assets/{sortable-tabs-strip-pSnPuBXA.js → sortable-tabs-strip-BYmWSdS9.js} +1 -1
  57. package/dist/assets/src-Dbdlm5x1.css +2 -0
  58. package/dist/assets/{store-BuXogmxp.js → store-Z1MxUaP4.js} +3 -3
  59. package/dist/assets/{terminalApi-jfes-HIU.js → terminalApi-KDUc5mh2.js} +1 -1
  60. package/dist/assets/{tooltip-rN8LrxMF.js → tooltip-CaCe-GAm.js} +1 -1
  61. package/dist/assets/{uk-DgOxtvS7.js → uk-Cgc5wXrs.js} +3 -3
  62. package/dist/assets/{url-B0Ak7X6b.js → url-B-FWrnxu.js} +1 -1
  63. package/dist/assets/useAppFontEffects-DZtIwtO3.js +7 -0
  64. package/dist/assets/{useAssistantUIStore-SPh025NE.js → useAssistantUIStore-ifqNyja4.js} +1 -1
  65. package/dist/assets/{useDesktopWindowControlsLayout-BoWzBf65.js → useDesktopWindowControlsLayout-Cr5t1ig0.js} +1 -1
  66. package/dist/assets/{useEffectiveDirectory-DD8R37hZ.js → useEffectiveDirectory-CK663bcS.js} +1 -1
  67. package/dist/assets/{useFontPreferences-Bz3crRRO.js → useFontPreferences-D_LFFhFS.js} +1 -1
  68. package/dist/assets/useMobileNavigationStore-BsKna5_o.js +476 -0
  69. package/dist/assets/useSessionAutoCleanup-B9urLPq8.js +1 -0
  70. package/dist/assets/useUIStore-NfqK-HA7.js +1 -0
  71. package/dist/assets/{useWorkerHighlightedLines-C9GG1QuS.js → useWorkerHighlightedLines-BjUFjEsH.js} +1 -1
  72. package/dist/assets/{utils-DfPDhqys.js → utils-C2P0wcWO.js} +1 -1
  73. package/dist/assets/{zh-CN-BPGatJp9.js → zh-CN-BdRwDJuj.js} +3 -3
  74. package/dist/assets/{zh-TW-qUWWsaxK.js → zh-TW-C3LubKzZ.js} +3 -3
  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/opencode/DOCUMENTATION.md +4 -4
  84. package/server/lib/opencode/bootstrap-runtime.js +0 -6
  85. package/server/lib/opencode/cli-entry-runtime.js +5 -1
  86. package/server/lib/opencode/cli-entry-runtime.test.js +17 -0
  87. package/server/lib/opencode/core-routes.js +0 -3
  88. package/server/lib/opencode/core-routes.test.js +0 -15
  89. package/server/lib/opencode/openchamber-routes.js +0 -11
  90. package/server/lib/relay/DOCUMENTATION.md +3 -45
  91. package/server/lib/relay/host-client.js +1 -4
  92. package/server/lib/relay/service.js +28 -16
  93. package/server/lib/relay/service.test.js +64 -4
  94. package/server/lib/relay/tunnel-host.js +2 -52
  95. package/server/lib/relay/tunnel-host.test.js +2 -250
  96. package/dist/assets/MobileShareBridge-DrNSL4C7.js +0 -4
  97. package/dist/assets/MobileSurface-BgHhiwbU.js +0 -2
  98. package/dist/assets/appThemeRegistry-L534iVhM.js +0 -2
  99. package/dist/assets/desktopHosts-aA40MHzB.js +0 -1
  100. package/dist/assets/desktopRelayRestore-CxUYRC6v.js +0 -1
  101. package/dist/assets/mobile-YvjvWRM9.js +0 -2
  102. package/dist/assets/persistence-DwXJK4-j.js +0 -3
  103. package/dist/assets/pl-DCsKBRAg.js +0 -15
  104. package/dist/assets/platform-CXJwZIZe.js +0 -2
  105. package/dist/assets/radio-DPp74W5W.js +0 -1
  106. package/dist/assets/runtimeConfig-DuCY41vV.js +0 -2
  107. package/dist/assets/src-DF4hiLrB.css +0 -2
  108. package/dist/assets/useAppFontEffects-awkP3dQX.js +0 -7
  109. package/dist/assets/useMobileNavigationStore-DEdx_KjS.js +0 -476
  110. package/dist/assets/useSessionAutoCleanup-01QEaqnI.js +0 -1
  111. package/dist/assets/useUIStore-BJpkPwky.js +0 -1
  112. package/server/lib/desktop-hosts/routes.js +0 -157
  113. package/server/lib/desktop-hosts/routes.test.js +0 -227
@@ -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>),
@@ -7,18 +7,17 @@
7
7
  // corrupted payload. This test must stay red until send serialization is fixed.
8
8
  // Production send path is intentionally not changed here.
9
9
 
10
- import { afterEach, describe, expect, it } from 'bun:test';
10
+ import { describe, expect, it } from 'bun:test';
11
11
 
12
12
  import {
13
13
  MAX_TUNNEL_PAYLOAD_BYTES,
14
14
  TunnelFrameType,
15
15
  createFragmentAssembler,
16
- decodeJsonPayload,
17
16
  decodeTunnelFrame,
18
17
  encodeJsonPayload,
19
18
  encodeTunnelFrame,
20
19
  } from './tunnel-codec.js';
21
- import { createTunnelHost, resolveTargetPort } from './tunnel-host.js';
20
+ import { createTunnelHost } from './tunnel-host.js';
22
21
 
23
22
  const textEncoder = new TextEncoder();
24
23
  const textDecoder = new TextDecoder();
@@ -224,250 +223,3 @@ describe('createTunnelHost WS fragment ordering', () => {
224
223
  }
225
224
  });
226
225
  });
227
-
228
- const collectHttpExchange = async (outbound, streamId) => {
229
- await waitFor(() => outbound.some((frame) => {
230
- const decoded = decodeTunnelFrame(frame);
231
- return decoded.streamId === streamId
232
- && (decoded.frameType === TunnelFrameType.StreamEnd
233
- || decoded.frameType === TunnelFrameType.StreamAbort);
234
- }));
235
- let status = null;
236
- /** @type {Uint8Array[]} */
237
- const bodyChunks = [];
238
- for (const raw of outbound) {
239
- const frame = decodeTunnelFrame(raw);
240
- if (frame.streamId !== streamId) continue;
241
- if (frame.frameType === TunnelFrameType.HttpResponse) {
242
- status = decodeJsonPayload(frame.payload, (v) => v && typeof v.status === 'number').status;
243
- } else if (frame.frameType === TunnelFrameType.HttpBody) {
244
- bodyChunks.push(frame.payload);
245
- }
246
- }
247
- let body = null;
248
- if (bodyChunks.length > 0) {
249
- const total = bodyChunks.reduce((n, c) => n + c.length, 0);
250
- const merged = new Uint8Array(total);
251
- let off = 0;
252
- for (const c of bodyChunks) {
253
- merged.set(c, off);
254
- off += c.length;
255
- }
256
- body = JSON.parse(textDecoder.decode(merged));
257
- }
258
- return { status, body };
259
- };
260
-
261
- describe('createTunnelHost SSH target-port routing', () => {
262
- /** @type {typeof globalThis.fetch | undefined} */
263
- let originalFetch;
264
-
265
- afterEach(() => {
266
- if (originalFetch) {
267
- globalThis.fetch = originalFetch;
268
- originalFetch = undefined;
269
- }
270
- });
271
-
272
- const installFetchProbe = () => {
273
- /** @type {{ url: string, headers: Record<string, string> }[]} */
274
- const calls = [];
275
- originalFetch = globalThis.fetch;
276
- globalThis.fetch = async (url, init) => {
277
- calls.push({
278
- url: String(url),
279
- headers: { ...(init?.headers || {}) },
280
- });
281
- return new Response(JSON.stringify({ ok: true }), {
282
- status: 200,
283
- headers: { 'content-type': 'application/json' },
284
- });
285
- };
286
- return calls;
287
- };
288
-
289
- it('dials the routing-table port when x-openchamber-target-port is allowed', async () => {
290
- const fetchCalls = installFetchProbe();
291
- /** @type {Uint8Array[]} */
292
- const outbound = [];
293
- const host = createTunnelHost({
294
- connectionId: 'test-conn',
295
- getLocalPort: () => 3000,
296
- getSshRoutingTable: () => [{ id: 'ssh-1', localPort: 41234 }],
297
- sendFrame: (frame) => { outbound.push(frame); },
298
- getBufferedAmount: () => 0,
299
- });
300
-
301
- const streamId = 11;
302
- await host.handleFrame(encodeTunnelFrame(
303
- TunnelFrameType.HttpRequest,
304
- streamId,
305
- encodeJsonPayload({
306
- method: 'GET',
307
- path: '/health',
308
- query: '',
309
- headers: { 'x-openchamber-target-port': '41234', accept: 'application/json' },
310
- }),
311
- ));
312
-
313
- const exchange = await collectHttpExchange(outbound, streamId);
314
- expect(exchange.status).toBe(200);
315
- expect(fetchCalls).toHaveLength(1);
316
- expect(fetchCalls[0].url).toBe('http://127.0.0.1:41234/health');
317
- host.close();
318
- });
319
-
320
- it('returns 503 and does not fall back when target-port is unknown', async () => {
321
- const fetchCalls = installFetchProbe();
322
- /** @type {Uint8Array[]} */
323
- const outbound = [];
324
- const host = createTunnelHost({
325
- connectionId: 'test-conn',
326
- getLocalPort: () => 3000,
327
- getSshRoutingTable: () => [{ id: 'ssh-1', localPort: 41234 }],
328
- sendFrame: (frame) => { outbound.push(frame); },
329
- getBufferedAmount: () => 0,
330
- });
331
-
332
- const streamId = 12;
333
- await host.handleFrame(encodeTunnelFrame(
334
- TunnelFrameType.HttpRequest,
335
- streamId,
336
- encodeJsonPayload({
337
- method: 'GET',
338
- path: '/health',
339
- query: '',
340
- headers: { 'x-openchamber-target-port': '9999' },
341
- }),
342
- ));
343
-
344
- const exchange = await collectHttpExchange(outbound, streamId);
345
- expect(exchange.status).toBe(503);
346
- expect(exchange.body?.error).toBe('ssh-host-unreachable');
347
- expect(fetchCalls).toHaveLength(0);
348
- host.close();
349
- });
350
-
351
- it('strips x-openchamber-target-port before forwarding to loopback', async () => {
352
- const fetchCalls = installFetchProbe();
353
- /** @type {Uint8Array[]} */
354
- const outbound = [];
355
- const host = createTunnelHost({
356
- connectionId: 'test-conn',
357
- getLocalPort: () => 3000,
358
- getSshRoutingTable: () => [{ id: 'ssh-1', localPort: 41234 }],
359
- sendFrame: (frame) => { outbound.push(frame); },
360
- getBufferedAmount: () => 0,
361
- });
362
-
363
- const streamId = 13;
364
- await host.handleFrame(encodeTunnelFrame(
365
- TunnelFrameType.HttpRequest,
366
- streamId,
367
- encodeJsonPayload({
368
- method: 'GET',
369
- path: '/api/version',
370
- query: '',
371
- headers: {
372
- 'x-openchamber-target-port': '41234',
373
- authorization: 'Bearer client-token',
374
- },
375
- }),
376
- ));
377
-
378
- await collectHttpExchange(outbound, streamId);
379
- expect(fetchCalls).toHaveLength(1);
380
- expect(fetchCalls[0].headers['x-openchamber-target-port']).toBeUndefined();
381
- expect(fetchCalls[0].headers.authorization).toBe('Bearer client-token');
382
- expect(fetchCalls[0].headers['x-openchamber-relay-connection']).toBe('test-conn');
383
- host.close();
384
- });
385
-
386
- it('dials the default local port when target-port header is absent', async () => {
387
- const fetchCalls = installFetchProbe();
388
- /** @type {Uint8Array[]} */
389
- const outbound = [];
390
- const host = createTunnelHost({
391
- connectionId: 'test-conn',
392
- getLocalPort: () => 3000,
393
- getSshRoutingTable: () => [{ id: 'ssh-1', localPort: 41234 }],
394
- sendFrame: (frame) => { outbound.push(frame); },
395
- getBufferedAmount: () => 0,
396
- });
397
-
398
- const streamId = 14;
399
- await host.handleFrame(encodeTunnelFrame(
400
- TunnelFrameType.HttpRequest,
401
- streamId,
402
- encodeJsonPayload({
403
- method: 'GET',
404
- path: '/health',
405
- query: '',
406
- headers: { accept: 'application/json' },
407
- }),
408
- ));
409
-
410
- await collectHttpExchange(outbound, streamId);
411
- expect(fetchCalls).toHaveLength(1);
412
- expect(fetchCalls[0].url).toBe('http://127.0.0.1:3000/health');
413
- host.close();
414
- });
415
-
416
- it('WS open dials routing-table port and aborts on miss without fallback', async () => {
417
- FakeWebSocket.reset();
418
- /** @type {Uint8Array[]} */
419
- const outbound = [];
420
- const host = createTunnelHost({
421
- connectionId: 'test-conn',
422
- getLocalPort: () => 3000,
423
- getSshRoutingTable: () => [{ id: 'ssh-1', localPort: 41234 }],
424
- sendFrame: (frame) => { outbound.push(frame); },
425
- getBufferedAmount: () => 0,
426
- createWebSocket: (url, protocols) => new FakeWebSocket(url, protocols),
427
- });
428
-
429
- await host.handleFrame(encodeTunnelFrame(
430
- TunnelFrameType.WsOpen,
431
- 21,
432
- encodeJsonPayload({
433
- path: '/api/event/ws',
434
- query: '',
435
- headers: { 'x-openchamber-target-port': '41234' },
436
- }),
437
- ));
438
- expect(FakeWebSocket.instances).toHaveLength(1);
439
- expect(FakeWebSocket.instances[0].url).toBe('ws://127.0.0.1:41234/api/event/ws');
440
-
441
- await host.handleFrame(encodeTunnelFrame(
442
- TunnelFrameType.WsOpen,
443
- 22,
444
- encodeJsonPayload({
445
- path: '/api/event/ws',
446
- query: '',
447
- headers: { 'x-openchamber-target-port': '9999' },
448
- }),
449
- ));
450
- // Miss must not open another socket (no fallback to 3000).
451
- expect(FakeWebSocket.instances).toHaveLength(1);
452
- await waitFor(() => outbound.some((frame) => {
453
- const decoded = decodeTunnelFrame(frame);
454
- return decoded.streamId === 22 && decoded.frameType === TunnelFrameType.StreamAbort;
455
- }));
456
- const abort = outbound
457
- .map((frame) => decodeTunnelFrame(frame))
458
- .find((frame) => frame.streamId === 22 && frame.frameType === TunnelFrameType.StreamAbort);
459
- expect(decodeJsonPayload(abort.payload, () => true).reason).toBe('ssh-host-unreachable');
460
- host.close();
461
- });
462
- });
463
-
464
- describe('resolveTargetPort', () => {
465
- it('returns default when header is absent and null on table miss', () => {
466
- const table = () => [{ id: 'ssh-1', localPort: 41234 }];
467
- expect(resolveTargetPort({}, 3000, table)).toBe(3000);
468
- expect(resolveTargetPort({ 'x-openchamber-target-port': '41234' }, 3000, table)).toBe(41234);
469
- expect(resolveTargetPort({ 'X-OpenChamber-Target-Port': '41234' }, 3000, table)).toBe(41234);
470
- expect(resolveTargetPort({ 'x-openchamber-target-port': '9999' }, 3000, table)).toBeNull();
471
- expect(resolveTargetPort({ 'x-openchamber-target-port': 'not-a-port' }, 3000, table)).toBeNull();
472
- });
473
- });