@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
|
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
|
|
2
2
|
|
|
3
3
|
import { findGatekeeperHardeningViolations, findHardeningViolations, requireMemoryBytes } from './hardening.js';
|
|
4
4
|
import { buildSpaceLabels, buildToolsLabels, hashProjectDirectory } from './labels.js';
|
|
5
|
+
import { SPACE_ENVIRONMENT } from './layout.js';
|
|
5
6
|
import { bridgeNetworkEntry, hardenedContainerEntry, internalNetworkEntry } from './places/fake-docker.js';
|
|
6
7
|
|
|
7
8
|
const ID = 'a1b2c3d4e5f6';
|
|
@@ -78,8 +79,11 @@ describe('findHardeningViolations', () => {
|
|
|
78
79
|
['user', { ...goodContainer(), Config: { User: '' } }],
|
|
79
80
|
['user', { ...goodContainer(), Config: { User: '0:0' } }],
|
|
80
81
|
['environment', withEnv(['OPENCHAMBER_UI_PASSWORD=secret'])],
|
|
81
|
-
['environment', withEnv(['OPENCODE_AUTH_CONTENT={}'])],
|
|
82
82
|
['environment', withEnv(['OPENCHAMBER_UI_PASSWORD='])],
|
|
83
|
+
// OpenCode 2 takes a provider key from its environment, directly or inside its config text.
|
|
84
|
+
['environment', withEnv(['OPENAI_API_KEY=x'])],
|
|
85
|
+
['environment', withEnv(['OPENCODE_CONFIG_CONTENT={"provider":{"groq":{"options":{"apiKey":"x"}}}}'])],
|
|
86
|
+
['environment', withEnv(['OPENCODE_CLI_CONFIG_CONTENT={}'])],
|
|
83
87
|
['read_only', withHost({ ReadonlyRootfs: false })],
|
|
84
88
|
['privileged', withHost({ Privileged: true })],
|
|
85
89
|
['cap_drop', withHost({ CapDrop: ['NET_RAW'] })],
|
|
@@ -143,8 +147,18 @@ describe('findHardeningViolations', () => {
|
|
|
143
147
|
expect(checksFor({ container: withHost({ Binds: ['/var/run/docker.sock:/var/run/docker.sock'] }) })).toEqual(['binds', 'runtime_socket']);
|
|
144
148
|
});
|
|
145
149
|
|
|
146
|
-
it('accepts
|
|
147
|
-
|
|
150
|
+
it('accepts exactly the variables a space gets and the base image sets', () => {
|
|
151
|
+
const full = withEnv([
|
|
152
|
+
...Object.entries(SPACE_ENVIRONMENT).map(([name, value]) => `${name}=${value}`),
|
|
153
|
+
'NODE_VERSION=22.23.2',
|
|
154
|
+
'YARN_VERSION=1.22.22',
|
|
155
|
+
]);
|
|
156
|
+
expect(checksFor({ container: full })).toEqual([]);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('refuses any other variable, whatever its name says', () => {
|
|
160
|
+
const violations = findHardeningViolations({ spaceId: ID, owner: OWNER, container: withEnv(['NOT_OPENCHAMBER_UI_PASSWORD=x']), network: goodNetwork(), toolsVolume: goodToolsVolume() });
|
|
161
|
+
expect(violations).toEqual([{ check: 'environment', message: 'The container environment holds variables nobody set for it: NOT_OPENCHAMBER_UI_PASSWORD' }]);
|
|
148
162
|
});
|
|
149
163
|
|
|
150
164
|
describe('tools mount', () => {
|
|
@@ -295,6 +309,7 @@ describe('findGatekeeperHardeningViolations', () => {
|
|
|
295
309
|
|
|
296
310
|
it.each([
|
|
297
311
|
['gatekeeper_environment', { ...goodGatekeeper(), Config: { ...goodGatekeeper().Config, Env: ['HOME=/tmp', 'OPENCHAMBER_UI_PASSWORD=secret'] } }],
|
|
312
|
+
['gatekeeper_environment', { ...goodGatekeeper(), Config: { ...goodGatekeeper().Config, Env: ['HOME=/tmp', 'OPENAI_API_KEY=x'] } }],
|
|
298
313
|
['gatekeeper_user', { ...goodGatekeeper(), Config: { ...goodGatekeeper().Config, User: '0:0' } }],
|
|
299
314
|
['gatekeeper_read_only', withGatekeeperHost({ ReadonlyRootfs: false })],
|
|
300
315
|
['gatekeeper_privileged', withGatekeeperHost({ Privileged: true })],
|
|
@@ -44,6 +44,9 @@ const REMOVED_VARIABLES = new Set([
|
|
|
44
44
|
'GIT_NOGLOB_PATHSPECS',
|
|
45
45
|
'GIT_ICASE_PATHSPECS',
|
|
46
46
|
'GIT_PROTOCOL_FROM_USER',
|
|
47
|
+
// Reads every attribute from a tree instead of the working tree. Code out reads the working tree back
|
|
48
|
+
// with exactly the attributes it holds, and code in snapshots it with them.
|
|
49
|
+
'GIT_ATTR_SOURCE',
|
|
47
50
|
'GIT_AUTHOR_NAME',
|
|
48
51
|
'GIT_AUTHOR_EMAIL',
|
|
49
52
|
'GIT_AUTHOR_DATE',
|
|
@@ -101,18 +104,18 @@ const subcommandOf = (args) => {
|
|
|
101
104
|
* `run(directory, args, options)` resolves `{ code, stdout, stderr }` for any exit code, and
|
|
102
105
|
* `output(directory, args, options)` resolves stdout and rejects with `git_command_failed` for a
|
|
103
106
|
* non-zero one. Both run `git -C <directory> ...` through `runCommand`, with an argv and no shell.
|
|
104
|
-
* `options.env` adds variables for one call, such as `GIT_INDEX_FILE`. `options.
|
|
105
|
-
* `runCommand` unchanged.
|
|
107
|
+
* `options.env` adds variables for one call, such as `GIT_INDEX_FILE`. `options.keepTail`,
|
|
108
|
+
* `options.killTree`, `options.keepAtExit` and `options.signal` go to `runCommand` unchanged.
|
|
106
109
|
*
|
|
107
110
|
* `gitPath` is the git program, `environment` the environment to start from.
|
|
108
111
|
*/
|
|
109
112
|
export function createHostGit({ runCommand, gitPath = 'git', environment = process.env }) {
|
|
110
113
|
const baseEnvironment = hostGitEnvironment(environment);
|
|
111
114
|
|
|
112
|
-
const run = (directory, args, { env = {}, stdin, timeoutMs = QUERY_TIMEOUT_MS, maxOutputBytes = QUERY_MAX_OUTPUT_BYTES, killTree = false } = {}) => runCommand(
|
|
115
|
+
const run = (directory, args, { env = {}, stdin, timeoutMs = QUERY_TIMEOUT_MS, maxOutputBytes = QUERY_MAX_OUTPUT_BYTES, keepTail = false, killTree = false, keepAtExit = false, signal } = {}) => runCommand(
|
|
113
116
|
gitPath,
|
|
114
117
|
['-C', directory, ...args],
|
|
115
|
-
{ env: { ...baseEnvironment, ...env }, stdin, timeoutMs, maxOutputBytes, killTree },
|
|
118
|
+
{ env: { ...baseEnvironment, ...env }, stdin, timeoutMs, maxOutputBytes, keepTail, killTree, keepAtExit, signal },
|
|
116
119
|
);
|
|
117
120
|
|
|
118
121
|
const output = async (directory, args, options) => {
|
|
@@ -12,7 +12,7 @@ const REMOVED = [
|
|
|
12
12
|
'GIT_CONFIG_KEY_0', 'GIT_CONFIG_VALUE_0', 'GIT_CONFIG_KEY_12', 'GIT_CONFIG_VALUE_12',
|
|
13
13
|
'GIT_TRACE', 'GIT_TRACE_PACKET', 'GIT_TRACE_PACK_ACCESS', 'GIT_TRACE_PERFORMANCE', 'GIT_TRACE_SETUP', 'GIT_TRACE_CURL',
|
|
14
14
|
'GIT_TRACE_SHALLOW', 'GIT_TRACE2', 'GIT_TRACE2_EVENT', 'GIT_TRACE2_PERF', 'GIT_TRACE2_CONFIG_PARAMS',
|
|
15
|
-
'GIT_LITERAL_PATHSPECS', 'GIT_GLOB_PATHSPECS', 'GIT_NOGLOB_PATHSPECS', 'GIT_ICASE_PATHSPECS',
|
|
15
|
+
'GIT_LITERAL_PATHSPECS', 'GIT_GLOB_PATHSPECS', 'GIT_NOGLOB_PATHSPECS', 'GIT_ICASE_PATHSPECS', 'GIT_ATTR_SOURCE',
|
|
16
16
|
];
|
|
17
17
|
const ADDED = { GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0' };
|
|
18
18
|
const mixedCase = (name) => name.toLowerCase().replace(/^git/, 'Git');
|
|
@@ -86,7 +86,8 @@ describe('createHostGit', () => {
|
|
|
86
86
|
it('runs git -C with an argv, the cleaned environment and the call options', async () => {
|
|
87
87
|
const { calls, runCommand } = recordingRunner({ code: 0, stdout: 'out\n', stderr: '' });
|
|
88
88
|
const git = createHostGit({ runCommand, gitPath: '/opt/git/bin/git', environment: { PATH: '/usr/bin', GIT_DIR: '/elsewhere' } });
|
|
89
|
-
|
|
89
|
+
const { signal } = new AbortController();
|
|
90
|
+
expect(await git.output('/repo with space', ['write-tree'], { env: { GIT_INDEX_FILE: '/tmp/index' }, keepTail: true, killTree: true, keepAtExit: true, signal })).toBe('out\n');
|
|
90
91
|
expect(calls).toEqual([{
|
|
91
92
|
file: '/opt/git/bin/git',
|
|
92
93
|
args: ['-C', '/repo with space', 'write-tree'],
|
|
@@ -95,7 +96,10 @@ describe('createHostGit', () => {
|
|
|
95
96
|
stdin: undefined,
|
|
96
97
|
timeoutMs: 120_000,
|
|
97
98
|
maxOutputBytes: 4 * 1024 * 1024,
|
|
99
|
+
keepTail: true,
|
|
98
100
|
killTree: true,
|
|
101
|
+
keepAtExit: true,
|
|
102
|
+
signal,
|
|
99
103
|
},
|
|
100
104
|
}]);
|
|
101
105
|
});
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
// What the host server builds when the isolated-spaces switch is on, and nothing of it when it
|
|
2
|
+
// is off: the place, the manager, the dispatcher, the WebSocket forwarder, the session index
|
|
3
|
+
// with the event connection of every space, and the hooks the rest of the server takes.
|
|
4
|
+
//
|
|
5
|
+
// `server/index.js` reads the switch at start and changes it live through the switch route.
|
|
6
|
+
// While it is off this module is never imported for its effect: no place, no manager, no
|
|
7
|
+
// route, no `docker`.
|
|
8
|
+
|
|
9
|
+
import crypto from 'node:crypto';
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { z } from 'zod';
|
|
14
|
+
|
|
15
|
+
import { createCodeIn } from './code-in.js';
|
|
16
|
+
import { createCodeOut } from './code-out.js';
|
|
17
|
+
import { createSpaceDispatcher } from './dispatcher.js';
|
|
18
|
+
import { createGatekeeperChannel } from './gatekeeper-channel.js';
|
|
19
|
+
import { createHostGit } from './host-git.js';
|
|
20
|
+
import { createSpaceJourney } from './journey.js';
|
|
21
|
+
import { hashProjectDirectory } from './labels.js';
|
|
22
|
+
import { spaceProjectPath } from './layout.js';
|
|
23
|
+
import { createSpaceManager } from './manager.js';
|
|
24
|
+
import { createSpaceRecords } from './space-records.js';
|
|
25
|
+
import { createSpaceEventSources } from './space-events.js';
|
|
26
|
+
import { createSpaceOpenCode } from './space-opencode.js';
|
|
27
|
+
import { createSpaceSessionIndex, mergeSessionLists } from './space-sessions.js';
|
|
28
|
+
import { createSpaceWebSocketForwarder } from './websocket.js';
|
|
29
|
+
import { createDockerPlace } from './places/docker.js';
|
|
30
|
+
import { createPlaceRegistry } from './places/registry.js';
|
|
31
|
+
import { openCommandStream as openCommandStreamProcess, runCommand as runCommandProcess } from './run-command.js';
|
|
32
|
+
import { createSpaceServerChannel } from './space-server.js';
|
|
33
|
+
import { createRegistryToolsSource, readHostToolVersions } from './tools.js';
|
|
34
|
+
|
|
35
|
+
const OWNER_FILE = path.join('spaces', 'owner');
|
|
36
|
+
|
|
37
|
+
// How often the host reads its list of spaces to follow their event streams, and how long a
|
|
38
|
+
// read serves the merged session list before it is repeated.
|
|
39
|
+
const FOLLOW_INTERVAL_MS = 15_000;
|
|
40
|
+
const LIST_TTL_MS = 2_000;
|
|
41
|
+
// A space's session list is read in pages; more than this many is reported as partial.
|
|
42
|
+
const SESSION_PAGE_LIMIT = 100;
|
|
43
|
+
const SESSION_MAX_PAGES = 10;
|
|
44
|
+
const SESSION_LIST_TIMEOUT_MS = 10_000;
|
|
45
|
+
const MAX_SESSION_LIST_BYTES = 8 * 1024 * 1024;
|
|
46
|
+
// The cursor of a next page, as the server inside names it; anything else ends the read.
|
|
47
|
+
const nextCursorSchema = z.string().min(1);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The installation id that labels this host's spaces, so two installations that share a Docker
|
|
51
|
+
* daemon never see each other's. Made once, at the first start with the switch on, and kept in
|
|
52
|
+
* the data directory. It is a random token and names nothing about the machine.
|
|
53
|
+
*/
|
|
54
|
+
export function readOrCreateOwner(dataDir) {
|
|
55
|
+
const file = path.join(dataDir, OWNER_FILE);
|
|
56
|
+
try {
|
|
57
|
+
const existing = fs.readFileSync(file, 'utf8').trim();
|
|
58
|
+
if (/^[a-z0-9]{16,64}$/.test(existing)) return existing;
|
|
59
|
+
} catch {
|
|
60
|
+
// Made below.
|
|
61
|
+
}
|
|
62
|
+
const owner = crypto.randomBytes(12).toString('hex');
|
|
63
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
64
|
+
fs.writeFileSync(file, `${owner}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
65
|
+
return owner;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* `dataDir` is the host's data directory, `dockerPath` the docker CLI to run and `gitPath` the
|
|
70
|
+
* host git that moves code in and out, with `hostEnvironment` as its environment: git starts
|
|
71
|
+
* `docker exec` itself, so the PATH in it must find docker. `place` replaces the Docker place,
|
|
72
|
+
* for the tests; `runCommand` and `openCommandStream` are the two ways this module starts a
|
|
73
|
+
* process, injectable for the same reason. `listProjectDirectories` answers the host's registered
|
|
74
|
+
* project paths, so a space's project label can be resolved to the project it was made for;
|
|
75
|
+
* without it every space is marked as of an unknown project.
|
|
76
|
+
*/
|
|
77
|
+
export function createSpacesHost({
|
|
78
|
+
dataDir,
|
|
79
|
+
dockerPath = 'docker',
|
|
80
|
+
gitPath = 'git',
|
|
81
|
+
hostEnvironment = process.env,
|
|
82
|
+
listProjectDirectories = async () => [],
|
|
83
|
+
runCommand = runCommandProcess,
|
|
84
|
+
openCommandStream = openCommandStreamProcess,
|
|
85
|
+
place = null,
|
|
86
|
+
logger = console,
|
|
87
|
+
now = Date.now,
|
|
88
|
+
setTimer = setInterval,
|
|
89
|
+
clearTimer = clearInterval,
|
|
90
|
+
}) {
|
|
91
|
+
const dockerPlace = place ?? createDockerPlace({
|
|
92
|
+
runCommand,
|
|
93
|
+
openCommandStream,
|
|
94
|
+
dockerPath,
|
|
95
|
+
owner: readOrCreateOwner(dataDir),
|
|
96
|
+
toolsSource: createRegistryToolsSource(readHostToolVersions()),
|
|
97
|
+
});
|
|
98
|
+
const registry = createPlaceRegistry([dockerPlace]);
|
|
99
|
+
registry.seal();
|
|
100
|
+
const manager = createSpaceManager({ registry });
|
|
101
|
+
const serverInside = createSpaceServerChannel({ exec: dockerPlace.exec });
|
|
102
|
+
const gatekeeper = createGatekeeperChannel({ exec: dockerPlace.exec });
|
|
103
|
+
const git = createHostGit({ runCommand, gitPath, environment: hostEnvironment });
|
|
104
|
+
const codeIn = createCodeIn({ git, place: dockerPlace });
|
|
105
|
+
const codeOut = createCodeOut({ git, place: dockerPlace });
|
|
106
|
+
const records = createSpaceRecords({ dataDir, logger });
|
|
107
|
+
const spaceOpenCode = createSpaceOpenCode({ exec: dockerPlace.exec });
|
|
108
|
+
|
|
109
|
+
const listSpaces = () => manager.listSpaces({ placeId: dockerPlace.id });
|
|
110
|
+
const dispatcher = createSpaceDispatcher({
|
|
111
|
+
logger,
|
|
112
|
+
transport: {
|
|
113
|
+
listSpaceIds: async () => (await listSpaces()).map((space) => space.id),
|
|
114
|
+
connect: (spaceId) => dockerPlace.connect(spaceId),
|
|
115
|
+
readToken: (spaceId) => serverInside.readToken(spaceId),
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const index = createSpaceSessionIndex({ logger });
|
|
120
|
+
let events = null;
|
|
121
|
+
let unsubscribeHostEvents = null;
|
|
122
|
+
let followTimer = null;
|
|
123
|
+
let known = { spaces: [], readAt: -Infinity };
|
|
124
|
+
let listing = null;
|
|
125
|
+
// When each space's session list was last read; within the TTL the accepted list is served again.
|
|
126
|
+
const listReadAt = new Map();
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Which registered project a space was made for, by the label's hash of the project path. A
|
|
130
|
+
* failed read of the projects, or a project since removed, leaves the space with no project.
|
|
131
|
+
*/
|
|
132
|
+
const resolveProjects = async (spaces) => {
|
|
133
|
+
if (spaces.length === 0) return new Map();
|
|
134
|
+
let directories = [];
|
|
135
|
+
try {
|
|
136
|
+
directories = await listProjectDirectories();
|
|
137
|
+
} catch (error) {
|
|
138
|
+
logger.warn?.(`[spaces] could not read the projects: ${error?.code ?? error?.message ?? error}`);
|
|
139
|
+
}
|
|
140
|
+
const byHash = new Map(directories.map((directory) => [hashProjectDirectory(directory), directory]));
|
|
141
|
+
return new Map(spaces.map((space) => [space.id, byHash.get(space.project) ?? null]));
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* This host's spaces, each with its name and the project it was made for, read again when
|
|
146
|
+
* older than the TTL. A failed read keeps the last ones.
|
|
147
|
+
*/
|
|
148
|
+
const knownSpaces = async () => {
|
|
149
|
+
if (now() - known.readAt < LIST_TTL_MS) return known.spaces;
|
|
150
|
+
if (!listing) {
|
|
151
|
+
listing = listSpaces()
|
|
152
|
+
.then(async (spaces) => {
|
|
153
|
+
const projects = await resolveProjects(spaces);
|
|
154
|
+
known = {
|
|
155
|
+
spaces: spaces.map((space) => {
|
|
156
|
+
const projectDirectory = projects.get(space.id) ?? null;
|
|
157
|
+
return {
|
|
158
|
+
id: space.id,
|
|
159
|
+
name: space.name,
|
|
160
|
+
projectDirectory,
|
|
161
|
+
directory: projectDirectory === null ? null : spaceProjectPath(space.id, projectDirectory),
|
|
162
|
+
};
|
|
163
|
+
}),
|
|
164
|
+
readAt: now(),
|
|
165
|
+
};
|
|
166
|
+
})
|
|
167
|
+
.catch((error) => { logger.warn?.(`[spaces] could not list the spaces: ${error?.code ?? error?.message ?? error}`); })
|
|
168
|
+
.finally(() => { listing = null; });
|
|
169
|
+
}
|
|
170
|
+
await listing;
|
|
171
|
+
return known.spaces;
|
|
172
|
+
};
|
|
173
|
+
const spaceIds = async () => (await knownSpaces()).map((space) => space.id);
|
|
174
|
+
|
|
175
|
+
const follow = async () => {
|
|
176
|
+
const ids = await spaceIds();
|
|
177
|
+
events?.sync(ids);
|
|
178
|
+
};
|
|
179
|
+
/** The list is read again at once, and the event connections follow it: for a space just made or removed. */
|
|
180
|
+
const refresh = () => {
|
|
181
|
+
known = { spaces: known.spaces, readAt: -Infinity };
|
|
182
|
+
return follow();
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
let hub = null;
|
|
186
|
+
const journey = createSpaceJourney({
|
|
187
|
+
manager,
|
|
188
|
+
place: dockerPlace,
|
|
189
|
+
gatekeeper,
|
|
190
|
+
codeIn,
|
|
191
|
+
codeOut,
|
|
192
|
+
records,
|
|
193
|
+
spaceOpenCode,
|
|
194
|
+
listProjectDirectories,
|
|
195
|
+
// A key named by an environment variable is read from the host's own environment, now, and
|
|
196
|
+
// its value is kept nowhere (decision 5).
|
|
197
|
+
readHostSecret: (name) => hostEnvironment[name],
|
|
198
|
+
announce: (spaceId, payload) => { hub?.injectEvent({ payload, directory: 'global', spaceId }); },
|
|
199
|
+
onSpacesChanged: () => { void refresh().catch(() => {}); },
|
|
200
|
+
logger,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const readBody = (response, cap) => new Promise((resolve, reject) => {
|
|
204
|
+
const chunks = [];
|
|
205
|
+
let size = 0;
|
|
206
|
+
response.on('data', (chunk) => {
|
|
207
|
+
size += chunk.length;
|
|
208
|
+
if (size > cap) { response.destroy(); reject(new Error(`the list exceeds ${cap} bytes`)); return; }
|
|
209
|
+
chunks.push(chunk);
|
|
210
|
+
});
|
|
211
|
+
response.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
212
|
+
response.on('error', reject);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
/** One space's whole session list, page by page, or as much of it as the page cap allows. */
|
|
216
|
+
const readSpaceSessions = async (spaceId) => {
|
|
217
|
+
const records = [];
|
|
218
|
+
let cursor = null;
|
|
219
|
+
for (let page = 0; page < SESSION_MAX_PAGES; page += 1) {
|
|
220
|
+
const query = new URLSearchParams({ limit: String(SESSION_PAGE_LIMIT) });
|
|
221
|
+
if (cursor !== null) query.set('cursor', cursor);
|
|
222
|
+
const response = await dispatcher.requestInside(spaceId, { path: `/api/session?${query}`, headers: { accept: 'application/json' }, timeoutMs: SESSION_LIST_TIMEOUT_MS });
|
|
223
|
+
if (response.statusCode !== 200) { response.resume(); throw new Error(`status ${response.statusCode}`); }
|
|
224
|
+
const payload = JSON.parse(await readBody(response, MAX_SESSION_LIST_BYTES));
|
|
225
|
+
const data = Array.isArray(payload) ? payload : payload?.data;
|
|
226
|
+
if (!Array.isArray(data)) throw new Error('the list is not a list');
|
|
227
|
+
records.push(...data);
|
|
228
|
+
const next = nextCursorSchema.safeParse(payload?.cursor?.next);
|
|
229
|
+
if (!next.success || data.length === 0) return { records, complete: true };
|
|
230
|
+
cursor = next.data;
|
|
231
|
+
}
|
|
232
|
+
return { records, complete: false };
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* The host's session list with every space's after it. Each reachable space is asked once,
|
|
237
|
+
* all of them at the same time; one that does not answer keeps its last known list, marked
|
|
238
|
+
* stale. A host list without spaces goes back exactly as it came.
|
|
239
|
+
*/
|
|
240
|
+
const mergeSessionList = async (hostPayload) => {
|
|
241
|
+
const hostRecords = Array.isArray(hostPayload) ? hostPayload : hostPayload?.data;
|
|
242
|
+
if (Array.isArray(hostRecords)) index.observeHostRecords(hostRecords);
|
|
243
|
+
const spaces = await knownSpaces();
|
|
244
|
+
if (spaces.length === 0) return hostPayload;
|
|
245
|
+
await Promise.all(spaces.map(async ({ id: spaceId }) => {
|
|
246
|
+
if (now() - (listReadAt.get(spaceId) ?? -Infinity) < LIST_TTL_MS) return;
|
|
247
|
+
try {
|
|
248
|
+
const { records, complete } = await readSpaceSessions(spaceId);
|
|
249
|
+
index.acceptSpaceList(spaceId, records, { complete });
|
|
250
|
+
listReadAt.set(spaceId, now());
|
|
251
|
+
} catch (error) {
|
|
252
|
+
logger.warn?.(`[spaces] the session list of space ${spaceId} did not come: ${error?.code ?? error?.message ?? error}`);
|
|
253
|
+
index.markUnreachable(spaceId);
|
|
254
|
+
}
|
|
255
|
+
}));
|
|
256
|
+
// In the place's order, so the merged list reads the same from one call to the next.
|
|
257
|
+
const answers = new Map(index.snapshot().map((entry) => [entry.spaceId, entry]));
|
|
258
|
+
return mergeSessionLists(hostPayload, spaces.flatMap((space) => {
|
|
259
|
+
const answer = answers.get(space.id);
|
|
260
|
+
return answer === undefined ? [] : [{ ...answer, name: space.name, projectDirectory: space.projectDirectory, directory: space.directory }];
|
|
261
|
+
}));
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
let sockets = null;
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
manager,
|
|
268
|
+
dispatcher,
|
|
269
|
+
index,
|
|
270
|
+
journey,
|
|
271
|
+
/** The places a space can be made on, for the funnel. */
|
|
272
|
+
places: () => registry.list(),
|
|
273
|
+
/** The dispatcher, to mount after the API auth gate and before every route that reads a directory. */
|
|
274
|
+
middleware: dispatcher.middleware,
|
|
275
|
+
/**
|
|
276
|
+
* Makes the WebSocket forwarder, with the host's own auth and origin checks. `upgradeHandler`
|
|
277
|
+
* then takes the upgrades under the prefix, and nothing before this call.
|
|
278
|
+
*/
|
|
279
|
+
prepareUpgrades: ({ uiAuthController, isRequestOriginAllowed }) => {
|
|
280
|
+
sockets = createSpaceWebSocketForwarder({ dispatcher, connect: (spaceId) => dockerPlace.connect(spaceId), uiAuthController, isRequestOriginAllowed, logger });
|
|
281
|
+
},
|
|
282
|
+
upgradeHandler: (...args) => sockets?.upgradeHandler(...args),
|
|
283
|
+
/** Follows the spaces: an event connection for each, into the host's hub, and the host's own ids from its events. */
|
|
284
|
+
startEvents: (globalEventHub) => {
|
|
285
|
+
hub = globalEventHub;
|
|
286
|
+
events = createSpaceEventSources({ requestInside: dispatcher.requestInside, index, hub: globalEventHub, logger, now });
|
|
287
|
+
unsubscribeHostEvents = globalEventHub.subscribeEvent((event) => { if (event.spaceId === null) index.observeHostEvent(event.payload); });
|
|
288
|
+
followTimer = setTimer(() => { void follow(); }, FOLLOW_INTERVAL_MS);
|
|
289
|
+
followTimer?.unref?.();
|
|
290
|
+
return follow();
|
|
291
|
+
},
|
|
292
|
+
/** For the proxy: the merged session list, or the host's own when no space exists. */
|
|
293
|
+
mergeSessionList,
|
|
294
|
+
/** For the body parsers: a request to a space keeps its body for the space. */
|
|
295
|
+
skipsBodyParsing: (req) => dispatcher.isSpaceRequestPath(req.path),
|
|
296
|
+
/** For the directory gate: the reason a directory is refused on the host, or null. */
|
|
297
|
+
refuseDirectory: (candidate) => (dispatcher.isSpaceDirectory(candidate)
|
|
298
|
+
? 'A directory under /spaces/ belongs to an isolated space and is addressed as /api/spaces/<id>/... only'
|
|
299
|
+
: null),
|
|
300
|
+
close: () => {
|
|
301
|
+
if (followTimer !== null) clearTimer(followTimer);
|
|
302
|
+
followTimer = null;
|
|
303
|
+
unsubscribeHostEvents?.();
|
|
304
|
+
unsubscribeHostEvents = null;
|
|
305
|
+
events?.close();
|
|
306
|
+
sockets?.close();
|
|
307
|
+
dispatcher.close();
|
|
308
|
+
},
|
|
309
|
+
};
|
|
310
|
+
}
|