@vantran-se/openchamber-web 2.0.4 → 2.0.6
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/dist/assets/{CodeMirrorCommentWidgets-DZF8ntaq.js → CodeMirrorCommentWidgets-BlzB2e0C.js} +1 -1
- package/dist/assets/DiffView-D7JQzQVZ.js +4 -0
- package/dist/assets/FilesView-DC2gadXM.js +17 -0
- package/dist/assets/{GitView-DwQwqDvu.js → GitView-Gzl0wbdd.js} +4 -4
- package/dist/assets/{GuestAttachDialog-CoXM0gh_.js → GuestAttachDialog-CQdDIeco.js} +1 -1
- package/dist/assets/GuestSurfacePane-CrOhlIdt.js +1 -0
- package/dist/assets/{JsonTreeView-Cq_27v0y.js → JsonTreeView-C3QZifYe.js} +1 -1
- package/dist/assets/{LinearIssuesView-CsSSRMhn.js → LinearIssuesView-X27TCI3v.js} +1 -1
- package/dist/assets/MarkdownImageGallery-BlTWU9oH.js +1 -0
- package/dist/assets/MarkdownRendererImpl-Cno7QsJO.js +12 -0
- package/dist/assets/{OnboardingScreen-ClatMQAX.js → OnboardingScreen-Wr_UzNtt.js} +1 -1
- package/dist/assets/{PatchDiff-DOwlrIK9.js → PatchDiff-D6pWS7cD.js} +1 -1
- package/dist/assets/{PierreDiffViewer-CrcGqqD_.js → PierreDiffViewer-DDljFbJJ.js} +3 -3
- package/dist/assets/{PlanView-BGLsMgQk.js → PlanView-LcfEbBPi.js} +4 -4
- package/dist/assets/PluginPane-DPWPLc2f.js +3 -0
- package/dist/assets/SettingsView-CFe9O3EM.js +30 -0
- package/dist/assets/{SettingsWindow-B0BAy-f9.js → SettingsWindow-DT1xhvgi.js} +1 -1
- package/dist/assets/{SubmoduleDiffSummary-fcdDzCSW.js → SubmoduleDiffSummary-CBHXaIkB.js} +1 -1
- package/dist/assets/{ToolOutputDialog-B7xaUz2H.js → ToolOutputDialog-B_ZX8ZbZ.js} +6 -6
- package/dist/assets/{ToolPartDiffPreview-CkWCaUmZ.js → ToolPartDiffPreview-D_HQzkXU.js} +2 -2
- package/dist/assets/{VirtualizedFileDiff-DONC4eI2.js → VirtualizedFileDiff-BUK1ujXg.js} +1 -1
- package/dist/assets/{WalkthroughView-CXDImOXW.js → WalkthroughView-H-tYOvhO.js} +2 -2
- package/dist/assets/{appThemeRegistry-Br2TLSlb.js → appThemeRegistry-BoJGAwCP.js} +1 -1
- package/dist/assets/changesTree-CePHbFcj.js +1 -0
- package/dist/assets/commitAndPush-BzZrF8bK.js +1 -0
- package/dist/assets/{computeVirtualFileMetrics-_-20umCs.js → computeVirtualFileMetrics-kYyVjXLu.js} +2 -2
- package/dist/assets/de-ByfQDcPj.js +10 -0
- package/dist/assets/es-DzNbjsQS.js +16 -0
- package/dist/assets/fr-B8AVLrnX.js +14 -0
- package/dist/assets/{index-OKDYjcMF.js → index-0-ON1L7L.js} +1 -1
- package/dist/assets/index-DUke1nme.css +1 -0
- package/dist/assets/index-Di6zUe5h.js +41 -0
- package/dist/assets/{isDiffPlainText-D77M6q1f.js → isDiffPlainText-Bgm_aLpu.js} +1 -1
- package/dist/assets/{isFilePlainText-C54B4Mns.js → isFilePlainText-D5GMgURH.js} +1 -1
- package/dist/assets/ja-1g6S0G19.js +16 -0
- package/dist/assets/ko-CxplvYkJ.js +16 -0
- package/dist/assets/{main-CGEvLMd2.js → main--Y98ZLsD.js} +2 -2
- package/dist/assets/main-GjXmVNpD.js +896 -0
- package/dist/assets/{markdown-shiki.worker-CahLvHIq.js → markdown-shiki.worker-Bg8SoAJJ.js} +8 -5
- package/dist/assets/markdownCore-CVdUcVCi.js +7 -0
- package/dist/assets/{miniChat-BSxkRQLj.js → miniChat-tTx5mpbd.js} +2 -2
- package/dist/assets/{mobile-C5KRTNOB.js → mobile-BmbiDWGb.js} +2 -2
- package/dist/assets/patchFileDiff-CV4iVQ8L.js +13 -0
- package/dist/assets/pl-f3QyqvIB.js +16 -0
- package/dist/assets/pt-BR-BsaoaiCK.js +16 -0
- package/dist/assets/{renderElectronMiniChatApp-CnkAQV6o.js → renderElectronMiniChatApp-BnCdnDhv.js} +2 -2
- package/dist/assets/renderMobileApp-CKn8nbSW.js +25 -0
- package/dist/assets/runtimeEndpointReset-sCi88eed.js +27 -0
- package/dist/assets/scheduledTasksApi-Dahv3ws0.js +1 -0
- package/dist/assets/{shikiHighlight-4O0ZM14D.js → shikiHighlight-O5_xLmWt.js} +1 -1
- package/dist/assets/{textMateThemeFromAppTheme-yNq7b0DZ.js → textMateThemeFromAppTheme-XM4VefqN.js} +1 -1
- package/dist/assets/tr-BNFsa6nz.js +7 -0
- package/dist/assets/uk-CJ9y5ULA.js +16 -0
- package/dist/assets/{useAppFontEffects-Bzyszv2N.js → useAppFontEffects-BN06mgHB.js} +160 -156
- package/dist/assets/{useInlineCommentController-zOf6ORAq.js → useInlineCommentController-Bb9Gjz1Q.js} +2 -2
- package/dist/assets/{useRootScrollLock-DCaZ4BWi.js → useRootScrollLock-Chk1cFUw.js} +2 -2
- package/dist/assets/{vendor-codemirror-language-data-6cEFPNUR.js → vendor-codemirror-language-data-C2qMZiA7.js} +2 -2
- package/dist/assets/vendor-opencode-client-DSZl7jSi.js +12 -0
- package/dist/assets/vendor-zod-BSoePkpP.js +39 -0
- package/dist/assets/{web-components-BEo4-ISu.js → web-components-BQWjCn_U.js} +1 -1
- package/dist/assets/zh-CN-U0QjVlAA.js +16 -0
- package/dist/assets/zh-TW-J9PTJQJi.js +16 -0
- package/dist/index.html +5 -5
- package/dist/mini-chat.html +5 -5
- package/dist/mobile.html +5 -5
- package/package.json +4 -4
- package/server/index.js +82 -0
- package/server/lib/agent-tool/DOCUMENTATION.md +132 -47
- package/server/lib/agent-tool/runtime.js +34 -15
- package/server/lib/agent-tool/runtime.test.js +15 -0
- package/server/lib/browser-control/DOCUMENTATION.md +14 -2
- package/server/lib/event-stream/global-hub.js +23 -1
- package/server/lib/event-stream/global-hub.test.js +35 -0
- package/server/lib/event-stream/global-ws-bridge.js +3 -1
- package/server/lib/event-stream/upstream-reader.js +3 -2
- package/server/lib/git/DOCUMENTATION.md +1 -1
- package/server/lib/git/service.js +22 -0
- package/server/lib/git/service.test.js +8 -0
- package/server/lib/guests/DOCUMENTATION.md +4 -3
- package/server/lib/guests/catalog.js +19 -1
- package/server/lib/guests/catalog.test.js +22 -0
- package/server/lib/guests/html-styles.js +5 -3
- package/server/lib/guests/html-styles.test.js +3 -1
- package/server/lib/guests/routes.js +11 -3
- package/server/lib/guests/service.js +5 -0
- package/server/lib/guests/surface.js +55 -9
- package/server/lib/guests/surface.test.js +56 -6
- package/server/lib/notifications/DOCUMENTATION.md +10 -0
- package/server/lib/notifications/emit-route.js +118 -0
- package/server/lib/notifications/emit-route.test.js +112 -0
- package/server/lib/openchamber-control/actions.js +16 -2
- package/server/lib/openchamber-control/service.js +23 -0
- package/server/lib/openchamber-control/service.test.js +31 -0
- package/server/lib/openchamber-sessions/fork-inheritance.js +103 -0
- package/server/lib/openchamber-sessions/fork-inheritance.test.js +69 -0
- package/server/lib/openchamber-sessions/routes.js +11 -0
- package/server/lib/openchamber-sessions/routes.test.js +18 -0
- package/server/lib/opencode/bootstrap-runtime.js +14 -1
- package/server/lib/opencode/compatibility.js +6 -3
- package/server/lib/opencode/compatibility.test.js +8 -0
- package/server/lib/opencode/config-entity-v2.test.js +27 -0
- package/server/lib/opencode/config-paths.test.js +12 -0
- package/server/lib/opencode/config-v2.d.ts +2 -0
- package/server/lib/opencode/config-v2.js +42 -0
- package/server/lib/opencode/config-v2.test.js +17 -0
- package/server/lib/opencode/core-routes.js +20 -4
- package/server/lib/opencode/core-routes.test.js +41 -0
- package/server/lib/opencode/env-runtime.js +1 -8
- package/server/lib/opencode/feature-routes-runtime.js +2 -1
- package/server/lib/opencode/lifecycle.js +30 -2
- package/server/lib/opencode/lifecycle.test.js +27 -0
- package/server/lib/opencode/managed-config-file.js +18 -6
- package/server/lib/opencode/managed-config-file.test.js +23 -7
- package/server/lib/opencode/mcp.js +7 -2
- package/server/lib/opencode/npm-registry-config.d.ts +1 -0
- package/server/lib/opencode/npm-registry-config.js +123 -0
- package/server/lib/opencode/npm-registry-config.test.js +167 -0
- package/server/lib/opencode/npm-registry.js +5 -6
- package/server/lib/opencode/npm-registry.test.js +30 -0
- package/server/lib/opencode/openchamber-routes.js +29 -11
- package/server/lib/opencode/openchamber-routes.test.js +18 -0
- package/server/lib/opencode/project-directory-runtime.js +8 -0
- package/server/lib/opencode/project-directory-runtime.test.js +13 -0
- package/server/lib/opencode/provider-source-route.test.js +59 -0
- package/server/lib/opencode/providers.js +3 -0
- package/server/lib/opencode/providers.test.js +62 -0
- package/server/lib/opencode/proxy.js +38 -2
- package/server/lib/opencode/routes-upgrade.test.js +1 -1
- package/server/lib/opencode/routes.js +36 -1
- package/server/lib/opencode/server-utils-runtime.js +5 -0
- package/server/lib/opencode/settings-helpers.js +10 -0
- package/server/lib/opencode/settings-helpers.test.js +2 -2
- package/server/lib/opencode/settings-registry.json +17 -0
- package/server/lib/opencode/settings-runtime.js +1 -0
- package/server/lib/opencode/shared.js +49 -4
- package/server/lib/opencode/shared.test.js +78 -1
- package/server/lib/opencode/shutdown-runtime.js +3 -0
- package/server/lib/opencode/skill-routes.js +28 -12
- package/server/lib/opencode/skill-routes.test.js +121 -1
- package/server/lib/opencode/upgrade-capability.js +0 -14
- package/server/lib/opencode/v1-migration-topup.js +21 -15
- package/server/lib/opencode/v1-migration-topup.test.js +14 -6
- package/server/lib/opencode/v2-install.d.ts +1 -0
- package/server/lib/opencode/v2-install.js +8 -7
- package/server/lib/opencode/v2-install.test.js +13 -4
- package/server/lib/opencode/watcher.js +3 -1
- package/server/lib/opencode/websearch-config.js +13 -1
- package/server/lib/quota/providers/kimi.js +1 -1
- package/server/lib/realtime-proxy.js +5 -1
- package/server/lib/realtime-proxy.test.js +29 -0
- package/server/lib/relay/tunnel-host.js +4 -1
- package/server/lib/relay/tunnel-host.test.js +11 -0
- package/server/lib/session-assist/DOCUMENTATION.md +17 -5
- package/server/lib/session-assist/runtime.js +19 -2
- package/server/lib/session-assist/runtime.test.js +8 -0
- package/server/lib/session-goal/DOCUMENTATION.md +11 -0
- package/server/lib/small-model/DOCUMENTATION.md +11 -10
- package/server/lib/small-model/index.js +27 -32
- package/server/lib/small-model/index.test.js +28 -16
- package/server/lib/spaces/DOCUMENTATION.md +404 -32
- package/server/lib/spaces/code-in-bait.js +145 -6
- package/server/lib/spaces/code-in.js +10 -150
- package/server/lib/spaces/code-in.test.js +7 -83
- package/server/lib/spaces/code-out.js +1819 -0
- package/server/lib/spaces/code-out.test.js +3945 -0
- package/server/lib/spaces/code-transfer.js +173 -0
- package/server/lib/spaces/command-stream.test.js +181 -0
- package/server/lib/spaces/dispatcher.js +670 -0
- package/server/lib/spaces/dispatcher.test.js +582 -0
- package/server/lib/spaces/gatekeeper-channel.js +61 -7
- package/server/lib/spaces/gatekeeper-channel.test.js +43 -7
- package/server/lib/spaces/gatekeeper-program.cjs +68 -14
- package/server/lib/spaces/gatekeeper-program.test.js +118 -3
- package/server/lib/spaces/hardening.js +20 -4
- package/server/lib/spaces/hardening.test.js +18 -3
- package/server/lib/spaces/host-git.js +7 -4
- package/server/lib/spaces/host-git.test.js +6 -2
- package/server/lib/spaces/host.js +310 -0
- package/server/lib/spaces/host.test.js +273 -0
- package/server/lib/spaces/journey.js +552 -0
- package/server/lib/spaces/journey.test.js +589 -0
- package/server/lib/spaces/layout.js +42 -8
- package/server/lib/spaces/manager.js +4 -3
- package/server/lib/spaces/manager.test.js +6 -0
- package/server/lib/spaces/places/code-out.docker.live.test.js +412 -0
- package/server/lib/spaces/places/contract-suite.js +36 -0
- package/server/lib/spaces/places/dispatcher.docker.live.test.js +296 -0
- package/server/lib/spaces/places/docker-live-support.js +27 -4
- package/server/lib/spaces/places/docker.js +33 -5
- package/server/lib/spaces/places/docker.test.js +42 -3
- package/server/lib/spaces/places/escape-suite.js +118 -14
- package/server/lib/spaces/places/fake-docker.js +3 -1
- package/server/lib/spaces/places/grants.docker.live.test.js +197 -0
- package/server/lib/spaces/places/memory-place.js +24 -1
- package/server/lib/spaces/places/registry.js +1 -1
- package/server/lib/spaces/places/server.docker.live.test.js +69 -33
- package/server/lib/spaces/places/sessions.docker.live.test.js +222 -0
- package/server/lib/spaces/routes.js +216 -0
- package/server/lib/spaces/routes.test.js +213 -0
- package/server/lib/spaces/run-command.js +261 -8
- package/server/lib/spaces/run-command.test.js +193 -3
- package/server/lib/spaces/space-events.js +153 -0
- package/server/lib/spaces/space-events.test.js +160 -0
- package/server/lib/spaces/space-opencode.js +52 -0
- package/server/lib/spaces/space-opencode.test.js +41 -0
- package/server/lib/spaces/space-records.js +150 -0
- package/server/lib/spaces/space-records.test.js +113 -0
- package/server/lib/spaces/space-sessions.js +210 -0
- package/server/lib/spaces/space-sessions.test.js +161 -0
- package/server/lib/spaces/websocket.js +329 -0
- package/server/lib/spaces/websocket.test.js +345 -0
- package/server/lib/ui-auth/ui-auth.js +8 -2
- package/server/lib/ui-auth/ui-auth.test.js +29 -0
- package/server/opencode-proxy.test.js +113 -0
- package/dist/assets/DiffView-D2p4Ar0H.js +0 -4
- package/dist/assets/FilesView-BeTtbsip.js +0 -17
- package/dist/assets/GuestSurfacePane-DIJvkkM-.js +0 -1
- package/dist/assets/MarkdownImageGallery-CY0dWTaE.js +0 -1
- package/dist/assets/MarkdownRendererImpl-ILdXzLrt.js +0 -12
- package/dist/assets/PluginPane-B39JEUL8.js +0 -3
- package/dist/assets/SettingsView-13B2_G-A.js +0 -29
- package/dist/assets/commitAndPush-CcI4ol7c.js +0 -1
- package/dist/assets/de-BtYnUgII.js +0 -10
- package/dist/assets/es-crP5qPye.js +0 -16
- package/dist/assets/fr-CLSRuALk.js +0 -14
- package/dist/assets/index-CLcM8HKx.css +0 -1
- package/dist/assets/index-CvLgnuNI.js +0 -41
- package/dist/assets/ja-SZ1-ucLV.js +0 -16
- package/dist/assets/ko-HthbT_Ve.js +0 -16
- package/dist/assets/main-BJklC7Cl.js +0 -896
- package/dist/assets/markdownCore-BzQuETm0.js +0 -5
- package/dist/assets/patchFileDiff-DGw2KryM.js +0 -13
- package/dist/assets/pl-C8eYfjL1.js +0 -16
- package/dist/assets/pt-BR-cYOrZdxS.js +0 -16
- package/dist/assets/renderMobileApp-CMxjwisj.js +0 -25
- package/dist/assets/runtimeEndpointReset-C45jrFq4.js +0 -27
- package/dist/assets/scheduledTasksApi-CkK1F9s2.js +0 -1
- package/dist/assets/tr-B4IQ5t-T.js +0 -7
- package/dist/assets/uk-DCoXG6ve.js +0 -16
- package/dist/assets/vendor-opencode-client-_BAHQo4R.js +0 -12
- package/dist/assets/vendor-zod-Gea9uBlY.js +0 -39
- package/dist/assets/zh-CN-BXBoxsFk.js +0 -16
- package/dist/assets/zh-TW-C6oelpnL.js +0 -16
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
// WebSocket upgrades under `/api/spaces/<id>/`, forwarded into the space over a stream of its
|
|
2
|
+
// own from the place's `connect`: the terminal socket, the dev tunnel and the two event
|
|
3
|
+
// sockets of the server inside. The host authenticates the user the way its own socket
|
|
4
|
+
// handlers do, applies the dispatcher's rules and guard, strips the user's credentials, adds
|
|
5
|
+
// the space's session, and then joins the two sockets byte for byte, backpressure both ways.
|
|
6
|
+
// Nothing of the frames is read on the host.
|
|
7
|
+
//
|
|
8
|
+
// A failure before the upgrade is one HTTP answer with a stable code on the client's socket.
|
|
9
|
+
// After the upgrade the client gets a close frame with a code, and the stream inside, one
|
|
10
|
+
// `docker exec`, is killed with the socket.
|
|
11
|
+
|
|
12
|
+
import { SpaceError } from './errors.js';
|
|
13
|
+
import { isSpaceId } from './labels.js';
|
|
14
|
+
import { SPACE_SERVER_HOST, SPACE_SERVER_PORT, spaceWorkPath } from './layout.js';
|
|
15
|
+
import {
|
|
16
|
+
classifySpacePath,
|
|
17
|
+
describeFailure,
|
|
18
|
+
forwardRequestHeaders,
|
|
19
|
+
isDirectoryOfSpace,
|
|
20
|
+
parseSpaceRoute,
|
|
21
|
+
requestedDirectories,
|
|
22
|
+
stripCredentialQuery,
|
|
23
|
+
} from './dispatcher.js';
|
|
24
|
+
|
|
25
|
+
// The sockets of the server inside that a client may reach through the prefix. Any other
|
|
26
|
+
// upgrade under the prefix is refused: the dictation socket and the guest surfaces are the host's.
|
|
27
|
+
const SPACE_WEBSOCKET_PATHS = Object.freeze(['/api/terminal/ws', '/api/dev-tunnel', '/api/event/ws', '/api/global/event/ws']);
|
|
28
|
+
const DEV_TUNNEL_PATH = '/api/dev-tunnel';
|
|
29
|
+
|
|
30
|
+
// The client's handshake headers that travel; the dispatcher's list drops them for HTTP.
|
|
31
|
+
const HANDSHAKE_REQUEST_HEADERS = ['sec-websocket-key', 'sec-websocket-version', 'sec-websocket-protocol', 'sec-websocket-extensions'];
|
|
32
|
+
// The only headers of the answer inside that reach the client.
|
|
33
|
+
const HANDSHAKE_RESPONSE_HEADERS = new Set(['upgrade', 'connection', 'sec-websocket-accept', 'sec-websocket-protocol', 'sec-websocket-extensions']);
|
|
34
|
+
|
|
35
|
+
const HANDSHAKE_TIMEOUT_MS = 30_000;
|
|
36
|
+
const MAX_HANDSHAKE_BYTES = 16 * 1024;
|
|
37
|
+
// One `docker exec` per socket; a cap so a page that opens sockets in a loop cannot fork the host.
|
|
38
|
+
const MAX_SOCKETS_PER_SPACE = 72;
|
|
39
|
+
const CLOSE_REASON_BYTES = 123;
|
|
40
|
+
const CLOSE_GRACE_MS = 1_000;
|
|
41
|
+
|
|
42
|
+
const STATUS_TEXT = { 400: 'Bad Request', 401: 'Unauthorized', 403: 'Forbidden', 404: 'Not Found', 500: 'Internal Server Error', 502: 'Bad Gateway', 503: 'Service Unavailable' };
|
|
43
|
+
|
|
44
|
+
/** One HTTP answer on a socket that never got its upgrade, JSON with a stable code. */
|
|
45
|
+
const refuse = (socket, status, code, message) => {
|
|
46
|
+
if (socket.destroyed) return;
|
|
47
|
+
const body = Buffer.from(JSON.stringify({ error: message, code }), 'utf8');
|
|
48
|
+
try {
|
|
49
|
+
socket.write(`HTTP/1.1 ${status} ${STATUS_TEXT[status] ?? 'Bad Request'}\r\nConnection: close\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: ${body.length}\r\n\r\n`);
|
|
50
|
+
socket.end(body);
|
|
51
|
+
} catch {
|
|
52
|
+
socket.destroy();
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** A close frame from the server side, unmasked, as RFC 6455 has it. */
|
|
57
|
+
export const closeFrame = (code, reason) => {
|
|
58
|
+
const text = Buffer.from(String(reason ?? ''), 'utf8').subarray(0, CLOSE_REASON_BYTES);
|
|
59
|
+
const payload = Buffer.alloc(2 + text.length);
|
|
60
|
+
payload.writeUInt16BE(code, 0);
|
|
61
|
+
text.copy(payload, 2);
|
|
62
|
+
return Buffer.concat([Buffer.from([0x88, payload.length]), payload]);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const headerValue = (value) => (Array.isArray(value) ? value.join(', ') : value);
|
|
66
|
+
|
|
67
|
+
/** The handshake as the server inside gets it: the request line, then the forwarded headers. */
|
|
68
|
+
export const buildHandshake = ({ innerPath, search, requestHeaders, cookie }) => {
|
|
69
|
+
const headers = forwardRequestHeaders(requestHeaders, cookie);
|
|
70
|
+
headers.connection = 'Upgrade';
|
|
71
|
+
headers.upgrade = 'websocket';
|
|
72
|
+
// The server inside checks the origin of an upgrade against its own host, as the host does.
|
|
73
|
+
// The client's origin is not what it gets: the loopback origin is, the same one the relay
|
|
74
|
+
// host presents when it dials the host's loopback.
|
|
75
|
+
headers.origin = `http://${SPACE_SERVER_HOST}:${SPACE_SERVER_PORT}`;
|
|
76
|
+
for (const name of HANDSHAKE_REQUEST_HEADERS) {
|
|
77
|
+
const value = headerValue(requestHeaders[name]);
|
|
78
|
+
if (value !== undefined) headers[name] = value;
|
|
79
|
+
}
|
|
80
|
+
const lines = [`GET ${innerPath}${search} HTTP/1.1`];
|
|
81
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
82
|
+
const text = String(value);
|
|
83
|
+
if (/[\r\n]/.test(text)) throw new SpaceError('invalid_request', `The header ${name} holds a line break`);
|
|
84
|
+
lines.push(`${name}: ${text}`);
|
|
85
|
+
}
|
|
86
|
+
return `${lines.join('\r\n')}\r\n\r\n`;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** The status and the headers of the answer inside, and the bytes that followed its header block. */
|
|
90
|
+
export const parseHandshakeAnswer = (buffer) => {
|
|
91
|
+
const end = buffer.indexOf('\r\n\r\n');
|
|
92
|
+
if (end < 0) return null;
|
|
93
|
+
const [statusLine, ...headerLines] = buffer.subarray(0, end).toString('utf8').split('\r\n');
|
|
94
|
+
const status = Number.parseInt(/^HTTP\/1\.[01] (\d{3})/.exec(statusLine)?.[1] ?? '', 10);
|
|
95
|
+
if (Number.isNaN(status)) throw new SpaceError('space_unreachable', 'The server inside the space answered the upgrade with something that is not HTTP');
|
|
96
|
+
const headers = {};
|
|
97
|
+
for (const line of headerLines) {
|
|
98
|
+
const colon = line.indexOf(':');
|
|
99
|
+
if (colon <= 0) continue;
|
|
100
|
+
headers[line.slice(0, colon).trim().toLowerCase()] = line.slice(colon + 1).trim();
|
|
101
|
+
}
|
|
102
|
+
return { status, headers, rest: buffer.subarray(end + 4) };
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* `dispatcher` is the space dispatcher, for its list and its sessions; `connect` is the place's.
|
|
107
|
+
* `uiAuthController`, `isRequestOriginAllowed` and `logger` are the host's.
|
|
108
|
+
*/
|
|
109
|
+
export function createSpaceWebSocketForwarder({ dispatcher, connect, uiAuthController, isRequestOriginAllowed, logger = console, maxSocketsPerSpace = MAX_SOCKETS_PER_SPACE }) {
|
|
110
|
+
const open = new Map();
|
|
111
|
+
|
|
112
|
+
// Handshakes under way count against the cap too: each one is a `docker exec` already.
|
|
113
|
+
const count = (spaceId) => open.get(spaceId)?.size ?? 0;
|
|
114
|
+
const track = (spaceId, pair) => {
|
|
115
|
+
if (!open.has(spaceId)) open.set(spaceId, new Set());
|
|
116
|
+
open.get(spaceId).add(pair);
|
|
117
|
+
};
|
|
118
|
+
const untrack = (spaceId, pair) => {
|
|
119
|
+
open.get(spaceId)?.delete(pair);
|
|
120
|
+
if (count(spaceId) === 0) open.delete(spaceId);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Who may open a socket: the same rule as the host's own handler for that socket. The dev
|
|
125
|
+
* tunnel also takes a client that authenticated with its own token and sends no origin, as
|
|
126
|
+
* the desktop's tunnel client does; the others need an origin the host trusts.
|
|
127
|
+
*/
|
|
128
|
+
const authorize = async (req, innerPath) => {
|
|
129
|
+
if (!uiAuthController?.enabled) return null;
|
|
130
|
+
if (innerPath === DEV_TUNNEL_PATH) {
|
|
131
|
+
const auth = await uiAuthController.resolveAuthContext(req, null, { allowUrlToken: true });
|
|
132
|
+
if (!auth) return { status: 401, code: 'unauthorized', message: 'UI authentication required' };
|
|
133
|
+
const hasOrigin = String(req.headers.origin ?? '').trim() !== '';
|
|
134
|
+
if (hasOrigin && !await isRequestOriginAllowed(req)) return { status: 403, code: 'invalid_origin', message: 'Invalid origin' };
|
|
135
|
+
if (!hasOrigin && auth.type !== 'client') return { status: 403, code: 'client_auth_required', message: 'Client authentication required' };
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
if (!await uiAuthController.ensureSessionToken(req, null)) return { status: 401, code: 'unauthorized', message: 'UI authentication required' };
|
|
139
|
+
if (!await isRequestOriginAllowed(req)) return { status: 403, code: 'invalid_origin', message: 'Invalid origin' };
|
|
140
|
+
return null;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* One handshake inside on a fresh stream. Resolves the stream with the answer, or rejects;
|
|
145
|
+
* `signal` aborting, the client having left, ends it at once.
|
|
146
|
+
*/
|
|
147
|
+
const handshake = (spaceId, request, signal) => new Promise((resolve, reject) => {
|
|
148
|
+
let stream;
|
|
149
|
+
let settled = false;
|
|
150
|
+
const chunks = [];
|
|
151
|
+
let received = 0;
|
|
152
|
+
const fail = (error) => {
|
|
153
|
+
if (settled) return;
|
|
154
|
+
settled = true;
|
|
155
|
+
clearTimeout(timer);
|
|
156
|
+
signal.removeEventListener('abort', onAbort);
|
|
157
|
+
stream?.destroy();
|
|
158
|
+
reject(error);
|
|
159
|
+
};
|
|
160
|
+
const onAbort = () => fail(new SpaceError('client_gone', 'The client left before the upgrade was answered'));
|
|
161
|
+
const timer = setTimeout(() => fail(new SpaceError('space_unreachable', 'The server inside the space did not answer the upgrade in time')), HANDSHAKE_TIMEOUT_MS);
|
|
162
|
+
timer.unref?.();
|
|
163
|
+
if (signal.aborted) { onAbort(); return; }
|
|
164
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
165
|
+
connect(spaceId).then((opened) => {
|
|
166
|
+
if (settled) { opened.destroy(); return; }
|
|
167
|
+
stream = opened;
|
|
168
|
+
stream.on('error', (error) => fail(error));
|
|
169
|
+
stream.on('end', () => fail(new SpaceError('space_unreachable', 'The server inside the space closed the upgrade')));
|
|
170
|
+
const onData = (chunk) => {
|
|
171
|
+
chunks.push(chunk);
|
|
172
|
+
received += chunk.length;
|
|
173
|
+
let answer;
|
|
174
|
+
try {
|
|
175
|
+
answer = parseHandshakeAnswer(Buffer.concat(chunks));
|
|
176
|
+
} catch (error) {
|
|
177
|
+
fail(error);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (!answer) {
|
|
181
|
+
if (received > MAX_HANDSHAKE_BYTES) fail(new SpaceError('space_unreachable', 'The server inside the space sent too many bytes before its upgrade answer'));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
settled = true;
|
|
185
|
+
clearTimeout(timer);
|
|
186
|
+
signal.removeEventListener('abort', onAbort);
|
|
187
|
+
stream.removeListener('data', onData);
|
|
188
|
+
stream.removeAllListeners('end');
|
|
189
|
+
stream.pause();
|
|
190
|
+
resolve({ stream, answer });
|
|
191
|
+
};
|
|
192
|
+
stream.on('data', onData);
|
|
193
|
+
stream.write(request);
|
|
194
|
+
}, fail);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
/** Joins the client's socket and the stream inside until either side ends. */
|
|
198
|
+
const join = ({ spaceId, socket, head, stream, answer, pair }) => {
|
|
199
|
+
pair.stream = stream;
|
|
200
|
+
const lines = ['HTTP/1.1 101 Switching Protocols'];
|
|
201
|
+
for (const [name, value] of Object.entries(answer.headers)) {
|
|
202
|
+
if (HANDSHAKE_RESPONSE_HEADERS.has(name)) lines.push(`${name}: ${value}`);
|
|
203
|
+
}
|
|
204
|
+
socket.write(`${lines.join('\r\n')}\r\n\r\n`);
|
|
205
|
+
if (answer.rest.length > 0) socket.write(answer.rest);
|
|
206
|
+
if (head.length > 0) stream.write(head);
|
|
207
|
+
socket.setNoDelay?.(true);
|
|
208
|
+
let ended = false;
|
|
209
|
+
const endWith = (code, reason) => {
|
|
210
|
+
if (ended) return;
|
|
211
|
+
ended = true;
|
|
212
|
+
if (!socket.destroyed) {
|
|
213
|
+
try { socket.end(closeFrame(code, reason)); } catch { socket.destroy(); }
|
|
214
|
+
// A client that never answers the close frame is not waited for.
|
|
215
|
+
setTimeout(() => socket.destroy(), CLOSE_GRACE_MS).unref?.();
|
|
216
|
+
}
|
|
217
|
+
stream.destroy();
|
|
218
|
+
};
|
|
219
|
+
socket.on('close', () => untrack(spaceId, pair));
|
|
220
|
+
stream.on('error', () => endWith(1011, 'space_unreachable'));
|
|
221
|
+
stream.on('end', () => endWith(1012, 'space_closed'));
|
|
222
|
+
stream.on('close', () => endWith(1012, 'space_closed'));
|
|
223
|
+
// The host's sockets allow a half-open state: the client's end arrives as `end` long
|
|
224
|
+
// before `close`, and the stream inside is killed at the first of the two.
|
|
225
|
+
socket.on('error', () => endWith(1011, 'client_gone'));
|
|
226
|
+
socket.on('end', () => endWith(1011, 'client_gone'));
|
|
227
|
+
socket.on('close', () => endWith(1011, 'client_gone'));
|
|
228
|
+
stream.pipe(socket, { end: false });
|
|
229
|
+
socket.pipe(stream, { end: false });
|
|
230
|
+
stream.resume();
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const forward = async (req, socket, head, route, search) => {
|
|
234
|
+
if (!await dispatcher.spaceSessions.isKnownSpace(route.spaceId)) {
|
|
235
|
+
refuse(socket, 404, 'space_not_found', 'There is no such space.');
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (count(route.spaceId) >= maxSocketsPerSpace) {
|
|
239
|
+
refuse(socket, 503, 'space_socket_limit', `Space ${route.spaceId} has too many open sockets.`);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
// The slot is taken now, before the first `docker exec`, and given back on every failure.
|
|
243
|
+
const pair = { socket, stream: null };
|
|
244
|
+
track(route.spaceId, pair);
|
|
245
|
+
const gone = new AbortController();
|
|
246
|
+
const onLeave = () => gone.abort();
|
|
247
|
+
// The socket has to flow for its `end` to be seen, and `end` as well as `close` is needed:
|
|
248
|
+
// the host's sockets allow a half-open state, see `join`. What the client sends before the
|
|
249
|
+
// answer, which a WebSocket client does not, is kept for the stream inside.
|
|
250
|
+
const early = [];
|
|
251
|
+
const onEarly = (chunk) => early.push(chunk);
|
|
252
|
+
socket.on('data', onEarly);
|
|
253
|
+
socket.on('end', onLeave);
|
|
254
|
+
socket.on('close', onLeave);
|
|
255
|
+
try {
|
|
256
|
+
const attempt = async (cookie) => handshake(route.spaceId, buildHandshake({ innerPath: route.path, search, requestHeaders: req.headers, cookie }), gone.signal);
|
|
257
|
+
let result = await attempt(await dispatcher.spaceSessions.cookieFor(route.spaceId));
|
|
258
|
+
if (result.answer.status === 401) {
|
|
259
|
+
// The server inside refused the session: renewed once, as for HTTP. An upgrade has no
|
|
260
|
+
// body, so the fresh attempt is the same request.
|
|
261
|
+
result.stream.destroy();
|
|
262
|
+
result = await attempt(await dispatcher.spaceSessions.renew(route.spaceId));
|
|
263
|
+
}
|
|
264
|
+
if (result.answer.status !== 101) {
|
|
265
|
+
result.stream.destroy();
|
|
266
|
+
const status = result.answer.status >= 400 && result.answer.status < 600 ? result.answer.status : 502;
|
|
267
|
+
throw new SpaceError('space_socket_refused', `The server inside the space refused the socket with status ${result.answer.status}.`, { status });
|
|
268
|
+
}
|
|
269
|
+
socket.removeListener('data', onEarly);
|
|
270
|
+
socket.removeListener('end', onLeave);
|
|
271
|
+
socket.removeListener('close', onLeave);
|
|
272
|
+
join({ spaceId: route.spaceId, socket, head: Buffer.concat([head, ...early]), stream: result.stream, answer: result.answer, pair });
|
|
273
|
+
} catch (error) {
|
|
274
|
+
socket.removeListener('data', onEarly);
|
|
275
|
+
socket.removeListener('end', onLeave);
|
|
276
|
+
socket.removeListener('close', onLeave);
|
|
277
|
+
untrack(route.spaceId, pair);
|
|
278
|
+
throw error;
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
/** The `upgrade` handler: takes every upgrade under the prefix, leaves every other one alone. */
|
|
283
|
+
const upgradeHandler = (req, socket, head) => {
|
|
284
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
285
|
+
const route = parseSpaceRoute(url.pathname);
|
|
286
|
+
if (!route) return;
|
|
287
|
+
socket.on('error', () => {});
|
|
288
|
+
if (!isSpaceId(route.spaceId)) { refuse(socket, 404, 'space_not_found', 'There is no such space.'); return; }
|
|
289
|
+
const rule = classifySpacePath(route.path);
|
|
290
|
+
if (rule === 'host_only') { refuse(socket, 403, 'host_only_route', 'This route exists on the host only and is not forwarded to a space.'); return; }
|
|
291
|
+
if (rule === 'refused_across_boundary') { refuse(socket, 403, 'refused_across_boundary', 'This action does not cross the boundary of an isolated space.'); return; }
|
|
292
|
+
const innerPath = route.path.toLowerCase();
|
|
293
|
+
if (!SPACE_WEBSOCKET_PATHS.includes(innerPath)) { refuse(socket, 404, 'space_socket_unknown', 'No socket of a space answers at this path.'); return; }
|
|
294
|
+
void (async () => {
|
|
295
|
+
try {
|
|
296
|
+
const denied = await authorize(req, innerPath);
|
|
297
|
+
if (denied) { refuse(socket, denied.status, denied.code, denied.message); return; }
|
|
298
|
+
if (requestedDirectories(req, url).some((directory) => !isDirectoryOfSpace(directory, route.spaceId))) {
|
|
299
|
+
refuse(socket, 400, 'directory_outside_space', `A request to space ${route.spaceId} may name a directory under ${spaceWorkPath(route.spaceId)} only.`);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
stripCredentialQuery(url);
|
|
303
|
+
await forward(req, socket, head, { spaceId: route.spaceId, path: innerPath }, url.search);
|
|
304
|
+
} catch (error) {
|
|
305
|
+
if (error?.code === 'client_gone') { socket.destroy(); return; }
|
|
306
|
+
if (error?.code === 'space_socket_refused') {
|
|
307
|
+
refuse(socket, error.details.status, error.code, error.message);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const failure = describeFailure(error);
|
|
311
|
+
logger.warn?.(`[spaces] socket to space ${route.spaceId} failed: ${failure.code}`);
|
|
312
|
+
refuse(socket, failure.status, failure.code, failure.message);
|
|
313
|
+
}
|
|
314
|
+
})();
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
/** Ends every open socket into every space. */
|
|
318
|
+
const close = () => {
|
|
319
|
+
for (const pairs of open.values()) {
|
|
320
|
+
for (const pair of pairs) {
|
|
321
|
+
pair.stream?.destroy();
|
|
322
|
+
pair.socket.destroy();
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
open.clear();
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
return { upgradeHandler, close, openSocketCount: (spaceId) => count(spaceId) };
|
|
329
|
+
}
|