@openchambery/web 1.17.1 → 1.18.0

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 (130) hide show
  1. package/bin/lib/DOCUMENTATION.md +1 -0
  2. package/dist/assets/{AgentAvatar-C6Hxu5_o.js → AgentAvatar-B2kZjgaf.js} +1 -1
  3. package/dist/assets/{AssistantView-CbnxMZU0.js → AssistantView-Dc9MhgcD.js} +1 -1
  4. package/dist/assets/{ChatContainer-DEDGosbz.js → ChatContainer-CA3RO1yP.js} +40 -40
  5. package/dist/assets/{DiagramView-Byxhaduz.js → DiagramView-CpT4q2Jg.js} +1 -1
  6. package/dist/assets/{FadeInOnReveal-CvNA_aGD.js → FadeInOnReveal-aeWlaqVB.js} +1 -1
  7. package/dist/assets/{Icon-BPJ_quEe.js → Icon-AXqa-fYB.js} +1 -1
  8. package/dist/assets/{MarkdownRendererImpl-Cv8V-Q5l.js → MarkdownRendererImpl-DLnXrjCS.js} +2 -2
  9. package/dist/assets/{MobileDetailNavigation-lVtCe-5P.js → MobileDetailNavigation-BYX8jFRg.js} +1 -1
  10. package/dist/assets/MobileShareBridge-BJzGsvUa.js +4 -0
  11. package/dist/assets/MobileSurface-Cfe17dw4.js +2 -0
  12. package/dist/assets/{MultiRunWindow-1kkKTh4h.js → MultiRunWindow-DEYIMkSg.js} +1 -1
  13. package/dist/assets/{OnboardingScreen-CdvaEBtt.js → OnboardingScreen-CCfdEtW6.js} +1 -1
  14. package/dist/assets/{OverlayScrollbar-Dqk1zGJB.js → OverlayScrollbar-CpQvV-qY.js} +1 -1
  15. package/dist/assets/SettingsView-PjA5aLCR.js +25 -0
  16. package/dist/assets/{SettingsWindow-DMQMRgc3.js → SettingsWindow-DOe4sCjl.js} +1 -1
  17. package/dist/assets/{TerminalView-jYq0JQPc.js → TerminalView-Bf-2wMV0.js} +1 -1
  18. package/dist/assets/{ToolOutputDialog-DJefd2N6.js → ToolOutputDialog-DuT5ne12.js} +1 -1
  19. package/dist/assets/{appThemeRegistry-BXeIggYO.js → appThemeRegistry--wNZ5yxf.js} +2 -2
  20. package/dist/assets/{button-BmNJBT13.js → button-CDvMVaiO.js} +1 -1
  21. package/dist/assets/{clipboard-CXWbHCAk.js → clipboard-Jdqe05db.js} +1 -1
  22. package/dist/assets/{collapsible-68IqWBM1.js → collapsible-C2aamDvD.js} +1 -1
  23. package/dist/assets/{context-menu-BbRpMBQh.js → context-menu-CS_u7xxB.js} +1 -1
  24. package/dist/assets/desktopHosts-CBoBpJ1Z.js +1 -0
  25. package/dist/assets/desktopRelayRestore-l2R9muux.js +1 -0
  26. package/dist/assets/{diagram-YN0GZ1Q0.js → diagram-CEon0BQs.js} +1 -1
  27. package/dist/assets/{dialog-BV1ENlLf.js → dialog-BzZ3cKxG.js} +1 -1
  28. package/dist/assets/es-C25PRSjo.js +15 -0
  29. package/dist/assets/{fileQueries-i4oziMnC.js → fileQueries-CE_DaZWQ.js} +1 -1
  30. package/dist/assets/fr-Cv8ZWQdW.js +13 -0
  31. package/dist/assets/{gitApi-Bs_2iBAm.js → gitApi-DSLGdswL.js} +3 -3
  32. package/dist/assets/gitApiHttp-D8j1qRH4.js +2 -0
  33. package/dist/assets/{input-BLVhPvhN.js → input-k9DGGyZ6.js} +1 -1
  34. package/dist/assets/insertionBoundaries-BkoTIR-0.js +1 -0
  35. package/dist/assets/{ja-Dcb0VtDE.js → ja-Ue9m9eSe.js} +3 -3
  36. package/dist/assets/{ko-CX71xNDX.js → ko-DWBUCCV6.js} +3 -3
  37. package/dist/assets/main-CTnxV3y3.js +172 -0
  38. package/dist/assets/{main-CT0DuuLm.js → main-D1Xze0xA.js} +2 -2
  39. package/dist/assets/{markdown-worker-Cq3Twp1A.js → markdown-worker-DkeWiER6.js} +1 -1
  40. package/dist/assets/{markdownTheme-CCnvI6L0.js → markdownTheme-DNT93i5L.js} +1 -1
  41. package/dist/assets/miniChat-Dxajshg_.js +2 -0
  42. package/dist/assets/mobile-CisSv-ca.js +2 -0
  43. package/dist/assets/multirun-BOb2dW9T.js +4 -0
  44. package/dist/assets/persistence-DPNMIbF5.js +3 -0
  45. package/dist/assets/{pl-mR_3Sqr8.js → pl-DaBaPCyE.js} +3 -3
  46. package/dist/assets/projectMeta-WTDhB__y.js +3 -0
  47. package/dist/assets/pt-BR-C2cJZ43o.js +15 -0
  48. package/dist/assets/radio-DZwuHb7-.js +1 -0
  49. package/dist/assets/renderElectronMiniChatApp-DpYFpBSy.js +2 -0
  50. package/dist/assets/renderMobileApp-DB1TXtLg.js +23 -0
  51. package/dist/assets/runtimeConfig-BtWi74Pm.js +1 -0
  52. package/dist/assets/runtimeEndpointReset-BnA1DoNG.js +145 -0
  53. package/dist/assets/{sortable-tabs-strip-oKJ700WG.js → sortable-tabs-strip-DADoz-JR.js} +1 -1
  54. package/dist/assets/src-oHrYIqHF.css +2 -0
  55. package/dist/assets/{store-oojlrF4d.js → store-SqV3zfE8.js} +4 -4
  56. package/dist/assets/{terminalApi-BYTA045F.js → terminalApi-tB3qLcfa.js} +1 -1
  57. package/dist/assets/{tooltip-BYGBBTc2.js → tooltip-DMNR5rN0.js} +1 -1
  58. package/dist/assets/uk-DJz_P8OZ.js +15 -0
  59. package/dist/assets/{url-CGHw6D-E.js → url-7MqoLs9X.js} +1 -1
  60. package/dist/assets/useAppFontEffects-CeS0e0FK.js +7 -0
  61. package/dist/assets/{useAssistantUIStore-ESTV1mEY.js → useAssistantUIStore-Bq3cPj2x.js} +1 -1
  62. package/dist/assets/{useDesktopWindowControlsLayout-Cq5a2gcJ.js → useDesktopWindowControlsLayout-DwFMUFtC.js} +1 -1
  63. package/dist/assets/{useEffectiveDirectory-DG6uPzBG.js → useEffectiveDirectory-Dp8McufI.js} +1 -1
  64. package/dist/assets/{useFontPreferences-Bu4ME5hk.js → useFontPreferences-CIurCeNv.js} +1 -1
  65. package/dist/assets/useMobileNavigationStore-kWPnKBP1.js +475 -0
  66. package/dist/assets/useSessionAutoCleanup-BLiIZW0x.js +1 -0
  67. package/dist/assets/useUIStore-5agS8bsW.js +1 -0
  68. package/dist/assets/{useWorkerHighlightedLines-ADgu9578.js → useWorkerHighlightedLines-Cnxz7HaG.js} +1 -1
  69. package/dist/assets/{utils-DyUyiLje.js → utils-Co19EJEL.js} +1 -1
  70. package/dist/assets/zh-CN-DLAKDOM9.js +15 -0
  71. package/dist/assets/zh-TW-s3Jjx1_n.js +15 -0
  72. package/dist/index.html +9 -9
  73. package/dist/mini-chat.html +9 -9
  74. package/dist/mobile.html +9 -9
  75. package/package.json +1 -1
  76. package/server/index.d.ts +4 -0
  77. package/server/index.js +29 -15
  78. package/server/lib/client-auth/pairing.js +7 -1
  79. package/server/lib/client-auth/pairing.test.js +45 -0
  80. package/server/lib/desktop-hosts/routes.js +157 -0
  81. package/server/lib/desktop-hosts/routes.test.js +227 -0
  82. package/server/lib/message-queue/DOCUMENTATION.md +1 -1
  83. package/server/lib/message-queue/routes.js +1 -1
  84. package/server/lib/message-queue/routes.test.js +1 -0
  85. package/server/lib/message-queue/service.js +12 -1
  86. package/server/lib/message-queue/service.test.js +30 -0
  87. package/server/lib/opencode/DOCUMENTATION.md +4 -4
  88. package/server/lib/opencode/bootstrap-runtime.js +7 -0
  89. package/server/lib/opencode/cli-entry-runtime.js +6 -0
  90. package/server/lib/opencode/cli-entry-runtime.test.js +43 -0
  91. package/server/lib/opencode/core-routes.js +4 -1
  92. package/server/lib/opencode/core-routes.test.js +29 -0
  93. package/server/lib/opencode/openchamber-routes.js +12 -0
  94. package/server/lib/opencode/settings-runtime.js +1 -1
  95. package/server/lib/package-manager.js +3 -1
  96. package/server/lib/relay/DOCUMENTATION.md +44 -1
  97. package/server/lib/relay/host-client.js +4 -1
  98. package/server/lib/relay/service.js +16 -8
  99. package/server/lib/relay/service.test.js +21 -6
  100. package/server/lib/relay/tunnel-host.js +57 -4
  101. package/server/lib/relay/tunnel-host.test.js +250 -2
  102. package/dist/assets/MobileShareBridge-DAlvgjo1.js +0 -4
  103. package/dist/assets/MobileSurface-Bde8yLWo.js +0 -2
  104. package/dist/assets/SettingsView-D0dhTIur.js +0 -25
  105. package/dist/assets/desktopHosts-BpjUSDz4.js +0 -1
  106. package/dist/assets/desktopRelayRestore-BzUEk1i7.js +0 -1
  107. package/dist/assets/es-EXZUX1sB.js +0 -15
  108. package/dist/assets/fr-jWNPO91J.js +0 -13
  109. package/dist/assets/gitApiHttp-C1DaEMKR.js +0 -2
  110. package/dist/assets/insertionBoundaries-BAFtAotq.js +0 -1
  111. package/dist/assets/main-G8tKFcTV.js +0 -172
  112. package/dist/assets/miniChat-BP3OHqNF.js +0 -2
  113. package/dist/assets/mobile-BtwuDs-C.js +0 -2
  114. package/dist/assets/multirun-B-L3JH60.js +0 -4
  115. package/dist/assets/persistence-C_KCiMcY.js +0 -3
  116. package/dist/assets/projectMeta-Cp74cItv.js +0 -3
  117. package/dist/assets/pt-BR-D1RpVOvC.js +0 -15
  118. package/dist/assets/radio-Bs4mdPv5.js +0 -1
  119. package/dist/assets/renderElectronMiniChatApp-dgirEOtR.js +0 -2
  120. package/dist/assets/renderMobileApp-DK9qzy0P.js +0 -23
  121. package/dist/assets/runtimeConfig-JSaYFA5x.js +0 -1
  122. package/dist/assets/runtimeEndpointReset-C0eCWIE6.js +0 -145
  123. package/dist/assets/src-8zziWB78.css +0 -2
  124. package/dist/assets/uk-UvnVqWv-.js +0 -15
  125. package/dist/assets/useAppFontEffects-BhLcUfRB.js +0 -7
  126. package/dist/assets/useMobileNavigationStore-1xHjnAsg.js +0 -475
  127. package/dist/assets/useSessionAutoCleanup-BFletAeV.js +0 -1
  128. package/dist/assets/useUIStore-D6BTB4va.js +0 -1
  129. package/dist/assets/zh-CN-9v-RC966.js +0 -15
  130. package/dist/assets/zh-TW-BZ_wZdC-.js +0 -15
