@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,296 @@
|
|
|
1
|
+
// The dispatcher in front of a real space on a real Docker daemon. Runs only with
|
|
2
|
+
// OPENCHAMBER_TEST_DOCKER=1. Two parts: the dispatcher over the Docker place, mounted in an
|
|
3
|
+
// Express app the way `server/index.js` mounts it; and the real server started headless, once
|
|
4
|
+
// with the switch off and once with it on, in front of a stand-in Docker daemon that records
|
|
5
|
+
// whether anything ever spoke to it.
|
|
6
|
+
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import http from 'node:http';
|
|
10
|
+
import net from 'node:net';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
|
|
15
|
+
import express from 'express';
|
|
16
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
17
|
+
|
|
18
|
+
import { createSpacesHost } from '../host.js';
|
|
19
|
+
import { createSpaceId, hashProjectDirectory } from '../labels.js';
|
|
20
|
+
import { LIVE_DOCKER_ENABLED, createLiveDockerPlace } from './docker-live-support.js';
|
|
21
|
+
|
|
22
|
+
const CREATE_TIMEOUT_MS = 25 * 60_000;
|
|
23
|
+
const SERVER_ENTRY = fileURLToPath(new URL('../../../index.js', import.meta.url));
|
|
24
|
+
|
|
25
|
+
const temporary = (prefix) => fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
26
|
+
|
|
27
|
+
const listen = (app) => new Promise((resolve) => {
|
|
28
|
+
const server = http.createServer(app);
|
|
29
|
+
server.listen(0, '127.0.0.1', () => resolve(server));
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe.skipIf(!LIVE_DOCKER_ENABLED)('dispatcher over a real space: docker (live)', () => {
|
|
33
|
+
const spec = {
|
|
34
|
+
id: createSpaceId(),
|
|
35
|
+
name: 'Dispatcher live',
|
|
36
|
+
project: hashProjectDirectory('/dispatcher/live/project'),
|
|
37
|
+
created: new Date().toISOString(),
|
|
38
|
+
memoryBytes: 2 * 1024 * 1024 * 1024,
|
|
39
|
+
};
|
|
40
|
+
const repo = `/spaces/${spec.id}/repo`;
|
|
41
|
+
let place;
|
|
42
|
+
let dispose = async () => {};
|
|
43
|
+
let dataDir;
|
|
44
|
+
let host;
|
|
45
|
+
let server;
|
|
46
|
+
let hostRouteRuns = 0;
|
|
47
|
+
const url = (suffix) => `http://127.0.0.1:${server.address().port}${suffix}`;
|
|
48
|
+
const shell = async (script) => {
|
|
49
|
+
const result = await place.exec(spec.id, ['sh', '-c', script]);
|
|
50
|
+
expect(result.code, `${script}: ${result.stderr}`).toBe(0);
|
|
51
|
+
return result.stdout;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
beforeAll(async () => {
|
|
55
|
+
({ place, dispose } = createLiveDockerPlace());
|
|
56
|
+
await place.create(spec);
|
|
57
|
+
await shell(`mkdir -p ${repo} && cd ${repo} && git init -q . && git -c user.email=space@example.invalid -c user.name=Space commit -q --allow-empty -m init`);
|
|
58
|
+
dataDir = temporary('openchamber-dispatcher-live-');
|
|
59
|
+
host = createSpacesHost({ dataDir, place, logger: { warn: () => {} } });
|
|
60
|
+
const app = express();
|
|
61
|
+
// The host's auth gate stands in front of the dispatcher in the real server; here the request
|
|
62
|
+
// is taken as authenticated, and one host route records whether it ever ran.
|
|
63
|
+
app.use(host.middleware);
|
|
64
|
+
app.all('/api/git/status', (req, res) => {
|
|
65
|
+
hostRouteRuns += 1;
|
|
66
|
+
res.json({ ranOnHost: true, directory: req.query.directory ?? null });
|
|
67
|
+
});
|
|
68
|
+
app.use((_req, res) => res.status(404).json({ error: 'not found on host' }));
|
|
69
|
+
server = await listen(app);
|
|
70
|
+
}, CREATE_TIMEOUT_MS);
|
|
71
|
+
|
|
72
|
+
afterAll(async () => {
|
|
73
|
+
host?.close();
|
|
74
|
+
await new Promise((resolve) => { if (server) server.close(() => resolve()); else resolve(); });
|
|
75
|
+
await dispose();
|
|
76
|
+
if (dataDir) fs.rmSync(dataDir, { recursive: true, force: true });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('forwards a prefixed request to the server inside and returns its answer', async () => {
|
|
80
|
+
const response = await fetch(url(`/api/spaces/${spec.id}/session?directory=${encodeURIComponent(repo)}`));
|
|
81
|
+
expect(response.status).toBe(200);
|
|
82
|
+
expect(response.headers.get('x-content-type-options')).toBe('nosniff');
|
|
83
|
+
expect(response.headers.get('set-cookie')).toBeNull();
|
|
84
|
+
const list = await response.json();
|
|
85
|
+
expect(Array.isArray(list.data ?? list)).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('creates a session inside through a streamed POST body, and lists it back', async () => {
|
|
89
|
+
const created = await fetch(url(`/api/spaces/${spec.id}/session`), {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
headers: { 'content-type': 'application/json' },
|
|
92
|
+
body: JSON.stringify({ location: { directory: repo } }),
|
|
93
|
+
});
|
|
94
|
+
expect(created.status, await created.clone().text()).toBe(200);
|
|
95
|
+
const session = (await created.json()).data;
|
|
96
|
+
expect(session.location.directory).toBe(repo);
|
|
97
|
+
|
|
98
|
+
const listed = await fetch(url(`/api/spaces/${spec.id}/session?directory=${encodeURIComponent(repo)}`));
|
|
99
|
+
const ids = ((await listed.json()).data ?? []).map((entry) => entry.id);
|
|
100
|
+
expect(ids).toContain(session.id);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('streams the event stream of the server inside', async () => {
|
|
104
|
+
const controller = new AbortController();
|
|
105
|
+
const response = await fetch(url(`/api/spaces/${spec.id}/event`), { signal: controller.signal, headers: { accept: 'text/event-stream' } });
|
|
106
|
+
expect(response.status).toBe(200);
|
|
107
|
+
expect(response.headers.get('content-type')).toContain('text/event-stream');
|
|
108
|
+
const reader = response.body.getReader();
|
|
109
|
+
const { value, done } = await reader.read();
|
|
110
|
+
expect(done).toBe(false);
|
|
111
|
+
expect(new TextDecoder().decode(value).length).toBeGreaterThan(0);
|
|
112
|
+
controller.abort();
|
|
113
|
+
}, 60_000);
|
|
114
|
+
|
|
115
|
+
it('refuses a prefixed request that names a directory outside the space, and a host-only route', async () => {
|
|
116
|
+
const outside = await fetch(url(`/api/spaces/${spec.id}/session?directory=${encodeURIComponent('/home/me/project')}`));
|
|
117
|
+
expect(outside.status).toBe(400);
|
|
118
|
+
expect(await outside.json()).toMatchObject({ code: 'directory_outside_space' });
|
|
119
|
+
const hostOnly = await fetch(url(`/api/spaces/${spec.id}/config/settings`));
|
|
120
|
+
expect(hostOnly.status).toBe(403);
|
|
121
|
+
expect(await hostOnly.json()).toMatchObject({ code: 'host_only_route' });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// The escape check of TESTING.md: a request without the space prefix, but with a space
|
|
125
|
+
// directory, does not run on the host.
|
|
126
|
+
it('refuses an unprefixed request with the space\'s directory before any host route runs', async () => {
|
|
127
|
+
const response = await fetch(url(`/api/git/status?directory=${encodeURIComponent(repo)}`));
|
|
128
|
+
expect(response.status).toBe(400);
|
|
129
|
+
expect(await response.json()).toMatchObject({ code: 'space_directory_needs_prefix' });
|
|
130
|
+
expect(hostRouteRuns).toBe(0);
|
|
131
|
+
const ordinary = await fetch(url('/api/git/status?directory=/tmp'));
|
|
132
|
+
expect(ordinary.status).toBe(200);
|
|
133
|
+
expect(hostRouteRuns).toBe(1);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Processes inside are read from /proc, because the image has no pgrep. The match is anchored
|
|
137
|
+
// to the bridge's own command line, so the shell that runs this count is not counted.
|
|
138
|
+
const bridgesInside = async () => Number((await shell('n=0; for p in /proc/[0-9]*; do if tr "\\0" " " < "$p/cmdline" 2>/dev/null | grep -q "^/usr/local/bin/node -e "; then n=$((n+1)); fi; done; echo $n')).trim());
|
|
139
|
+
|
|
140
|
+
it('ends the bridge inside when the host closes its connections', async () => {
|
|
141
|
+
expect((await fetch(url(`/api/spaces/${spec.id}/session?directory=${encodeURIComponent(repo)}`))).status).toBe(200);
|
|
142
|
+
expect(await bridgesInside()).toBeGreaterThan(0);
|
|
143
|
+
host.close();
|
|
144
|
+
let left = await bridgesInside();
|
|
145
|
+
for (let attempt = 0; attempt < 40 && left > 0; attempt += 1) {
|
|
146
|
+
await new Promise((resolve) => { setTimeout(resolve, 250); });
|
|
147
|
+
left = await bridgesInside();
|
|
148
|
+
}
|
|
149
|
+
expect(left).toBe(0);
|
|
150
|
+
// The next request opens a new one.
|
|
151
|
+
expect((await fetch(url(`/api/spaces/${spec.id}/session?directory=${encodeURIComponent(repo)}`))).status).toBe(200);
|
|
152
|
+
}, 60_000);
|
|
153
|
+
|
|
154
|
+
it('answers 503 with a stable code for a stopped space, and serves it again after a start', async () => {
|
|
155
|
+
await place.stop(spec.id);
|
|
156
|
+
const stopped = await fetch(url(`/api/spaces/${spec.id}/session?directory=${encodeURIComponent(repo)}`));
|
|
157
|
+
expect(stopped.status).toBe(503);
|
|
158
|
+
expect(await stopped.json()).toMatchObject({ code: 'space_not_running' });
|
|
159
|
+
|
|
160
|
+
await place.start(spec.id);
|
|
161
|
+
const again = await fetch(url(`/api/spaces/${spec.id}/session?directory=${encodeURIComponent(repo)}`));
|
|
162
|
+
expect(again.status).toBe(200);
|
|
163
|
+
}, 5 * 60_000);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The real server, started headless, with a stand-in for the Docker daemon on `DOCKER_HOST`
|
|
168
|
+
* that records every connection. The docker CLI on this machine is the real one: whether the
|
|
169
|
+
* feature ever called it is exactly what the daemon's connection count says.
|
|
170
|
+
*/
|
|
171
|
+
const startFakeDaemon = async (folder) => {
|
|
172
|
+
const socketPath = path.join(folder, 'docker.sock');
|
|
173
|
+
let connections = 0;
|
|
174
|
+
const open = new Set();
|
|
175
|
+
const daemon = net.createServer((socket) => {
|
|
176
|
+
connections += 1;
|
|
177
|
+
open.add(socket);
|
|
178
|
+
socket.on('close', () => open.delete(socket));
|
|
179
|
+
socket.on('error', () => {});
|
|
180
|
+
socket.end('HTTP/1.1 500 Internal Server Error\r\ncontent-length: 0\r\n\r\n');
|
|
181
|
+
});
|
|
182
|
+
await new Promise((resolve) => daemon.listen(socketPath, resolve));
|
|
183
|
+
return {
|
|
184
|
+
host: `unix://${socketPath}`,
|
|
185
|
+
connections: () => connections,
|
|
186
|
+
// A docker CLI may hold a connection open; `close` alone would wait for it for ever.
|
|
187
|
+
stop: () => new Promise((resolve) => {
|
|
188
|
+
for (const socket of open) socket.destroy();
|
|
189
|
+
daemon.close(() => resolve());
|
|
190
|
+
}),
|
|
191
|
+
};
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const freePort = () => new Promise((resolve) => {
|
|
195
|
+
const probe = net.createServer();
|
|
196
|
+
probe.listen(0, '127.0.0.1', () => {
|
|
197
|
+
const { port } = probe.address();
|
|
198
|
+
probe.close(() => resolve(port));
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const startServer = async ({ dataDir, dockerHost, port }) => {
|
|
203
|
+
const child = spawn(process.execPath, [SERVER_ENTRY, '--port', String(port), '--api-only'], {
|
|
204
|
+
env: { ...process.env, OPENCHAMBER_DATA_DIR: dataDir, DOCKER_HOST: dockerHost, OPENCHAMBER_RELAY_HOST: 'off' },
|
|
205
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
206
|
+
});
|
|
207
|
+
let output = '';
|
|
208
|
+
child.stdout.on('data', (chunk) => { output += chunk; });
|
|
209
|
+
child.stderr.on('data', (chunk) => { output += chunk; });
|
|
210
|
+
const base = `http://127.0.0.1:${port}`;
|
|
211
|
+
const deadline = Date.now() + 60_000;
|
|
212
|
+
let healthy = false;
|
|
213
|
+
while (!healthy && Date.now() < deadline) {
|
|
214
|
+
if (child.exitCode !== null) throw new Error(`the server left early:\n${output.slice(-2000)}`);
|
|
215
|
+
try {
|
|
216
|
+
healthy = (await fetch(`${base}/health`, { signal: AbortSignal.timeout(5_000) })).status === 200;
|
|
217
|
+
} catch {
|
|
218
|
+
// Not up yet.
|
|
219
|
+
}
|
|
220
|
+
if (!healthy) await new Promise((resolve) => { setTimeout(resolve, 250); });
|
|
221
|
+
}
|
|
222
|
+
if (!healthy) throw new Error(`the server did not become healthy:\n${output.slice(-2000)}`);
|
|
223
|
+
return {
|
|
224
|
+
base,
|
|
225
|
+
/** One request with a bound, so a hang names itself and the server's output instead of a timeout. */
|
|
226
|
+
get: async (suffix) => {
|
|
227
|
+
try {
|
|
228
|
+
return await fetch(`${base}${suffix}`, { signal: AbortSignal.timeout(30_000) });
|
|
229
|
+
} catch (error) {
|
|
230
|
+
throw new Error(`${suffix}: ${error.message}\n${output.slice(-2000)}`);
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
output: () => output,
|
|
234
|
+
stop: () => new Promise((resolve) => {
|
|
235
|
+
child.once('exit', resolve);
|
|
236
|
+
child.kill('SIGTERM');
|
|
237
|
+
setTimeout(() => child.kill('SIGKILL'), 10_000).unref();
|
|
238
|
+
}),
|
|
239
|
+
};
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
describe.skipIf(!LIVE_DOCKER_ENABLED)('the switch, on the real server (live)', () => {
|
|
243
|
+
const folders = [];
|
|
244
|
+
afterAll(() => {
|
|
245
|
+
for (const folder of folders) fs.rmSync(folder, { recursive: true, force: true });
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const startWith = async (enabled) => {
|
|
249
|
+
const folder = temporary('openchamber-switch-live-');
|
|
250
|
+
folders.push(folder);
|
|
251
|
+
const dataDir = path.join(folder, 'data');
|
|
252
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
253
|
+
// The server copies `projects`, `themes` and `speech-models` from the default data directory
|
|
254
|
+
// into a fresh one at start; the last of those can be gigabytes. Present and empty, they stay.
|
|
255
|
+
for (const entry of ['projects', 'themes', 'speech-models']) fs.mkdirSync(path.join(dataDir, entry));
|
|
256
|
+
fs.writeFileSync(path.join(dataDir, 'settings.json'), JSON.stringify({ isolatedSpacesEnabled: enabled }));
|
|
257
|
+
const daemon = await startFakeDaemon(folder);
|
|
258
|
+
const server = await startServer({ dataDir, dockerHost: daemon.host, port: await freePort() });
|
|
259
|
+
return { dataDir, daemon, server, stop: async () => { await server.stop(); await daemon.stop(); } };
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
it('registers no route of the feature and never speaks to Docker while the switch is off', async () => {
|
|
263
|
+
const run = await startWith(false);
|
|
264
|
+
try {
|
|
265
|
+
const prefixed = await run.server.get('/api/spaces/aaaaaaaaaaaa/session');
|
|
266
|
+
// Whatever the host answers, it is not the feature: no space code, and the request went on
|
|
267
|
+
// to the host's own routes.
|
|
268
|
+
const body = await prefixed.text();
|
|
269
|
+
expect(body).not.toMatch(/space_not_found|space_unreachable|host_only_route/);
|
|
270
|
+
const unguarded = await run.server.get('/api/git/status?directory=/spaces/aaaaaaaaaaaa/repo');
|
|
271
|
+
expect(unguarded.status).not.toBe(400);
|
|
272
|
+
expect(run.daemon.connections()).toBe(0);
|
|
273
|
+
expect(fs.existsSync(path.join(run.dataDir, 'spaces'))).toBe(false);
|
|
274
|
+
} finally {
|
|
275
|
+
await run.stop();
|
|
276
|
+
}
|
|
277
|
+
}, 120_000);
|
|
278
|
+
|
|
279
|
+
it('registers the routes and asks Docker for its spaces while the switch is on', async () => {
|
|
280
|
+
const run = await startWith(true);
|
|
281
|
+
try {
|
|
282
|
+
const prefixed = await run.server.get('/api/spaces/aaaaaaaaaaaa/session');
|
|
283
|
+
expect(prefixed.status).toBe(502);
|
|
284
|
+
expect(await prefixed.json()).toMatchObject({ code: 'space_unreachable' });
|
|
285
|
+
expect(run.daemon.connections()).toBeGreaterThan(0);
|
|
286
|
+
const guarded = await run.server.get('/api/git/status?directory=/spaces/aaaaaaaaaaaa/repo');
|
|
287
|
+
expect(guarded.status).toBe(400);
|
|
288
|
+
expect(await guarded.json()).toMatchObject({ code: 'space_directory_needs_prefix' });
|
|
289
|
+
const hostOnly = await run.server.get('/api/spaces/aaaaaaaaaaaa/config/settings');
|
|
290
|
+
expect(hostOnly.status).toBe(403);
|
|
291
|
+
expect(fs.existsSync(path.join(run.dataDir, 'spaces', 'owner'))).toBe(true);
|
|
292
|
+
} finally {
|
|
293
|
+
await run.stop();
|
|
294
|
+
}
|
|
295
|
+
}, 120_000);
|
|
296
|
+
});
|
|
@@ -118,13 +118,21 @@ export function createLiveDockerPlace({ toolsSource = createRegistryToolsSource(
|
|
|
118
118
|
const crypto = require('node:crypto');
|
|
119
119
|
const hash = (value) => (value === undefined ? 'none' : 'sha256:' + crypto.createHash('sha256').update(String(value)).digest('hex'));
|
|
120
120
|
require('node:http').createServer((request, response) => {
|
|
121
|
-
|
|
122
|
-
|
|
121
|
+
const seen = {
|
|
122
|
+
method: request.method,
|
|
123
123
|
path: request.url,
|
|
124
124
|
authorization: hash(request.headers.authorization),
|
|
125
125
|
apiKey: hash(request.headers['x-api-key']),
|
|
126
126
|
headerNames: Object.keys(request.headers).sort(),
|
|
127
|
-
}
|
|
127
|
+
};
|
|
128
|
+
// One line per request in the container's log, so the host can read what a client it does
|
|
129
|
+
// not control, OpenCode inside a space, sent here. Hashes only, as in the answer.
|
|
130
|
+
console.log('seen ' + JSON.stringify(seen));
|
|
131
|
+
request.resume();
|
|
132
|
+
request.on('end', () => {
|
|
133
|
+
response.writeHead(200, { 'content-type': 'application/json' });
|
|
134
|
+
response.end(JSON.stringify(seen));
|
|
135
|
+
});
|
|
128
136
|
}).listen(9000, '0.0.0.0', () => console.log('upstream listening'));
|
|
129
137
|
`;
|
|
130
138
|
await docker([
|
|
@@ -136,9 +144,24 @@ require('node:http').createServer((request, response) => {
|
|
|
136
144
|
await new Promise((resolve) => { setTimeout(resolve, 250); });
|
|
137
145
|
if ((await docker(['logs', name])).stdout.includes('upstream listening')) break;
|
|
138
146
|
}
|
|
139
|
-
return {
|
|
147
|
+
return {
|
|
148
|
+
url: 'http://upstream:9000/v1',
|
|
149
|
+
/** Every request this upstream has seen so far, oldest first, from its log. */
|
|
150
|
+
seen: async () => (await docker(['logs', name])).stdout.split('\n').filter((line) => line.startsWith('seen ')).map((line) => JSON.parse(line.slice(5))),
|
|
151
|
+
stop: () => docker(['rm', '--force', name]),
|
|
152
|
+
};
|
|
140
153
|
},
|
|
141
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Runs a Node script in a helper container on the space's outer network, where the stand-in
|
|
157
|
+
* upstream sits and where, on a Linux host, the host's own processes can reach. What such a
|
|
158
|
+
* neighbour can reach on the gatekeeper is what the gatekeeper's bind address decides.
|
|
159
|
+
*/
|
|
160
|
+
probeFromOuterNetwork: (spaceId, script) => docker(
|
|
161
|
+
['run', '--rm', ...helperLabels, '--network', spaceResourceName(spaceId, ROLE_OUTER_NETWORK), SPACE_BASE_IMAGE, 'node', '-e', script],
|
|
162
|
+
{ timeoutMs: 120_000 },
|
|
163
|
+
),
|
|
164
|
+
|
|
142
165
|
/**
|
|
143
166
|
* A name of our own in the space's outer network, answered by Docker's embedded resolver with
|
|
144
167
|
* an address in that bridge's subnet — a private range, and therefore one the corridor must
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
|
|
1
3
|
import { SpaceError } from '../errors.js';
|
|
2
4
|
import { createGatekeeperChannel } from '../gatekeeper-channel.js';
|
|
3
5
|
import {
|
|
@@ -25,7 +27,8 @@ import {
|
|
|
25
27
|
requireSpaceId,
|
|
26
28
|
spaceResourceName,
|
|
27
29
|
} from '../labels.js';
|
|
28
|
-
import { SPACE_USER, TOOLS_MOUNT_PATH } from '../layout.js';
|
|
30
|
+
import { SPACE_CONNECT_COMMAND, SPACE_USER, TOOLS_MOUNT_PATH } from '../layout.js';
|
|
31
|
+
import { openCommandStream as openCommandStreamProcess } from '../run-command.js';
|
|
29
32
|
import { createSpaceServerChannel, createSpaceToken } from '../space-server.js';
|
|
30
33
|
import { CHANGE_TIMEOUT_MS, ROLLBACK_SETTLE_MS, createDockerEngine, entryLabels, entryName, isInterrupted, pause } from './docker-engine.js';
|
|
31
34
|
import { createDockerTools } from './docker-tools.js';
|
|
@@ -57,7 +60,7 @@ function execRole(target) {
|
|
|
57
60
|
throw new SpaceError('invalid_exec_target', `A command runs in the space or in its gatekeeper, not in '${target}'`);
|
|
58
61
|
}
|
|
59
62
|
|
|
60
|
-
export function createDockerPlace({ runCommand, dockerPath, owner, toolsSource, wait = pause, now = () => new Date() }) {
|
|
63
|
+
export function createDockerPlace({ runCommand, openCommandStream = openCommandStreamProcess, dockerPath, owner, toolsSource, wait = pause, now = () => new Date() }) {
|
|
61
64
|
requireOwner(owner);
|
|
62
65
|
|
|
63
66
|
const engine = createDockerEngine({ runCommand, dockerPath });
|
|
@@ -307,7 +310,7 @@ export function createDockerPlace({ runCommand, dockerPath, owner, toolsSource,
|
|
|
307
310
|
await docker(['network', 'connect', outerNetwork, gatekeeperName], CHANGE_TIMEOUT_MS);
|
|
308
311
|
await requireGatekeeperVerified(id, await inspectOwnContainer(id, gatekeeperName) ?? {});
|
|
309
312
|
await docker(['start', gatekeeperName], CHANGE_TIMEOUT_MS);
|
|
310
|
-
await gatekeeper.writeProgram(id);
|
|
313
|
+
await gatekeeper.writeProgram(id, { bindAddress: await innerAddressOf(id) });
|
|
311
314
|
await gatekeeper.waitUntilReady(id);
|
|
312
315
|
// Create, verify, then start: a container that fails the check never runs.
|
|
313
316
|
await docker(buildSpaceCreateArgs({
|
|
@@ -388,6 +391,20 @@ export function createDockerPlace({ runCommand, dockerPath, owner, toolsSource,
|
|
|
388
391
|
// Starts that are under way in this process, by space id.
|
|
389
392
|
const starting = new Map();
|
|
390
393
|
|
|
394
|
+
/**
|
|
395
|
+
* The gatekeeper's own address on the space's inner network, read from the runtime once the
|
|
396
|
+
* container runs: that is where its corridor and window listen, and nowhere else. An engine
|
|
397
|
+
* that reports none leaves the gatekeeper unstarted, which is the safe answer.
|
|
398
|
+
*/
|
|
399
|
+
const innerAddressOf = async (spaceId) => {
|
|
400
|
+
const entry = await inspectOwnContainer(spaceId, spaceResourceName(spaceId, ROLE_GATEKEEPER));
|
|
401
|
+
const address = String(entry?.NetworkSettings?.Networks?.[spaceResourceName(spaceId, ROLE_NETWORK)]?.IPAddress ?? '');
|
|
402
|
+
if (net.isIP(address) === 0) {
|
|
403
|
+
throw new SpaceError('gatekeeper_address_unknown', `The runtime reports no address for the gatekeeper of space ${spaceId} on the space's network, so its listeners cannot be bound.`);
|
|
404
|
+
}
|
|
405
|
+
return address;
|
|
406
|
+
};
|
|
407
|
+
|
|
391
408
|
/** The gatekeeper container of a space. It is never renamed, so there is no move to repair here. */
|
|
392
409
|
const requireGatekeeperContainer = async (spaceId) => {
|
|
393
410
|
const name = spaceResourceName(spaceId, ROLE_GATEKEEPER);
|
|
@@ -425,6 +442,17 @@ export function createDockerPlace({ runCommand, dockerPath, owner, toolsSource,
|
|
|
425
442
|
return [dockerPath, 'exec', '--interactive', '--user', SPACE_USER, spaceResourceName(spaceId, ROLE_SPACE)];
|
|
426
443
|
};
|
|
427
444
|
|
|
445
|
+
/**
|
|
446
|
+
* A channel to the server inside the space: the bridge of `layout.js` over the argv of
|
|
447
|
+
* `execArgv`, so the same ownership and running checks come first, and the same process
|
|
448
|
+
* shape as a push carries the bytes. The space's network never sees it. The stream is a
|
|
449
|
+
* `CommandStream` of `run-command.js`, and the dispatcher's agent uses it as a socket.
|
|
450
|
+
*/
|
|
451
|
+
const connect = async (spaceId) => {
|
|
452
|
+
const [file, ...args] = await execArgv(spaceId);
|
|
453
|
+
return openCommandStream(file, [...args, ...SPACE_CONNECT_COMMAND]);
|
|
454
|
+
};
|
|
455
|
+
|
|
428
456
|
/**
|
|
429
457
|
* The space stops first and its gatekeeper after it, so a space is never running while its
|
|
430
458
|
* way out is not under the host's control. A stop of the gatekeeper that fails leaves the
|
|
@@ -456,7 +484,7 @@ export function createDockerPlace({ runCommand, dockerPath, owner, toolsSource,
|
|
|
456
484
|
await requireGatekeeperVerified(spaceId, container);
|
|
457
485
|
await docker(['start', name], CHANGE_TIMEOUT_MS);
|
|
458
486
|
}
|
|
459
|
-
await gatekeeper.writeProgram(spaceId);
|
|
487
|
+
await gatekeeper.writeProgram(spaceId, { bindAddress: await innerAddressOf(spaceId) });
|
|
460
488
|
await gatekeeper.waitUntilReady(spaceId);
|
|
461
489
|
};
|
|
462
490
|
|
|
@@ -583,5 +611,5 @@ export function createDockerPlace({ runCommand, dockerPath, owner, toolsSource,
|
|
|
583
611
|
return starting.get(spaceId);
|
|
584
612
|
};
|
|
585
613
|
|
|
586
|
-
return { id: DOCKER_PLACE_ID, check, create, list, exec, execArgv, stop, start, remove, verify };
|
|
614
|
+
return { id: DOCKER_PLACE_ID, check, create, list, exec, execArgv, connect, stop, start, remove, verify };
|
|
587
615
|
}
|
|
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest';
|
|
|
3
3
|
import { SpaceError } from '../errors.js';
|
|
4
4
|
import { GATEKEEPER_PROGRAM } from '../gatekeeper-channel.js';
|
|
5
5
|
import { buildSpaceLabels, buildToolsLabels, hashProjectDirectory } from '../labels.js';
|
|
6
|
-
import { SPACE_ENVIRONMENT, SPACE_SERVER_COMMAND } from '../layout.js';
|
|
6
|
+
import { SPACE_CONNECT_COMMAND, SPACE_ENVIRONMENT, SPACE_SERVER_COMMAND } from '../layout.js';
|
|
7
7
|
import { createRegistryToolsSource, toolsContentKey } from '../tools.js';
|
|
8
8
|
import { SPACE_BASE_IMAGE, createDockerPlace } from './docker.js';
|
|
9
9
|
import { bridgeNetworkEntry, createFakeDocker, hardenedContainerEntry, internalNetworkEntry } from './fake-docker.js';
|
|
@@ -27,7 +27,7 @@ const NOW = new Date('2026-09-20T08:00:00.000Z');
|
|
|
27
27
|
/** A place on a plain runner, for the tests that wrap or replace the fake. */
|
|
28
28
|
const placeOn = (runCommand, options = {}) => createDockerPlace({ runCommand, dockerPath: 'docker', owner: OWNER, toolsSource: SOURCE, now: () => NOW, ...options });
|
|
29
29
|
|
|
30
|
-
const makePlace = (fake, owner = OWNER, toolsSource = SOURCE) => placeOn(fake.runCommand, { dockerPath: '/usr/bin/docker', owner, toolsSource, wait: fake.wait, now: fake.now });
|
|
30
|
+
const makePlace = (fake, owner = OWNER, toolsSource = SOURCE, options = {}) => placeOn(fake.runCommand, { dockerPath: '/usr/bin/docker', owner, toolsSource, wait: fake.wait, now: fake.now, ...options });
|
|
31
31
|
|
|
32
32
|
const labelsFor = (role, { id = ID, owner = OWNER } = {}) => buildSpaceLabels({ ...SPEC, id, role, owner });
|
|
33
33
|
|
|
@@ -272,13 +272,21 @@ describe('docker place: create', () => {
|
|
|
272
272
|
'--env', 'HOME=/tmp',
|
|
273
273
|
SPACE_BASE_IMAGE,
|
|
274
274
|
'/bin/sh', '-c',
|
|
275
|
-
|
|
275
|
+
// The bind host is the file the host writes after the start, holding the gatekeeper's own
|
|
276
|
+
// address on the space's network, never every interface: see "Known limits" of stage 2.
|
|
277
|
+
'while [ ! -s /tmp/openchamber-gatekeeper/gatekeeper.cjs ]; do /bin/sleep 0.2; done; exec /usr/local/bin/node /tmp/openchamber-gatekeeper/gatekeeper.cjs /tmp/openchamber-gatekeeper/bind 3128 8080 9099 300000 128 64 8',
|
|
276
278
|
]);
|
|
277
279
|
expect(create).toContain('openchamber.space.role=gatekeeper');
|
|
278
280
|
expect(create).not.toContain('--mount');
|
|
279
281
|
// The program travels on stdin, so it is in no argument list, and no secret is in one either.
|
|
280
282
|
const write = fake.calls.find((call) => isExec('gatekeeper.cjs.new')(call.args));
|
|
281
283
|
expect(write.options.stdin).toBe(GATEKEEPER_PROGRAM);
|
|
284
|
+
// The address the fake engine gives the gatekeeper on the inner network, read after the start
|
|
285
|
+
// and written into the bind file before the program.
|
|
286
|
+
expect(write.args.slice(-2)).toEqual(['sh', '172.19.0.2']);
|
|
287
|
+
expect(write.args.join(' ')).toContain('> /tmp/openchamber-gatekeeper/bind.new && mv /tmp/openchamber-gatekeeper/bind.new /tmp/openchamber-gatekeeper/bind && cat > /tmp/openchamber-gatekeeper/gatekeeper.cjs.new');
|
|
288
|
+
const order = fake.calls.map((call) => describeCall(call.args));
|
|
289
|
+
expect(order.indexOf(`start ${GATEKEEPER}`)).toBeLessThan(order.indexOf('exec write program'));
|
|
282
290
|
expect(write.args[4]).toBe(GATEKEEPER);
|
|
283
291
|
});
|
|
284
292
|
|
|
@@ -833,6 +841,37 @@ describe('docker place: exec, stop, start, verify', () => {
|
|
|
833
841
|
await expect(makePlace(createFakeDocker({ resources: aside })).execArgv(ID)).rejects.toMatchObject({ code: 'space_move_unfinished' });
|
|
834
842
|
});
|
|
835
843
|
|
|
844
|
+
// Added in stage 4a. `connect` runs the bridge of layout.js over the argv of `execArgv`, through
|
|
845
|
+
// the injected stream opener, so the same checks come first and no process starts in a unit test.
|
|
846
|
+
it('connects through the bridge inside the space container, over the exec argv, and changes nothing', async () => {
|
|
847
|
+
const fake = createFakeDocker({ resources: spaceResources() });
|
|
848
|
+
const opened = [];
|
|
849
|
+
const stream = { destroyed: false };
|
|
850
|
+
const place = makePlace(fake, OWNER, SOURCE, { openCommandStream: (file, args) => { opened.push([file, ...args]); return stream; } });
|
|
851
|
+
|
|
852
|
+
expect(await place.connect(ID)).toBe(stream);
|
|
853
|
+
expect(opened).toEqual([['/usr/bin/docker', 'exec', '--interactive', '--user', '1000:1000', CONTAINER, ...SPACE_CONNECT_COMMAND]]);
|
|
854
|
+
expect(SPACE_CONNECT_COMMAND.slice(0, 2)).toEqual(['/usr/local/bin/node', '-e']);
|
|
855
|
+
expect(SPACE_CONNECT_COMMAND.slice(-2)).toEqual(['127.0.0.1', '27600']);
|
|
856
|
+
expect(SPACE_CONNECT_COMMAND.join(' ')).not.toMatch(/\n/);
|
|
857
|
+
expect(changes(fake)).toEqual([]);
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
it('connects to no stopped space, no stranger\'s container, no space of another installation, none that is missing or mid-move', async () => {
|
|
861
|
+
const opened = [];
|
|
862
|
+
const connectWith = (fake) => makePlace(fake, OWNER, SOURCE, { openCommandStream: (...args) => { opened.push(args); return {}; } }).connect(ID);
|
|
863
|
+
await expect(connectWith(createFakeDocker({ resources: spaceResources({ running: false }) }))).rejects.toMatchObject({ code: 'space_not_running' });
|
|
864
|
+
const stranger = hardenedContainerEntry({ name: CONTAINER, labels: {}, network: NETWORK, volumes: [] });
|
|
865
|
+
await expect(connectWith(createFakeDocker({ resources: [{ kind: 'container', name: CONTAINER, entry: stranger }] }))).rejects.toMatchObject({ code: 'space_not_ours' });
|
|
866
|
+
await expect(connectWith(createFakeDocker({ resources: spaceResources({ owner: 'install-b' }) }))).rejects.toMatchObject({ code: 'space_not_ours' });
|
|
867
|
+
await expect(connectWith(createFakeDocker())).rejects.toMatchObject({ code: 'space_not_found' });
|
|
868
|
+
const aside = spaceResources({ running: false }).map((resource) => (resource.name === CONTAINER
|
|
869
|
+
? { ...resource, name: `${CONTAINER}-old`, entry: { ...resource.entry, Name: `/${CONTAINER}-old` } }
|
|
870
|
+
: resource));
|
|
871
|
+
await expect(connectWith(createFakeDocker({ resources: aside }))).rejects.toMatchObject({ code: 'space_move_unfinished' });
|
|
872
|
+
expect(opened).toEqual([]);
|
|
873
|
+
});
|
|
874
|
+
|
|
836
875
|
it('stops the space, starts the same container again, and waits for its server', async () => {
|
|
837
876
|
const fake = createFakeDocker({ resources: [toolsResource(), ...spaceResources()] });
|
|
838
877
|
const place = makePlace(fake);
|