@openchambery/web 1.18.4-beta.2 → 1.18.4-beta.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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-DDcjamcJ.js} +1 -1
- package/dist/assets/AssistantView-BKtWtK9a.js +1 -0
- package/dist/assets/{ChatContainer-Ba2Qqd2R.js → ChatContainer-CmZuBd4k.js} +42 -42
- package/dist/assets/{DiagramView--CkYLtF9.js → DiagramView-C3H0iaE1.js} +1 -1
- package/dist/assets/{FadeInOnReveal-98buIVdv.js → FadeInOnReveal-By2F9vcI.js} +1 -1
- package/dist/assets/{Icon-BhSwbLaF.js → Icon-DpjYnXxn.js} +1 -1
- package/dist/assets/{MarkdownRendererImpl-CuaQbwUx.js → MarkdownRendererImpl-Oq7v-1r2.js} +1 -1
- package/dist/assets/{MobileDetailNavigation-ld2mS8hq.js → MobileDetailNavigation-Bh_f-iat.js} +1 -1
- package/dist/assets/MobileShareBridge-BvcbH_WJ.js +4 -0
- package/dist/assets/MobileSurface-BTMvLLPa.js +2 -0
- package/dist/assets/{MultiRunWindow-kryfNvF6.js → MultiRunWindow-Cv4K6i1U.js} +1 -1
- package/dist/assets/{OnboardingScreen-C1n_wVb-.js → OnboardingScreen-BKmuT7AL.js} +1 -1
- package/dist/assets/{OverlayScrollbar-CXFN6Cqq.js → OverlayScrollbar-Ch3J6ASR.js} +1 -1
- package/dist/assets/{SettingsView-BBTUVI-a.js → SettingsView-DZT_nuTt.js} +11 -11
- package/dist/assets/{SettingsWindow-4RS_41Wb.js → SettingsWindow-Bjb84x0C.js} +1 -1
- package/dist/assets/{TerminalView-B1he4xRe.js → TerminalView-CXq2MDyL.js} +1 -1
- package/dist/assets/{ToolOutputDialog-ak07C60J.js → ToolOutputDialog-CTrqAwo1.js} +1 -1
- package/dist/assets/appThemeRegistry-D6EEEMQw.js +2 -0
- package/dist/assets/{button-BQY5ugjh.js → button-C7OXr4ae.js} +1 -1
- package/dist/assets/{clipboard-BvJ7NunE.js → clipboard-DWuULPzo.js} +1 -1
- package/dist/assets/{collapsible-CqcZGw3W.js → collapsible-DaEya6b6.js} +1 -1
- package/dist/assets/{context-menu-DSUJY2i3.js → context-menu-Cjof3V7x.js} +1 -1
- package/dist/assets/desktopHosts-Txp4xOVQ.js +1 -0
- package/dist/assets/desktopRelayRestore-C_FSEHKM.js +1 -0
- package/dist/assets/{diagram-B8D9Klct.js → diagram-CHfginjS.js} +1 -1
- package/dist/assets/{dialog-sGFkIKP2.js → dialog-DPS0W9eS.js} +1 -1
- package/dist/assets/{es-BuinIayO.js → es-BX0Vm3Ac.js} +4 -4
- package/dist/assets/fileQueries-BrUKKgPW.js +1 -0
- package/dist/assets/{fr-CT7us58h.js → fr-CuPzXTYx.js} +4 -4
- package/dist/assets/{gitApi-DtV2V_Fp.js → gitApi-mV826uOd.js} +2 -2
- package/dist/assets/{input-CgIoxUJc.js → input-BzYA603C.js} +1 -1
- package/dist/assets/insertionBoundaries-fFm8sTaK.js +1 -0
- package/dist/assets/{ja-CCuAH4nO.js → ja-DGtzjrFO.js} +4 -4
- package/dist/assets/{ko-lXbhGt6P.js → ko-DZ9Di0je.js} +4 -4
- package/dist/assets/{main-CoNVZvj1.js → main-CqaUoTu5.js} +2 -2
- package/dist/assets/main-DaX6y8EG.js +172 -0
- package/dist/assets/{markdown-worker-C5Om4TVK.js → markdown-worker-W06-MhF5.js} +1 -1
- package/dist/assets/{markdownTheme-BdF4C4-o.js → markdownTheme-B89dbj3N.js} +1 -1
- package/dist/assets/{miniChat-CYd1PMdJ.js → miniChat-CX64FVKM.js} +2 -2
- package/dist/assets/mobile-BljNuNl6.js +2 -0
- package/dist/assets/multirun-Bdv-VeHM.js +4 -0
- package/dist/assets/persistence-6sclr7B6.js +3 -0
- package/dist/assets/pl-BuCO3qb1.js +15 -0
- package/dist/assets/platform-CbeLpKPk.js +2 -0
- package/dist/assets/projectMeta-EGeQpzvX.js +3 -0
- package/dist/assets/{pt-BR-DWbGs1bc.js → pt-BR-Brf4RMsw.js} +4 -4
- package/dist/assets/radio-BPJ5XDOL.js +1 -0
- package/dist/assets/renderElectronMiniChatApp-7_MeDuZ2.js +2 -0
- package/dist/assets/renderMobileApp-J09WW2wY.js +23 -0
- package/dist/assets/runtimeConfig-DbkphGhQ.js +2 -0
- package/dist/assets/runtimeEndpointReset-DooWSsoH.js +147 -0
- package/dist/assets/{sortable-tabs-strip-pSnPuBXA.js → sortable-tabs-strip-nLsPC06f.js} +1 -1
- package/dist/assets/src-Yqt5hwRm.css +2 -0
- package/dist/assets/{store-BuXogmxp.js → store-DQY8Fkb0.js} +4 -4
- package/dist/assets/{terminalApi-jfes-HIU.js → terminalApi-BpPeXWIu.js} +1 -1
- package/dist/assets/{tooltip-rN8LrxMF.js → tooltip-D_orJMYd.js} +1 -1
- package/dist/assets/{uk-DgOxtvS7.js → uk-C-wmaPTi.js} +4 -4
- package/dist/assets/{url-B0Ak7X6b.js → url-BsaBG5-1.js} +1 -1
- package/dist/assets/useAppFontEffects-DFRR7hqN.js +7 -0
- package/dist/assets/{useAssistantUIStore-SPh025NE.js → useAssistantUIStore-BHsHXCKE.js} +1 -1
- package/dist/assets/{useDesktopWindowControlsLayout-BSHPIcFE.js → useDesktopWindowControlsLayout-7oe0nJkK.js} +1 -1
- package/dist/assets/{useEffectiveDirectory-Cbsu48gT.js → useEffectiveDirectory-DEbnpVAu.js} +1 -1
- package/dist/assets/{useFontPreferences-Bz3crRRO.js → useFontPreferences-ckzpXQaJ.js} +1 -1
- package/dist/assets/useMobileNavigationStore-CPi-bAr0.js +475 -0
- package/dist/assets/useSessionAutoCleanup-BFwpZAij.js +1 -0
- package/dist/assets/useUIStore-DzwCfRqr.js +1 -0
- package/dist/assets/{useWorkerHighlightedLines-D7ZRwkm3.js → useWorkerHighlightedLines-Cb7i5p7s.js} +1 -1
- package/dist/assets/{utils-DfPDhqys.js → utils-B1EK5zKa.js} +1 -1
- package/dist/assets/{zh-CN-BPGatJp9.js → zh-CN-DueRrIql.js} +4 -4
- package/dist/assets/{zh-TW-qUWWsaxK.js → zh-TW-6875X80V.js} +4 -4
- package/dist/index.html +9 -9
- package/dist/mini-chat.html +9 -9
- package/dist/mobile.html +9 -9
- package/package.json +1 -1
- package/server/index.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/config-sync/config-sync.test.js +52 -0
- package/server/lib/config-sync/scripts.js +10 -2
- package/server/lib/notifications/DOCUMENTATION.md +2 -1
- package/server/lib/notifications/emitter-runtime.js +78 -1
- package/server/lib/notifications/emitter-runtime.test.js +94 -1
- 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/server/lib/scheduled-tasks/DOCUMENTATION.md +4 -1
- package/server/lib/scheduled-tasks/routes.js +51 -8
- package/server/lib/session-assist/DOCUMENTATION.md +4 -2
- package/server/lib/session-assist/runtime.js +67 -30
- package/server/lib/session-assist/runtime.test.js +114 -0
- package/server/lib/session-title/DOCUMENTATION.md +15 -10
- package/server/lib/session-title/runtime.js +24 -0
- package/server/lib/session-title/runtime.test.js +143 -0
- package/server/lib/ui-auth/ui-auth.js +3 -1
- package/server/lib/ui-auth/ui-auth.test.js +25 -0
- package/server/sse-routes.test.js +64 -1
- package/dist/assets/AssistantView-DMK-KCPX.js +0 -1
- package/dist/assets/MobileShareBridge-lTfofNXr.js +0 -4
- package/dist/assets/MobileSurface-Bc_pAGrN.js +0 -2
- package/dist/assets/appThemeRegistry-ChhJvOPo.js +0 -2
- package/dist/assets/desktopHosts-aA40MHzB.js +0 -1
- package/dist/assets/desktopRelayRestore-CxUYRC6v.js +0 -1
- package/dist/assets/fileQueries-BSE4ycQt.js +0 -1
- package/dist/assets/insertionBoundaries-BIS62xbD.js +0 -1
- package/dist/assets/main-ClQdYXuu.js +0 -172
- package/dist/assets/mobile-CJkbELE1.js +0 -2
- package/dist/assets/multirun-DfYluvqf.js +0 -4
- package/dist/assets/persistence-DwXJK4-j.js +0 -3
- package/dist/assets/pl-DCsKBRAg.js +0 -15
- package/dist/assets/platform-CXJwZIZe.js +0 -2
- package/dist/assets/projectMeta-jG2rzIks.js +0 -3
- package/dist/assets/radio-CIR_xKSG.js +0 -1
- package/dist/assets/renderElectronMiniChatApp-D6cOGBOZ.js +0 -2
- package/dist/assets/renderMobileApp-BuiAqCNI.js +0 -23
- package/dist/assets/runtimeConfig-B9P24SI-.js +0 -2
- package/dist/assets/runtimeEndpointReset-BLmUiRJq.js +0 -147
- package/dist/assets/src-DF4hiLrB.css +0 -2
- package/dist/assets/useAppFontEffects-DCQnaU6M.js +0 -7
- package/dist/assets/useMobileNavigationStore-BYiwDqQe.js +0 -476
- package/dist/assets/useSessionAutoCleanup-DDdudIWc.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
|
@@ -62,9 +62,6 @@ export const createBootstrapRuntime = (dependencies) => {
|
|
|
62
62
|
sessionIndexService,
|
|
63
63
|
sessionIndexSyncRuntime,
|
|
64
64
|
transcriptCacheService,
|
|
65
|
-
getSshRoutingTable,
|
|
66
|
-
mintSshHostToken,
|
|
67
|
-
getPairingSession,
|
|
68
65
|
} = options;
|
|
69
66
|
|
|
70
67
|
const uiAuthController = createUiAuth({
|
|
@@ -154,9 +151,6 @@ export const createBootstrapRuntime = (dependencies) => {
|
|
|
154
151
|
sessionIndexService,
|
|
155
152
|
sessionIndexSyncRuntime,
|
|
156
153
|
transcriptCacheService,
|
|
157
|
-
getSshRoutingTable,
|
|
158
|
-
mintSshHostToken,
|
|
159
|
-
getPairingSession,
|
|
160
154
|
express,
|
|
161
155
|
});
|
|
162
156
|
|
|
@@ -17,7 +17,11 @@ export const runCliEntryIfMain = (dependencies) => {
|
|
|
17
17
|
// OPENCHAMBER_RUNTIME=desktop from the parent shell — that would open a relay
|
|
18
18
|
// host-control socket from `bun run dev` / `dev:server`. Electron sets desktop
|
|
19
19
|
// before importing this module and never takes this CLI path.
|
|
20
|
-
|
|
20
|
+
// SSH-manager remotes set OPENCHAMBER_RUNTIME=ssh-remote (and may pass
|
|
21
|
+
// --relay-host); preserve that so the remote can host a private relay.
|
|
22
|
+
if (process.env.OPENCHAMBER_RUNTIME !== 'ssh-remote') {
|
|
23
|
+
process.env.OPENCHAMBER_RUNTIME = 'web';
|
|
24
|
+
}
|
|
21
25
|
|
|
22
26
|
const cliOptions = parseServeCliOptions({
|
|
23
27
|
argv: process.argv.slice(2),
|
|
@@ -26,6 +26,23 @@ describe('cli-entry-runtime', () => {
|
|
|
26
26
|
expect(startServer).toHaveBeenCalledTimes(1);
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
+
it('preserves OPENCHAMBER_RUNTIME=ssh-remote on the direct node server path', () => {
|
|
30
|
+
process.env.OPENCHAMBER_RUNTIME = 'ssh-remote';
|
|
31
|
+
const startServer = vi.fn(async () => {});
|
|
32
|
+
|
|
33
|
+
runCliEntryIfMain({
|
|
34
|
+
process: { argv: ['node', '/tmp/server/index.js', '--port', '3001'], env: process.env, exit: vi.fn() },
|
|
35
|
+
currentFilename: '/tmp/server/index.js',
|
|
36
|
+
parseServeCliOptions: () => ({ port: 3001, host: undefined, uiPassword: null, apiOnly: false }),
|
|
37
|
+
defaultPort: 3001,
|
|
38
|
+
setExitOnShutdown: vi.fn(),
|
|
39
|
+
startServer,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
expect(process.env.OPENCHAMBER_RUNTIME).toBe('ssh-remote');
|
|
43
|
+
expect(startServer).toHaveBeenCalledTimes(1);
|
|
44
|
+
});
|
|
45
|
+
|
|
29
46
|
it('does not rewrite OPENCHAMBER_RUNTIME when imported as a library (Electron in-process)', () => {
|
|
30
47
|
process.env.OPENCHAMBER_RUNTIME = 'desktop';
|
|
31
48
|
|
|
@@ -871,9 +871,6 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
|
|
|
871
871
|
allowedClientKinds: req.body?.allowedClientKinds,
|
|
872
872
|
createdByClientId: clientIdFromAuthContext(authContext),
|
|
873
873
|
usesRelay,
|
|
874
|
-
// Optional SSH instance id: binds mobile ssh-host-token mint to this
|
|
875
|
-
// pairing after redeem. Does not affect redeem itself.
|
|
876
|
-
sshHostId: req.body?.sshHostId,
|
|
877
874
|
});
|
|
878
875
|
void reconcileRelay();
|
|
879
876
|
res.setHeader('Cache-Control', 'no-store');
|
|
@@ -774,24 +774,9 @@ describe('core-routes', () => {
|
|
|
774
774
|
allowedClientKinds: ['mobile'],
|
|
775
775
|
createdByClientId: null,
|
|
776
776
|
usesRelay: false,
|
|
777
|
-
sshHostId: undefined,
|
|
778
777
|
});
|
|
779
778
|
});
|
|
780
779
|
|
|
781
|
-
it('forwards optional sshHostId into createPairingSession', async () => {
|
|
782
|
-
const { app, dependencies } = createPairingRouteApp();
|
|
783
|
-
|
|
784
|
-
await request(app)
|
|
785
|
-
.post('/api/client-auth/pairing/sessions')
|
|
786
|
-
.set('Host', 'runtime.example')
|
|
787
|
-
.send({ label: 'SSH phone', allowedClientKinds: ['mobile'], sshHostId: 'ssh-1', includeRelay: true })
|
|
788
|
-
.expect(201);
|
|
789
|
-
|
|
790
|
-
expect(dependencies.clientPairingRuntime.createPairingSession).toHaveBeenCalledWith(
|
|
791
|
-
expect.objectContaining({ sshHostId: 'ssh-1' }),
|
|
792
|
-
);
|
|
793
|
-
});
|
|
794
|
-
|
|
795
780
|
it('advertises the caller-supplied serverUrl as the direct candidate over the request origin', async () => {
|
|
796
781
|
const { app } = createPairingRouteApp();
|
|
797
782
|
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { registerSessionIndexRoutes } from '../session-index/routes.js';
|
|
2
2
|
import { registerTranscriptCacheRoutes } from '../transcript-cache/routes.js';
|
|
3
|
-
import { registerDesktopHostRoutes } from '../desktop-hosts/routes.js';
|
|
4
3
|
import { registerConfigSyncRoutes } from '../config-sync/routes.js';
|
|
5
4
|
|
|
6
5
|
export const registerOpenChamberRoutes = (app, dependencies) => {
|
|
@@ -18,21 +17,11 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
|
|
|
18
17
|
sessionIndexService,
|
|
19
18
|
sessionIndexSyncRuntime,
|
|
20
19
|
transcriptCacheService,
|
|
21
|
-
getSshRoutingTable,
|
|
22
|
-
mintSshHostToken,
|
|
23
|
-
getPairingSession,
|
|
24
20
|
express,
|
|
25
21
|
} = dependencies;
|
|
26
22
|
|
|
27
23
|
registerSessionIndexRoutes(app, { sessionIndexService, sessionIndexSyncRuntime });
|
|
28
24
|
registerTranscriptCacheRoutes(app, { transcriptCacheService });
|
|
29
|
-
registerDesktopHostRoutes(app, {
|
|
30
|
-
readSettingsFromDiskMigrated,
|
|
31
|
-
getSshRoutingTable,
|
|
32
|
-
mintSshHostToken,
|
|
33
|
-
getPairingSession,
|
|
34
|
-
express,
|
|
35
|
-
});
|
|
36
25
|
registerConfigSyncRoutes(app, {
|
|
37
26
|
readSettingsFromDiskMigrated,
|
|
38
27
|
persistSettings,
|
|
@@ -6,7 +6,7 @@ The private relay lets an OpenChamber client (mobile app, browser, or another de
|
|
|
6
6
|
|
|
7
7
|
Traffic is **end-to-end encrypted between the two endpoints** (client and host instance). The relay infrastructure forwards opaque ciphertext and cannot read application traffic — it is an untrusted transport, not a trusted middlebox.
|
|
8
8
|
|
|
9
|
-
This module (`packages/web/server/lib/relay/`) is the **Host side** of the private relay. **Only the Electron desktop runtime may act as a relay host** (`OPENCHAMBER_RUNTIME=desktop
|
|
9
|
+
This module (`packages/web/server/lib/relay/`) is the **Host side** of the private relay. **Only the Electron desktop runtime or an SSH-managed remote server may act as a relay host** (`OPENCHAMBER_RUNTIME=desktop` or `ssh-remote`). Electron sets `desktop` before it starts the in-process web server; SSH manager sets `ssh-remote` when launching a managed remote `openchamber serve` (optionally with `--relay-host`). Plain `node` / `dev:web:hmr` / ordinary CLI / VS Code web servers must not open the host-control socket, claim `relay-host.lock`, or advertise a host pairing candidate — even when they share the same data dir as desktop. Those processes may still run the API that *clients* use; mobile and other desktops remain **relay clients**. The **Client side** lives in `packages/ui/src/lib/relay/`. Hosted Relay instances may run in a Worker. The self-hosted **Relay server** belongs to `packages/relay-server/` and brokers Layer 1 connections.
|
|
10
10
|
|
|
11
11
|
## The three layers
|
|
12
12
|
|
|
@@ -19,7 +19,7 @@ Traffic is modeled as three stacked layers. The relay understands only Layer 1;
|
|
|
19
19
|
## Entrypoints and structure
|
|
20
20
|
|
|
21
21
|
Host side (`packages/web/server/lib/relay/`):
|
|
22
|
-
- `service.js` — thin entrypoint: relay config (enabled flag + relay URL), the management routes (`GET/POST /api/openchamber/relay/{status,enable,disable}`), a `getPairingCandidate()` accessor (the relay transport candidate folded into pairing-v2 links when enabled, consumed by the pairing-session route in `core-routes.js`), and lifecycle wiring. Host lifecycle is gated
|
|
22
|
+
- `service.js` — thin entrypoint: relay config (enabled flag + relay URL), the management routes (`GET/POST /api/openchamber/relay/{status,enable,disable}`), a `getPairingCandidate()` accessor (the relay transport candidate folded into pairing-v2 links when enabled, consumed by the pairing-session route in `core-routes.js`), and lifecycle wiring. Host lifecycle is gated by `isRelayHostRuntime()` (`desktop` or `ssh-remote`): other runtimes (`bun run dev`, `dev:web:hmr`, ordinary CLI `serve`, VS Code, plain `node server`) report `state: 'unavailable'`, refuse `/relay/enable`, `/relay/disable`, and relay pairing with 403, never mint a relay identity, never advertise `relayAvailable`, and never call `startRelayHost`. Direct `node server/index.js` forces `OPENCHAMBER_RUNTIME=web` unless it is already `ssh-remote`, so a leftover desktop env cannot open the host-control socket. On host runtimes, started from `packages/web/server/index.js` when demand/opt-in enables the relay. Desktop auto-clears `privateRelay.enabled` when demand drops; `ssh-remote` keeps an explicit opt-in (e.g. `openchamber serve --relay-host`) sticky until disabled. The relay endpoint defaults to the OpenChamber-hosted relay but can be pinned to a self-hosted relay via the `OPENCHAMBER_RELAY_URL` env var; when set it overrides the stored setting for the host connection, the pairing candidate, and status, so paired clients inherit the endpoint automatically. Endpoint identity is scheme/host/path only: accepted schemes are `ws://` and `wss://`; URLs with userinfo are rejected (no silent default fallback); query and fragment are stripped before persistence and pairing candidates. Custom endpoint persistence and Host control-connection switching are explicit authenticated management actions. Pairing creation with `relayUrl` requires an owner UI session or the local `desktop-local` shell client; `/relay/enable` follows its API auth gate.
|
|
23
23
|
- `identity.js` — the host's stable identity: the long-lived signing keypair (shared with the push relay, defines the routing id) plus a long-lived encryption keypair (the E2EE trust anchor). Reused across restarts; never rotated implicitly.
|
|
24
24
|
- `signing-key.js` — storage/derivation of the signing keypair and the routing id, shared with the notifications runtime.
|
|
25
25
|
- `host-client.js` — the long-lived connection manager: one outbound control connection to the relay, a per-client data connection for each connected device, reconnect/backoff, and the E2EE responder handshake per connection.
|
|
@@ -104,48 +104,6 @@ Relay mode plugs into the existing client transport layer rather than a parallel
|
|
|
104
104
|
|
|
105
105
|
Catalog loaders (`loadProviders` / `loadAgents`) and assistant Query keys gate writes/caches on the **transport fingerprint**, not `runtimeKey`. On endpoint reset and same-device transport switch, `runtimeEndpointReset.ts` must set `useConfigStore.catalogTransportIdentity` to `getRuntimeTransportIdentity()`. Writing `detail.runtimeKey` there silently discards provider/agent catalog refreshes under Relay, which then hides capability-gated surfaces such as Assistants.
|
|
106
106
|
|
|
107
|
-
## SSH host routing
|
|
108
|
-
|
|
109
|
-
Mobile (and other relay clients) can reach a desktop's **SSH-forwarded remote OpenChamber instances** through the same private-relay tunnel that already reaches the desktop's local loopback origin. Routing is host-side only: the client selects a target local-forward port; the tunnel dispatcher dials that port on `127.0.0.1` when — and only when — it is present in the live SSH routing table.
|
|
110
|
-
|
|
111
|
-
### Header contract
|
|
112
|
-
|
|
113
|
-
- Header name: `x-openchamber-target-port`
|
|
114
|
-
- Value: decimal port number string (e.g. `"41234"`)
|
|
115
|
-
- HTTP: carried on the tunneled request headers
|
|
116
|
-
- WebSocket: optional `headers` field on the `WsOpen` payload (same header name/value)
|
|
117
|
-
- The dispatcher **strips** this header before forwarding to loopback (it is routing metadata, not an upstream header)
|
|
118
|
-
|
|
119
|
-
### Resolution rules (`resolveTargetPort` in `tunnel-host.js`)
|
|
120
|
-
|
|
121
|
-
| Condition | Result |
|
|
122
|
-
|---|---|
|
|
123
|
-
| Header absent | Dial `getLocalPort()` (desktop local origin) — unchanged default behavior |
|
|
124
|
-
| Header present and `localPort` is in `getSshRoutingTable()` | Dial that port |
|
|
125
|
-
| Header present but port missing/invalid/not in table | **Do not** fall back to the default port |
|
|
126
|
-
|
|
127
|
-
HTTP miss → synthetic **503** with body `{ error: 'ssh-host-unreachable', …, source: 'relay-tunnel-host' }`.
|
|
128
|
-
WS miss → stream abort with reason `ssh-host-unreachable`.
|
|
129
|
-
|
|
130
|
-
### Routing table (authoritative in memory)
|
|
131
|
-
|
|
132
|
-
- Electron injects `getSshRoutingTable: () => sshManager.getRoutingTable()` into `startWebUiServer`.
|
|
133
|
-
- Table entries are `{ id, localPort }` for SSH sessions whose status phase is **`ready`** and whose `localPort` is finite.
|
|
134
|
-
- Degraded / connecting / disconnected sessions are absent. Settings.json is **not** the allowlist for dialing — only the live table is.
|
|
135
|
-
- Non-desktop runtimes default to `() => []`.
|
|
136
|
-
|
|
137
|
-
### Companion HTTP APIs (authenticated `/api` gate)
|
|
138
|
-
|
|
139
|
-
- `GET /api/openchamber/desktop-hosts` → `{ hosts: [{ id, label, localPort, reachable }] }` for configured SSH instances. `reachable` is true iff the host id is in the routing table. **Never returns `clientToken`.**
|
|
140
|
-
- `POST /api/openchamber/ssh-host-token` body `{ hostId }` → `{ token }` from `settings.json` `desktopHosts[].clientToken` for SSH instance hosts only. Missing / non-SSH / no token → **404**. Response includes `Cache-Control: no-store`.
|
|
141
|
-
|
|
142
|
-
### Security invariants (do not regress)
|
|
143
|
-
|
|
144
|
-
- Path allowlists (`isAllowedHttpPath` / `ALLOWED_WS_PATHS`) are **not** relaxed for SSH targets.
|
|
145
|
-
- The dispatcher **never injects credentials**; the client still authenticates with its own bearer / `oc_url_token` against the target instance.
|
|
146
|
-
- Port legitimacy is solely the in-memory routing table, not client-supplied trust and not settings alone.
|
|
147
|
-
- List endpoints must not leak `clientToken`; token mint is a separate authenticated POST.
|
|
148
|
-
|
|
149
107
|
## Design invariants (do not regress)
|
|
150
108
|
|
|
151
109
|
- The relay never sees plaintext application traffic; it sees only routing metadata (routing id, connection identifiers, timestamps, coarse counts).
|
|
@@ -154,6 +112,6 @@ WS miss → stream abort with reason `ssh-host-unreachable`.
|
|
|
154
112
|
- The host dispatcher never injects credentials; the server authenticates each tunneled request.
|
|
155
113
|
- The tunnel is transparent to the app: adding relay support to a feature should not require the feature to know the relay exists — it goes through the shared runtime transport helpers.
|
|
156
114
|
- The two implementations stay byte-compatible and the wire format is versioned/negotiated so mixed client/host app versions degrade gracefully rather than break.
|
|
157
|
-
-
|
|
115
|
+
- Tunneled HTTP/WS always dial `getLocalPort()` on loopback; there is no client-selected target-port override on the host dispatcher.
|
|
158
116
|
|
|
159
117
|
For the operational rules that keep future changes (new WebSocket endpoints, transport refactors, terminal/voice porting) from breaking this, load the `relay-transport` skill.
|
|
@@ -54,14 +54,12 @@ const resolveBatchWindowMs = (option) => {
|
|
|
54
54
|
* identity: { serverId: string, hostEncPrivateKey: CryptoKey, signRelayAuth: (role: string, connectionId?: string | null) => { ts: number, sig: string, pk: string } },
|
|
55
55
|
* localPort?: number,
|
|
56
56
|
* getLocalPort?: () => number,
|
|
57
|
-
* getSshRoutingTable?: () => { id: string, localPort: number }[],
|
|
58
57
|
* onStatus?: (status: { state: string, lastError: string | null, connectedClients: number }) => void,
|
|
59
58
|
* logger?: Pick<Console, 'warn'>,
|
|
60
59
|
* }} options
|
|
61
60
|
*/
|
|
62
|
-
export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort,
|
|
61
|
+
export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, onStatus, logger = console, batchWindowMs, batch }) => {
|
|
63
62
|
const resolveLocalPort = typeof getLocalPort === 'function' ? getLocalPort : () => localPort;
|
|
64
|
-
const resolveSshRoutingTable = typeof getSshRoutingTable === 'function' ? getSshRoutingTable : () => [];
|
|
65
63
|
const localBatch = batch !== false;
|
|
66
64
|
const resolvedBatchWindowMs = resolveBatchWindowMs(batchWindowMs);
|
|
67
65
|
|
|
@@ -190,7 +188,6 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, ge
|
|
|
190
188
|
entry.tunnel = createTunnelHost({
|
|
191
189
|
connectionId,
|
|
192
190
|
getLocalPort: resolveLocalPort,
|
|
193
|
-
getSshRoutingTable: resolveSshRoutingTable,
|
|
194
191
|
getBufferedAmount: () => socket.bufferedAmount,
|
|
195
192
|
sendFrame: (plaintextFrame) => {
|
|
196
193
|
if (dataSockets.get(connectionId) !== entry || socket.readyState !== WebSocket.OPEN) return;
|
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
// Routes are registered with the other OpenChamber feature routes, before the
|
|
7
7
|
// generic OpenCode proxy, and are covered by the same global UI auth gate.
|
|
8
8
|
//
|
|
9
|
-
// Host gate: only the Electron desktop runtime
|
|
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>),
|