@@ -54,12 +54,14 @@ 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 }[],
57
58
  * onStatus?: (status: { state: string, lastError: string | null, connectedClients: number }) => void,
58
59
  * logger?: Pick<Console, 'warn'>,
59
60
  * }} options
60
61
  */
61
- export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, onStatus, logger = console, batchWindowMs, batch }) => {
62
+ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, getSshRoutingTable = () => [], onStatus, logger = console, batchWindowMs, batch }) => {
62
63
  const resolveLocalPort = typeof getLocalPort === 'function' ? getLocalPort : () => localPort;
64
+ const resolveSshRoutingTable = typeof getSshRoutingTable === 'function' ? getSshRoutingTable : () => [];
63
65
  const localBatch = batch !== false;
64
66
  const resolvedBatchWindowMs = resolveBatchWindowMs(batchWindowMs);
65
67
 
@@ -188,6 +190,7 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
188
190
  entry.tunnel = createTunnelHost({
189
191
  connectionId,
190
192
  getLocalPort: resolveLocalPort,
193
+ getSshRoutingTable: resolveSshRoutingTable,
191
194
  getBufferedAmount: () => socket.bufferedAmount,
192
195
  sendFrame: (plaintextFrame) => {
193
196
  if (dataSockets.get(connectionId) !== entry || socket.readyState !== WebSocket.OPEN) return;
@@ -9,8 +9,9 @@
9
9
  // Host gate: only the Electron desktop runtime may run a relay host
10
10
  // (OPENCHAMBER_RUNTIME=desktop, set before the in-process web server starts).
11
11
  // Plain `node server`, `dev:web:hmr`, VS Code, CLI, and browser clients must
12
- // never open the host-control socket or advertise a host pairing candidate.
13
- // They may still be relay *clients* when connecting to another host.
12
+ // never open the host-control socket, mint a relay identity, advertise a host
13
+ // pairing candidate, or probe the relay. They may still be relay *clients*
14
+ // when connecting to another host.
14
15
 
15
16
  import express from 'express';
16
17
 
@@ -68,6 +69,7 @@ const envRelayUrlOverride = () => {
68
69
  * readSettingsFromDiskMigrated: () => Promise<object>,
69
70
  * writeSettingsToDisk: (settings: object) => Promise<void>,
70
71
  * getLocalPort: () => number,
72
+ * getSshRoutingTable?: () => { id: string, localPort: number }[],
71
73
  * logger?: Pick<Console, 'warn'>,
72
74
  * canHostRelay?: () => boolean,
73
75
  * }} deps
@@ -80,6 +82,8 @@ export const createRelayService = ({
80
82
  // regeneration — see identity.js/signing-key.js.
81
83
  readSettingsStrict,
82
84
  getLocalPort,
85
+ // Live SSH local-forward ports (Electron). Empty outside desktop.
86
+ getSshRoutingTable = () => [],
83
87
  // Returns true when any paired device or pending pairing session uses the
84
88
  // relay transport. The relay lifecycle is driven purely by this demand.
85
89
  hasRelayDemand = async () => false,
@@ -207,6 +211,7 @@ export const createRelayService = ({
207
211
  relayUrl,
208
212
  identity,
209
213
  getLocalPort,
214
+ getSshRoutingTable,
210
215
  logger,
211
216
  onStatus: (next) => {
212
217
  status = next;
@@ -265,27 +270,27 @@ export const createRelayService = ({
265
270
  // Stable server identity (base64url SHA-256 of the canonical public signing
266
271
  // JWK). Derived from a public key, so it is not a secret; clients use it to
267
272
  // verify that a learned/probed address belongs to this server before trusting
268
- // 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.
269
275
  const getServerId = async () => {
276
+ if (!hostAllowed()) return null;
270
277
  const identity = await identityRuntime.getRelayIdentity();
271
278
  return identity.serverId;
272
279
  };
273
280
 
274
281
  const getStatus = async () => {
275
- const config = await readConfig();
276
- const identity = await identityRuntime.getRelayIdentity();
277
282
  if (!hostAllowed()) {
278
283
  return {
279
284
  enabled: false,
280
285
  hostAllowed: false,
281
286
  state: 'unavailable',
282
- serverId: identity.serverId,
287
+ serverId: null,
283
288
  connectedClients: 0,
284
- relayUrl: config.relayUrl,
285
- relayUrlLocked: config.relayUrlLocked,
286
289
  lastError: RELAY_HOST_DESKTOP_ONLY_MESSAGE,
287
290
  };
288
291
  }
292
+ const config = await readConfig();
293
+ const identity = await identityRuntime.getRelayIdentity();
289
294
  const live = hostClient ? hostClient.getStatus() : status;
290
295
  return {
291
296
  enabled: config.enabled,
@@ -404,6 +409,9 @@ export const createRelayService = ({
404
409
 
405
410
  app.post('/api/openchamber/relay/disable', async (_req, res) => {
406
411
  try {
412
+ if (!hostAllowed()) {
413
+ return res.status(403).json({ error: RELAY_HOST_DESKTOP_ONLY_MESSAGE });
414
+ }
407
415
  const current = await readConfig();
408
416
  await writeConfig({ enabled: false, relayUrl: current.relayUrl });
409
417
  stop();
@@ -5,14 +5,18 @@ import request from 'supertest';
5
5
  const relayMocks = vi.hoisted(() => ({
6
6
  starts: [],
7
7
  stops: [],
8
+ identityReads: 0,
8
9
  }));
9
10
 
10
11
  vi.mock('./identity.js', () => ({
11
12
  createRelayIdentityRuntime: () => ({
12
- getRelayIdentity: async () => ({
13
- serverId: 'server-test',
14
- hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: 'x', y: 'y' },
15
- }),
13
+ getRelayIdentity: async () => {
14
+ relayMocks.identityReads += 1;
15
+ return {
16
+ serverId: 'server-test',
17
+ hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: 'x', y: 'y' },
18
+ };
19
+ },
16
20
  }),
17
21
  }));
18
22
 
@@ -59,6 +63,7 @@ describe('relay service pairing endpoint', () => {
59
63
  afterEach(() => {
60
64
  relayMocks.starts.length = 0;
61
65
  relayMocks.stops.length = 0;
66
+ relayMocks.identityReads = 0;
62
67
  delete process.env.OPENCHAMBER_RELAY_URL;
63
68
  });
64
69
 
@@ -142,9 +147,10 @@ describe('relay host desktop-only gate', () => {
142
147
  afterEach(() => {
143
148
  relayMocks.starts.length = 0;
144
149
  relayMocks.stops.length = 0;
150
+ relayMocks.identityReads = 0;
145
151
  });
146
152
 
147
- it('does not start a host, claim, or rewrite settings when reconcile runs off desktop', async () => {
153
+ it('does not start a host, claim, mint identity, or rewrite settings when reconcile runs off desktop', async () => {
148
154
  const write = vi.fn(async (next) => next);
149
155
  const { service, settings } = createTestRelayService({
150
156
  canHostRelay: false,
@@ -157,12 +163,16 @@ describe('relay host desktop-only gate', () => {
157
163
  expect(relayMocks.starts).toEqual([]);
158
164
  expect(write).not.toHaveBeenCalled();
159
165
  expect(settings.current()).toEqual({});
160
- await expect(service.getStatus()).resolves.toMatchObject({
166
+ await expect(service.getStatus()).resolves.toEqual({
161
167
  enabled: false,
162
168
  hostAllowed: false,
163
169
  state: 'unavailable',
170
+ serverId: null,
171
+ connectedClients: 0,
164
172
  lastError: RELAY_HOST_DESKTOP_ONLY_MESSAGE,
165
173
  });
174
+ await expect(service.getServerId()).resolves.toBeNull();
175
+ expect(relayMocks.identityReads).toBe(0);
166
176
  });
167
177
 
168
178
  it('refuses relay pairing and enable off desktop', async () => {
@@ -181,6 +191,11 @@ describe('relay host desktop-only gate', () => {
181
191
  .send({})
182
192
  .expect(403, { error: RELAY_HOST_DESKTOP_ONLY_MESSAGE });
183
193
 
194
+ await request(app)
195
+ .post('/api/openchamber/relay/disable')
196
+ .send({})
197
+ .expect(403, { error: RELAY_HOST_DESKTOP_ONLY_MESSAGE });
198
+
184
199
  expect(relayMocks.starts).toEqual([]);
185
200
  expect(settings.current()).toEqual({});
186
201
  });
@@ -35,9 +35,14 @@ 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
+
38
42
  // Hop-by-hop headers stripped from tunneled requests; `host` is set by fetch
39
43
  // to the loopback origin. content-length is dropped too because the body is
40
44
  // re-chunked through the tunnel and undici computes framing itself.
45
+ // Target-port is host-routing metadata and must not reach the loopback origin.
41
46
  const STRIPPED_REQUEST_HEADERS = new Set([
42
47
  'connection',
43
48
  'keep-alive',
@@ -45,6 +50,7 @@ const STRIPPED_REQUEST_HEADERS = new Set([
45
50
  'upgrade',
46
51
  'host',
47
52
  'content-length',
53
+ TARGET_PORT_HEADER,
48
54
  ]);
49
55
 
50
56
  // Response framing headers that no longer apply once the body crosses the
@@ -75,14 +81,45 @@ const isWsOpenPayload = (parsed) =>
75
81
  Boolean(parsed && typeof parsed === 'object'
76
82
  && typeof parsed.path === 'string'
77
83
  && typeof parsed.query === 'string'
78
- && (parsed.protocols === undefined || Array.isArray(parsed.protocols)));
84
+ && (parsed.protocols === undefined || Array.isArray(parsed.protocols))
85
+ && (parsed.headers === undefined || (parsed.headers && typeof parsed.headers === 'object')));
79
86
 
80
87
  const isWsClosePayload = (parsed) => Boolean(parsed && typeof parsed === 'object');
81
88
 
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
+
82
118
  /**
83
119
  * @param {{
84
120
  * connectionId: string,
85
121
  * getLocalPort: () => number,
122
+ * getSshRoutingTable?: () => { id: string, localPort: number }[],
86
123
  * sendFrame: (plaintextFrame: Uint8Array) => void | Promise<void>,
87
124
  * getBufferedAmount: () => number,
88
125
  * createWebSocket?: (url: string, protocols: string[] | undefined, options: { headers: Record<string, string> }) => import('ws').WebSocket,
@@ -91,6 +128,7 @@ const isWsClosePayload = (parsed) => Boolean(parsed && typeof parsed === 'object
91
128
  export const createTunnelHost = ({
92
129
  connectionId,
93
130
  getLocalPort,
131
+ getSshRoutingTable = () => [],
94
132
  sendFrame,
95
133
  getBufferedAmount,
96
134
  createWebSocket = (url, protocols, options) => new WebSocket(url, protocols, options),
@@ -197,6 +235,13 @@ export const createTunnelHost = ({
197
235
  const stream = streams.get(streamId);
198
236
  if (!stream || stream.kind !== 'http') return;
199
237
 
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
+
200
245
  const hasBody = method !== 'GET' && method !== 'HEAD';
201
246
  let requestBody;
202
247
  if (hasBody) {
@@ -210,7 +255,7 @@ export const createTunnelHost = ({
210
255
  stream.noBody = true;
211
256
  }
212
257
 
213
- const url = `http://127.0.0.1:${getLocalPort()}${request.path}${request.query ? `?${request.query}` : ''}`;
258
+ const url = `http://127.0.0.1:${targetPort}${request.path}${request.query ? `?${request.query}` : ''}`;
214
259
  let response;
215
260
  try {
216
261
  response = await fetch(url, {
@@ -323,7 +368,15 @@ export const createTunnelHost = ({
323
368
  return;
324
369
  }
325
370
 
326
- const url = `ws://127.0.0.1:${getLocalPort()}${open.path}${open.query ? `?${open.query}` : ''}`;
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
+
379
+ const url = `ws://127.0.0.1:${targetPort}${open.path}${open.query ? `?${open.query}` : ''}`;
327
380
  // Present the loopback origin we're actually dialing. The server derives this
328
381
  // as a trusted same-origin candidate from the Host header (127.0.0.1:<port>),
329
382
  // so the WS origin check passes reliably for every client platform. We do NOT
@@ -333,7 +386,7 @@ export const createTunnelHost = ({
333
386
  // authenticated by the tunneled oc_url_token, not by this origin.
334
387
  const dialHeaders = {
335
388
  'x-openchamber-relay-connection': connectionId,
336
- origin: `http://127.0.0.1:${getLocalPort()}`,
389
+ origin: `http://127.0.0.1:${targetPort}`,
337
390
  };
338
391
  let socket;
339
392
  try {
@@ -7,17 +7,18 @@
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 { describe, expect, it } from 'bun:test';
10
+ import { afterEach, 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,
16
17
  decodeTunnelFrame,
17
18
  encodeJsonPayload,
18
19
  encodeTunnelFrame,
19
20
  } from './tunnel-codec.js';
20
- import { createTunnelHost } from './tunnel-host.js';
21
+ import { createTunnelHost, resolveTargetPort } from './tunnel-host.js';
21
22
 
22
23
  const textEncoder = new TextEncoder();
23
24
  const textDecoder = new TextDecoder();
@@ -223,3 +224,250 @@ describe('createTunnelHost WS fragment ordering', () => {
223
224
  }
224
225
  });
225
226
  });
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
+ });