@openchambery/web 1.18.4-beta.6 → 1.18.4-beta.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.test.js +25 -0
- package/bin/lib/DOCUMENTATION.md +2 -1
- package/bin/lib/cli-args.js +19 -0
- package/bin/lib/commands-serve.js +73 -4
- package/dist/assets/{AgentAvatar-BvhXxFvg.js → AgentAvatar-BvSKUyH7.js} +1 -1
- package/dist/assets/{AssistantView-C71fxVQJ.js → AssistantView-Bl6I3ULZ.js} +1 -1
- package/dist/assets/{ChatContainer-C25uUZEc.js → ChatContainer-C8qtYsGj.js} +18 -18
- package/dist/assets/{DiagramView-D-0pq_LG.js → DiagramView-BNoRb-Lu.js} +1 -1
- package/dist/assets/{FadeInOnReveal-98buIVdv.js → FadeInOnReveal-D7n_Z9hV.js} +1 -1
- package/dist/assets/{Icon-BhSwbLaF.js → Icon-BsadCYtz.js} +1 -1
- package/dist/assets/{MarkdownRendererImpl-BPBGslhm.js → MarkdownRendererImpl-LjoqAmt2.js} +1 -1
- package/dist/assets/{MobileDetailNavigation-CeuGZn2R.js → MobileDetailNavigation-DVh_x9Vh.js} +1 -1
- package/dist/assets/MobileShareBridge-Dxo_PYrI.js +4 -0
- package/dist/assets/{MobileSurface-Dzu9algh.js → MobileSurface-BFZ2IvLJ.js} +1 -1
- package/dist/assets/{MultiRunWindow-Cnnhxk-c.js → MultiRunWindow-CiHWeKk0.js} +1 -1
- package/dist/assets/{OnboardingScreen-BN4L1hqu.js → OnboardingScreen-CxyAkEzM.js} +1 -1
- package/dist/assets/{OverlayScrollbar-CXFN6Cqq.js → OverlayScrollbar-Dh02m1YT.js} +1 -1
- package/dist/assets/{SettingsView-CxBOEPcX.js → SettingsView-BKzVkfbr.js} +9 -9
- package/dist/assets/{SettingsWindow-CSnveV5T.js → SettingsWindow-Dl3I7cA0.js} +1 -1
- package/dist/assets/{TerminalView-4X9QIzLd.js → TerminalView-C2e5RCaa.js} +1 -1
- package/dist/assets/{ToolOutputDialog-C5Rsi0ix.js → ToolOutputDialog-9gRd-6hH.js} +1 -1
- package/dist/assets/{appThemeRegistry-6YCqPDdh.js → appThemeRegistry-DKNLXAna.js} +2 -2
- package/dist/assets/{button-KTZJ84Mx.js → button-Cvx5jdoq.js} +1 -1
- package/dist/assets/{clipboard-BvJ7NunE.js → clipboard-BZpWHcqB.js} +1 -1
- package/dist/assets/{collapsible-CqcZGw3W.js → collapsible-BBBFSZhU.js} +1 -1
- package/dist/assets/{context-menu-ItR7JeMm.js → context-menu-t820Sl7u.js} +1 -1
- package/dist/assets/desktopHosts-DXKqR7_I.js +1 -0
- package/dist/assets/desktopRelayRestore-UQi66q4l.js +1 -0
- package/dist/assets/{diagram-B8D9Klct.js → diagram-BjEybADC.js} +1 -1
- package/dist/assets/{dialog-sGFkIKP2.js → dialog-1GZE0Vc1.js} +1 -1
- package/dist/assets/{es-BuinIayO.js → es-CB3oCV5s.js} +2 -2
- package/dist/assets/{fileQueries-BSE4ycQt.js → fileQueries-BLZMvnWH.js} +1 -1
- package/dist/assets/{fr-CT7us58h.js → fr-A8nhFMml.js} +2 -2
- package/dist/assets/{gitApi-C05aarFU.js → gitApi-ChClb5mq.js} +1 -1
- package/dist/assets/{input-CgIoxUJc.js → input-C_y_Hr7_.js} +1 -1
- package/dist/assets/{insertionBoundaries-B1qEhnwJ.js → insertionBoundaries-jE4JjX0L.js} +1 -1
- package/dist/assets/{ja-CCuAH4nO.js → ja-DqKuE9u1.js} +2 -2
- package/dist/assets/{ko-lXbhGt6P.js → ko-Bt7C3kKN.js} +2 -2
- package/dist/assets/{main-CMSXnD8-.js → main-432sqFSc.js} +13 -13
- package/dist/assets/{main-CUjrXmZE.js → main-B8NKudiw.js} +2 -2
- package/dist/assets/{markdown-worker-C5Om4TVK.js → markdown-worker-DO-d7szc.js} +1 -1
- package/dist/assets/{markdownTheme-BdF4C4-o.js → markdownTheme-BfO6u0QX.js} +1 -1
- package/dist/assets/{miniChat-t_4iBTuw.js → miniChat-Bwp_58pa.js} +2 -2
- package/dist/assets/mobile-C8nbgaGF.js +2 -0
- package/dist/assets/{multirun-Deu0GsaN.js → multirun-CuE8hkPx.js} +1 -1
- package/dist/assets/persistence-Ct8H5ggb.js +3 -0
- package/dist/assets/{pl-DCsKBRAg.js → pl-Cj9jqqPr.js} +2 -2
- package/dist/assets/platform-DnEF2-Ze.js +2 -0
- package/dist/assets/{projectMeta-C_kYsova.js → projectMeta-CCdwMa23.js} +2 -2
- package/dist/assets/{pt-BR-DWbGs1bc.js → pt-BR-Do6ttSSV.js} +2 -2
- package/dist/assets/{radio-uNnEkXyx.js → radio-NaB0SlKr.js} +1 -1
- package/dist/assets/{renderElectronMiniChatApp-C8DAzLcf.js → renderElectronMiniChatApp-BLrUQiGt.js} +2 -2
- package/dist/assets/{renderMobileApp-AjwW2czA.js → renderMobileApp-Bij-Oiye.js} +5 -5
- package/dist/assets/{runtimeConfig-C3lfsV4y.js → runtimeConfig-BbXAK0IO.js} +1 -1
- package/dist/assets/{runtimeEndpointReset-DJkQXFX9.js → runtimeEndpointReset-DxdWB6R5.js} +2 -2
- package/dist/assets/{sortable-tabs-strip-pSnPuBXA.js → sortable-tabs-strip-CfjgCI3u.js} +1 -1
- package/dist/assets/{store-BuXogmxp.js → store-C9j7lOhb.js} +2 -2
- package/dist/assets/{terminalApi-jfes-HIU.js → terminalApi-CRbIGjgm.js} +1 -1
- package/dist/assets/{tooltip-rN8LrxMF.js → tooltip-DH8Zlt21.js} +1 -1
- package/dist/assets/{uk-DgOxtvS7.js → uk-3Mp5A3Bg.js} +2 -2
- package/dist/assets/{url-B0Ak7X6b.js → url-BmW8IAUq.js} +1 -1
- package/dist/assets/useAppFontEffects-DarfU7-n.js +7 -0
- package/dist/assets/{useAssistantUIStore-SPh025NE.js → useAssistantUIStore-s8G4BYH7.js} +1 -1
- package/dist/assets/{useDesktopWindowControlsLayout-D3dqdJeh.js → useDesktopWindowControlsLayout-B0q0laG3.js} +1 -1
- package/dist/assets/{useEffectiveDirectory-DwBUAdt4.js → useEffectiveDirectory-D74ZxNOc.js} +1 -1
- package/dist/assets/{useFontPreferences-Bz3crRRO.js → useFontPreferences-CRz5l3xN.js} +1 -1
- package/dist/assets/{useMobileNavigationStore-DbgXlqQj.js → useMobileNavigationStore-CPU8_eJU.js} +11 -11
- package/dist/assets/useSessionAutoCleanup-DEa__M_5.js +1 -0
- package/dist/assets/useUIStore-C8_xhaOX.js +1 -0
- package/dist/assets/{useWorkerHighlightedLines-uDlox7Hk.js → useWorkerHighlightedLines-DkF_uHPp.js} +1 -1
- package/dist/assets/{utils-DfPDhqys.js → utils-DEcbW91m.js} +1 -1
- package/dist/assets/{zh-CN-BPGatJp9.js → zh-CN-Dg1_sByy.js} +2 -2
- package/dist/assets/{zh-TW-qUWWsaxK.js → zh-TW-BhmGGkEE.js} +2 -2
- package/dist/index.html +8 -8
- package/dist/mini-chat.html +8 -8
- package/dist/mobile.html +8 -8
- package/package.json +1 -1
- package/server/index.d.ts +0 -4
- package/server/index.js +11 -20
- package/server/lib/client-auth/pairing.js +1 -7
- package/server/lib/client-auth/pairing.test.js +0 -45
- package/server/lib/opencode/DOCUMENTATION.md +4 -4
- package/server/lib/opencode/bootstrap-runtime.js +0 -6
- package/server/lib/opencode/cli-entry-runtime.js +5 -1
- package/server/lib/opencode/cli-entry-runtime.test.js +17 -0
- package/server/lib/opencode/core-routes.js +0 -3
- package/server/lib/opencode/core-routes.test.js +0 -15
- package/server/lib/opencode/openchamber-routes.js +0 -11
- package/server/lib/relay/DOCUMENTATION.md +3 -45
- package/server/lib/relay/host-client.js +1 -4
- package/server/lib/relay/service.js +28 -16
- package/server/lib/relay/service.test.js +64 -4
- package/server/lib/relay/tunnel-host.js +2 -52
- package/server/lib/relay/tunnel-host.test.js +2 -250
- package/dist/assets/MobileShareBridge-x6v_4qIx.js +0 -4
- package/dist/assets/desktopHosts-aA40MHzB.js +0 -1
- package/dist/assets/desktopRelayRestore-CxUYRC6v.js +0 -1
- package/dist/assets/mobile-DTpbEiyr.js +0 -2
- package/dist/assets/persistence-DwXJK4-j.js +0 -3
- package/dist/assets/platform-CXJwZIZe.js +0 -2
- package/dist/assets/useAppFontEffects-DKx61bCr.js +0 -7
- package/dist/assets/useSessionAutoCleanup-x2K_C50s.js +0 -1
- package/dist/assets/useUIStore-BJpkPwky.js +0 -1
- package/server/lib/desktop-hosts/routes.js +0 -157
- 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
|
|
10
|
-
// (OPENCHAMBER_RUNTIME=desktop
|
|
11
|
-
// Plain `node server`, `dev:web:hmr`, VS Code, CLI, and browser clients
|
|
12
|
-
// never open the host-control socket, mint a relay identity, advertise a
|
|
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-
|
|
96
|
-
canHostRelay = () =>
|
|
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-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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('
|
|
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('
|
|
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 =
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
});
|