@vantran-se/openchamber-web 2.0.3 → 2.0.5
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/README.md +20 -28
- package/bin/cli.js +3 -1
- package/bin/cli.test.js +13 -4
- package/bin/lib/DOCUMENTATION.md +4 -1
- package/bin/lib/commands-serve.js +9 -0
- package/dist/assets/{CodeMirrorCommentWidgets-CWhdh0wf.js → CodeMirrorCommentWidgets-DCAQTLMM.js} +1 -1
- package/dist/assets/DiffView-uzNpHBlV.js +4 -0
- package/dist/assets/FilesView-Bkq0sjNx.js +17 -0
- package/dist/assets/{GitView-DKIjpnu4.js → GitView-BmibZC7n.js} +4 -4
- package/dist/assets/{GuestAttachDialog-BIa9jCfL.js → GuestAttachDialog-D7TJG1rt.js} +1 -1
- package/dist/assets/GuestSurfacePane-PAcYaFzP.js +1 -0
- package/dist/assets/{JsonTreeView-CtI1Mqwo.js → JsonTreeView-BNT2yUVu.js} +1 -1
- package/dist/assets/{LinearIssuesView-BD2zLu-r.js → LinearIssuesView-D3Azksju.js} +1 -1
- package/dist/assets/MarkdownImageGallery-BGY6tuiK.js +1 -0
- package/dist/assets/MarkdownRendererImpl-DJmhQ37f.js +12 -0
- package/dist/assets/{OnboardingScreen-B8TxloXZ.js → OnboardingScreen-D_41H2qC.js} +1 -1
- package/dist/assets/{PatchDiff--Civs1xY.js → PatchDiff-C0RSqa9W.js} +1 -1
- package/dist/assets/{PierreDiffViewer-BlDmw3-A.js → PierreDiffViewer-BBE7tTY2.js} +3 -3
- package/dist/assets/{PlanView-DyH2SyCP.js → PlanView-Bqc-xuDe.js} +4 -4
- package/dist/assets/PluginPane-DQ0k41Q-.js +3 -0
- package/dist/assets/SettingsView-BAiJU5hJ.js +30 -0
- package/dist/assets/{SettingsWindow-y9WpZqK6.js → SettingsWindow-DuC4ecLw.js} +1 -1
- package/dist/assets/{SubmoduleDiffSummary-DeFmhPFZ.js → SubmoduleDiffSummary-0HgmcMXz.js} +1 -1
- package/dist/assets/{ToolOutputDialog-8DmF7_PZ.js → ToolOutputDialog-DdHkQJEE.js} +6 -6
- package/dist/assets/{ToolPartDiffPreview-Ca15DKE1.js → ToolPartDiffPreview-DWdN0r5K.js} +2 -2
- package/dist/assets/{VirtualizedFileDiff-BfEBad09.js → VirtualizedFileDiff-ClDezf2d.js} +1 -1
- package/dist/assets/{WalkthroughView-Cj3K8Xz6.js → WalkthroughView-BJicp0RC.js} +2 -2
- package/dist/assets/{appThemeRegistry-DAdawJZn.js → appThemeRegistry-CiuyzfSW.js} +1 -1
- package/dist/assets/changesTree-CePHbFcj.js +1 -0
- package/dist/assets/commitAndPush-DTKb6IwT.js +1 -0
- package/dist/assets/{computeVirtualFileMetrics-RF-KjfUH.js → computeVirtualFileMetrics-kM_zJNMt.js} +2 -2
- package/dist/assets/de-BMnfXm-G.js +10 -0
- package/dist/assets/es-DKEvCCL6.js +16 -0
- package/dist/assets/fr-D9fgE2xE.js +14 -0
- package/dist/assets/index-DUke1nme.css +1 -0
- package/dist/assets/{index-CL7ZQ94-.js → index-DWB8bR_g.js} +1 -1
- package/dist/assets/index-Di6zUe5h.js +41 -0
- package/dist/assets/{isDiffPlainText-D2yJ9fO6.js → isDiffPlainText-DOoIsGqs.js} +1 -1
- package/dist/assets/{isFilePlainText-BwMEq1Ey.js → isFilePlainText-bt5VHvcd.js} +1 -1
- package/dist/assets/ja-BEXad-KW.js +16 -0
- package/dist/assets/ko-DGrQ0lEe.js +16 -0
- package/dist/assets/{main-quLSA7jc.js → main-BpLQbZC2.js} +2 -2
- package/dist/assets/main-CI9DtHJv.js +896 -0
- package/dist/assets/{markdown-shiki.worker-CahLvHIq.js → markdown-shiki.worker-Bg8SoAJJ.js} +8 -5
- package/dist/assets/markdownCore-DK2KK5RW.js +7 -0
- package/dist/assets/{miniChat-D-lJJibr.js → miniChat-CIWaR8wG.js} +2 -2
- package/dist/assets/{mobile-VTtphbW6.js → mobile-CnWKEYIP.js} +2 -2
- package/dist/assets/patchFileDiff-Cqn0C0az.js +13 -0
- package/dist/assets/pl-BlhkmR5b.js +16 -0
- package/dist/assets/pt-BR-Bs96Ew9I.js +16 -0
- package/dist/assets/{renderElectronMiniChatApp-DF7ch5eM.js → renderElectronMiniChatApp-D9t0-9nV.js} +2 -2
- package/dist/assets/renderMobileApp-CW7fVs_s.js +25 -0
- package/dist/assets/runtimeEndpointReset-D0PcCb3K.js +27 -0
- package/dist/assets/scheduledTasksApi-Dahv3ws0.js +1 -0
- package/dist/assets/{shikiHighlight-_HeCCHx1.js → shikiHighlight-l-MbaNS4.js} +1 -1
- package/dist/assets/{textMateThemeFromAppTheme-C95XdFGj.js → textMateThemeFromAppTheme-C-kGcOpD.js} +1 -1
- package/dist/assets/tr-CehUK1M9.js +7 -0
- package/dist/assets/uk-Bno5WGNz.js +16 -0
- package/dist/assets/{useAppFontEffects-ZM9ij19-.js → useAppFontEffects-DDjuwVqi.js} +160 -156
- package/dist/assets/{useInlineCommentController-CkfxMJPy.js → useInlineCommentController-Clg8EO3A.js} +2 -2
- package/dist/assets/{useRootScrollLock-BtiAC1fj.js → useRootScrollLock-nDcRrgJ1.js} +2 -2
- package/dist/assets/{vendor-codemirror-language-data-U-lXzXNK.js → vendor-codemirror-language-data-oKF0MAwd.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-C583Pmfm.js → web-components-Bc1WPd4i.js} +1 -1
- package/dist/assets/zh-CN-DnAweIWo.js +16 -0
- package/dist/assets/zh-TW-BbBBK25p.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 +3 -3
- package/server/index.d.ts +2 -0
- package/server/index.js +196 -52
- package/server/lib/agent-tool/DOCUMENTATION.md +5 -0
- package/server/lib/agent-tool/runtime.js +89 -51
- package/server/lib/agent-tool/runtime.test.js +17 -0
- package/server/lib/browser-control/DOCUMENTATION.md +14 -2
- package/server/lib/event-stream/DOCUMENTATION.md +2 -2
- package/server/lib/event-stream/global-hub.js +45 -7
- package/server/lib/event-stream/global-hub.test.js +128 -2
- package/server/lib/event-stream/global-ws-bridge.js +3 -1
- package/server/lib/event-stream/rebind.test.js +1 -0
- package/server/lib/event-stream/runtime.js +1 -2
- 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/DOCUMENTATION.md +23 -23
- 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 +156 -2
- package/server/lib/opencode/lifecycle.test.js +255 -1
- 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/network-runtime.js +16 -7
- package/server/lib/opencode/network-runtime.test.js +29 -1
- 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 +53 -16
- package/server/lib/opencode/routes-upgrade.test.js +1 -1
- package/server/lib/opencode/routes.js +36 -1
- package/server/lib/opencode/server-startup-runtime.js +25 -0
- package/server/lib/opencode/server-startup-runtime.test.js +67 -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-service-runtime.js +155 -0
- package/server/lib/opencode/shared-service-runtime.test.js +180 -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 +14 -5
- package/server/lib/opencode/shutdown-runtime.test.js +77 -2
- package/server/lib/opencode/skill-routes.js +28 -12
- package/server/lib/opencode/skill-routes.test.js +121 -1
- package/server/lib/opencode/startup-pipeline-runtime.js +5 -4
- package/server/lib/opencode/startup-pipeline-runtime.test.js +18 -7
- 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/watcher.test.js +54 -0
- 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 +271 -0
- package/server/proxy-headers.js +8 -2
- package/server/proxy-headers.test.js +20 -0
- package/dist/assets/DiffView-CX3wAX2S.js +0 -4
- package/dist/assets/FilesView-OL3CON3r.js +0 -17
- package/dist/assets/GuestSurfacePane-B_cqX5I5.js +0 -1
- package/dist/assets/MarkdownImageGallery-DFiTFZgj.js +0 -1
- package/dist/assets/MarkdownRendererImpl-DZm5sf1W.js +0 -12
- package/dist/assets/PluginPane-BibJit86.js +0 -3
- package/dist/assets/SettingsView-h-VpVO47.js +0 -29
- package/dist/assets/commitAndPush-BIPfOMXY.js +0 -1
- package/dist/assets/de-BqCNGROu.js +0 -10
- package/dist/assets/es-BTPpTRgN.js +0 -16
- package/dist/assets/fr-hxbzMdAg.js +0 -14
- package/dist/assets/index-CLcM8HKx.css +0 -1
- package/dist/assets/index-CvLgnuNI.js +0 -41
- package/dist/assets/ja-_zLwwYiy.js +0 -16
- package/dist/assets/ko-CFn3Dzhc.js +0 -16
- package/dist/assets/main-BLJaug9I.js +0 -896
- package/dist/assets/markdownCore-DS9FdwrI.js +0 -5
- package/dist/assets/patchFileDiff-D9iBcTEi.js +0 -13
- package/dist/assets/pl-DFHE7xac.js +0 -16
- package/dist/assets/pt-BR-CUjZF179.js +0 -16
- package/dist/assets/renderMobileApp-CNQhdXsi.js +0 -25
- package/dist/assets/runtimeEndpointReset-DeaERc0M.js +0 -27
- package/dist/assets/scheduledTasksApi-BD6Vx9pu.js +0 -1
- package/dist/assets/tr-BB6sA_zH.js +0 -7
- package/dist/assets/uk-CB4o8g-o.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-BRyDuWhc.js +0 -16
- package/dist/assets/zh-TW-BDGbWA1Q.js +0 -16
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
// Test support, never imported by product code. The bait repository
|
|
2
|
-
//
|
|
1
|
+
// Test support, never imported by product code. The bait repository, the host state that code in
|
|
2
|
+
// and code out must leave byte-identical, and a stand-in for a space in a local folder, shared by
|
|
3
|
+
// code-in.test.js, code-out.test.js and the live files under places/.
|
|
3
4
|
|
|
4
5
|
import { spawnSync } from 'node:child_process';
|
|
5
6
|
import crypto from 'node:crypto';
|
|
@@ -7,8 +8,13 @@ import fs from 'node:fs';
|
|
|
7
8
|
import os from 'node:os';
|
|
8
9
|
import path from 'node:path';
|
|
9
10
|
|
|
11
|
+
import { expect } from 'vitest';
|
|
12
|
+
|
|
10
13
|
import { createCodeIn } from './code-in.js';
|
|
14
|
+
import { createCodeOut } from './code-out.js';
|
|
15
|
+
import { SpaceError } from './errors.js';
|
|
11
16
|
import { createHostGit, hostGitEnvironment } from './host-git.js';
|
|
17
|
+
import { IMAGE_ONLY_PATH, IMAGE_SH, IMAGE_TIMEOUT } from './layout.js';
|
|
12
18
|
import { runCommand } from './run-command.js';
|
|
13
19
|
|
|
14
20
|
const WIN = process.platform === 'win32';
|
|
@@ -64,7 +70,11 @@ export function createTestHost({ config = '', identity = true, ownHome = true }
|
|
|
64
70
|
};
|
|
65
71
|
// Config that would break the setup itself goes in afterwards.
|
|
66
72
|
const addConfig = (text) => fs.appendFileSync(globalConfig, `\n${text}\n`);
|
|
67
|
-
return {
|
|
73
|
+
return {
|
|
74
|
+
root, environment, git, sh, addConfig,
|
|
75
|
+
codeIn: (place = null, options = {}) => createCodeIn({ git, place, temporaryDirectory: root, ...options }),
|
|
76
|
+
codeOut: (place = null, options = {}) => createCodeOut({ git, place, temporaryDirectory: root, ...options }),
|
|
77
|
+
};
|
|
68
78
|
}
|
|
69
79
|
|
|
70
80
|
/** The bait repository: every kind of change and every kind of file that must or must not travel. */
|
|
@@ -162,7 +172,16 @@ export const hostState = (directory) => {
|
|
|
162
172
|
// a nested repository's `.git`, is a difference like any other;
|
|
163
173
|
// 2. the start ref of the spaces named, `.git/refs/openchamber/spaces/<id>/start`, with the three
|
|
164
174
|
// folders git makes for it, added or removed. A start ref that moved to another commit is a difference.
|
|
175
|
+
// With `codeOut`, also what code out writes by design:
|
|
176
|
+
// 3. new packs, which a fetch writes when it brings many objects, as additions only;
|
|
177
|
+
// 4. the result ref of the spaces named, `.git/refs/openchamber/spaces/<id>/result`, the ref of what
|
|
178
|
+
// the last apply as uncommitted changes wrote, `.../applied`, the intent written while an apply
|
|
179
|
+
// runs, `.../applying`, what the patch of each was built from, `.../applied-from` and
|
|
180
|
+
// `.../applying-from`, the user's HEAD at each, `.../applied-head` and `.../applying-head`, and the
|
|
181
|
+
// one that says this space is applied as a branch from now on,
|
|
182
|
+
// `.../changes-closed`, added, removed or moved, because a later call moves each of them.
|
|
165
183
|
const LOOSE_OBJECT = /^\.git\/objects\/[0-9a-f]{2}(?:\/(?:[0-9a-f]{38}|[0-9a-f]{62}))?$/;
|
|
184
|
+
const NEW_PACK = /^\.git\/objects\/pack\/pack-(?:[0-9a-f]{40}|[0-9a-f]{64})\.(?:pack|idx|rev)$/;
|
|
166
185
|
const spaceRefPaths = (spaceId) => [
|
|
167
186
|
'.git/refs/openchamber',
|
|
168
187
|
'.git/refs/openchamber/spaces',
|
|
@@ -170,16 +189,136 @@ const spaceRefPaths = (spaceId) => [
|
|
|
170
189
|
`.git/refs/openchamber/spaces/${spaceId}/start`,
|
|
171
190
|
];
|
|
172
191
|
|
|
173
|
-
/**
|
|
174
|
-
|
|
192
|
+
/**
|
|
193
|
+
* The paths that differ between two `hostState`s, apart from what code in writes by design for
|
|
194
|
+
* `spaceIds`, and with `codeOut: true` also what code out writes by design for them.
|
|
195
|
+
*/
|
|
196
|
+
export const unexpectedChanges = (before, after, { spaceIds = [], codeOut = false } = {}) => {
|
|
175
197
|
const ownRefs = new Set(spaceIds.flatMap(spaceRefPaths));
|
|
198
|
+
const resultRefs = new Set(codeOut
|
|
199
|
+
? spaceIds.flatMap((spaceId) => ['result', 'applied', 'applied-from', 'applied-head', 'applying', 'applying-from', 'applying-head', 'changes-closed'].map((name) => `.git/refs/openchamber/spaces/${spaceId}/${name}`))
|
|
200
|
+
: []);
|
|
176
201
|
const names = new Set([...Object.keys(before), ...Object.keys(after)]);
|
|
177
202
|
return [...names].filter((name) => {
|
|
178
203
|
if (before[name] === after[name]) return false;
|
|
204
|
+
if (resultRefs.has(name)) return false;
|
|
179
205
|
const addedOrRemoved = before[name] === undefined || after[name] === undefined;
|
|
180
206
|
if (ownRefs.has(name) && addedOrRemoved) return false;
|
|
181
|
-
|
|
207
|
+
const added = before[name] === undefined;
|
|
208
|
+
if (added && codeOut && NEW_PACK.test(name)) return false;
|
|
209
|
+
return !(added && LOOSE_OBJECT.test(name));
|
|
182
210
|
}).sort();
|
|
183
211
|
};
|
|
184
212
|
|
|
185
213
|
export const shortStatus = (g) => g(['--no-optional-locks', 'status', '--porcelain=v1', '--untracked-files=all']);
|
|
214
|
+
|
|
215
|
+
// The other end of a push or fetch, standing in for `docker exec ... git receive-pack` or
|
|
216
|
+
// `git upload-pack` in a real space. It checks that the host wrote out the fixed command, and maps
|
|
217
|
+
// /spaces/ into a local folder. `slow` sends what upload-pack sends at about 3 MB/s, and every run
|
|
218
|
+
// records how its git ended in `<service>.exit`, which a killed run never writes.
|
|
219
|
+
const RECEIVER = `
|
|
220
|
+
const { spawn } = require('node:child_process');
|
|
221
|
+
const fs = require('node:fs');
|
|
222
|
+
const path = require('node:path');
|
|
223
|
+
const [root, behaviour, services, ...command] = process.argv.slice(2);
|
|
224
|
+
const [timeoutPath, dashS, signal, seconds, gitPath, service, target] = command;
|
|
225
|
+
if (command.length !== 7 || timeoutPath !== '/usr/bin/timeout' || dashS !== '-s' || signal !== 'KILL' || !/^[0-9]+$/.test(seconds)
|
|
226
|
+
|| gitPath !== '/usr/bin/git' || !services.split(',').includes(service) || !target.startsWith('/spaces/')) {
|
|
227
|
+
process.stderr.write('unexpected command ' + JSON.stringify(command) + '\\n');
|
|
228
|
+
process.exit(2);
|
|
229
|
+
}
|
|
230
|
+
fs.writeFileSync(path.join(root, 'last-command.json'), JSON.stringify(command));
|
|
231
|
+
fs.appendFileSync(path.join(root, 'commands.log'), target + '\\n');
|
|
232
|
+
if (behaviour === 'refuse') { process.stderr.write('refused by the space\\n'); process.exit(1); }
|
|
233
|
+
if (behaviour === 'flood') {
|
|
234
|
+
const chunk = 'x'.repeat(65536);
|
|
235
|
+
const pump = () => { while (process.stderr.write(chunk)) {} process.stderr.once('drain', pump); };
|
|
236
|
+
pump();
|
|
237
|
+
} else if (behaviour === 'hang') {
|
|
238
|
+
const grandchild = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' });
|
|
239
|
+
fs.writeFileSync(path.join(root, 'hang.pid'), String(grandchild.pid));
|
|
240
|
+
setInterval(() => {}, 1000);
|
|
241
|
+
} else {
|
|
242
|
+
const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.startsWith('GIT_')));
|
|
243
|
+
const slow = behaviour === 'slow';
|
|
244
|
+
const child = spawn('git', [service, path.join(root, target)], {
|
|
245
|
+
stdio: ['inherit', slow ? 'pipe' : 'inherit', 'inherit'],
|
|
246
|
+
env: { ...env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: ${JSON.stringify(os.devNull)} },
|
|
247
|
+
});
|
|
248
|
+
if (slow) {
|
|
249
|
+
child.stdout.on('data', (chunk) => {
|
|
250
|
+
child.stdout.pause();
|
|
251
|
+
process.stdout.write(chunk);
|
|
252
|
+
setTimeout(() => child.stdout.resume(), Math.ceil(chunk.length / 3000));
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
// The receiver ends by itself once git has closed and everything is written.
|
|
256
|
+
child.on('close', (code) => {
|
|
257
|
+
fs.writeFileSync(path.join(root, service + '.exit'), String(code));
|
|
258
|
+
process.exitCode = code ?? 1;
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
`;
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* A stand-in place for one space in a local folder. `exec` runs the module's fixed scripts with the
|
|
265
|
+
* host's `sh` and `git`, with /spaces/ mapped into the folder and a `timeout` that only drops its
|
|
266
|
+
* limit, since the host may have none; a script may come under the image's `timeout` itself.
|
|
267
|
+
* `execArgv` points at RECEIVER, kept in a folder whose name has a space and a percent sign, so the
|
|
268
|
+
* ext escaping is exercised through git itself. `setBehaviour` changes what the next transfer meets.
|
|
269
|
+
* POSIX only: the scripts run with `/bin/sh`.
|
|
270
|
+
*
|
|
271
|
+
* Without `codeOut` it stands in for code in alone, and is as strict as code in: only `receive-pack`,
|
|
272
|
+
* and no script wrapped in the image's `timeout`. With it, `upload-pack` and a wrapped script are
|
|
273
|
+
* allowed too, and the wrapper's seconds are checked.
|
|
274
|
+
*/
|
|
275
|
+
export function createLocalPlace(host, behaviour = 'receive', spaceId = 'a1b2c3d4e5f6', { codeOut = false } = {}) {
|
|
276
|
+
const root = path.join(host.root, 'stand in 50% place');
|
|
277
|
+
const bin = path.join(root, 'bin');
|
|
278
|
+
fs.mkdirSync(path.join(root, 'spaces', spaceId), { recursive: true });
|
|
279
|
+
fs.mkdirSync(bin);
|
|
280
|
+
const receiver = path.join(root, 'receiver.cjs');
|
|
281
|
+
fs.writeFileSync(receiver, RECEIVER);
|
|
282
|
+
fs.writeFileSync(path.join(bin, 'timeout'), '#!/bin/sh\nshift 3\nexec "$@"\n', { mode: 0o755 });
|
|
283
|
+
const emptyConfig = path.join(root, 'inside-gitconfig');
|
|
284
|
+
fs.writeFileSync(emptyConfig, '');
|
|
285
|
+
const insideEnvironment = { PATH: `${bin}${path.delimiter}${process.env.PATH}`, HOME: root, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: emptyConfig };
|
|
286
|
+
const local = (inside) => path.join(root, inside);
|
|
287
|
+
let current = behaviour;
|
|
288
|
+
return {
|
|
289
|
+
root,
|
|
290
|
+
local,
|
|
291
|
+
setBehaviour: (next) => { current = next; },
|
|
292
|
+
inside: (args, { env = {}, input } = {}) => {
|
|
293
|
+
const result = spawnSync('git', args, { env: { ...insideEnvironment, ...env }, input, encoding: 'utf8' });
|
|
294
|
+
return { code: result.status, stdout: result.stdout, stderr: result.stderr };
|
|
295
|
+
},
|
|
296
|
+
lastCommand: () => JSON.parse(fs.readFileSync(path.join(root, 'last-command.json'), 'utf8')),
|
|
297
|
+
/** How many pushes reached the history side repository. */
|
|
298
|
+
historyPushes: () => (fs.existsSync(path.join(root, 'commands.log')) ? fs.readFileSync(path.join(root, 'commands.log'), 'utf8').split('\n').filter((line) => line.endsWith('.openchamber-history.git')).length : 0),
|
|
299
|
+
hangPid: () => Number(fs.readFileSync(path.join(root, 'hang.pid'), 'utf8')),
|
|
300
|
+
/** How the last upload-pack ended, or null when it was killed before it could say. */
|
|
301
|
+
uploadPackExit: () => (fs.existsSync(path.join(root, 'upload-pack.exit')) ? fs.readFileSync(path.join(root, 'upload-pack.exit'), 'utf8') : null),
|
|
302
|
+
place: {
|
|
303
|
+
execArgv: async (id) => {
|
|
304
|
+
if (id !== spaceId) throw new SpaceError('space_not_found', 'no such space');
|
|
305
|
+
return [process.execPath, receiver, path.join(root), current, codeOut ? 'receive-pack,upload-pack' : 'receive-pack'];
|
|
306
|
+
},
|
|
307
|
+
exec: async (id, argv, { timeoutMs }) => {
|
|
308
|
+
const wrapped = argv[0] === IMAGE_TIMEOUT;
|
|
309
|
+
const unlimited = wrapped ? argv.slice(4) : argv;
|
|
310
|
+
if (wrapped) {
|
|
311
|
+
expect(codeOut, 'code in runs no script under the image timeout').toBe(true);
|
|
312
|
+
expect(argv.slice(1, 3)).toEqual(['-s', 'KILL']);
|
|
313
|
+
expect(Number(argv[3]), 'the seconds of the inner limit').toBeGreaterThan(0);
|
|
314
|
+
}
|
|
315
|
+
const [shell, flag, script, zero, ...args] = unlimited;
|
|
316
|
+
expect([shell, flag, zero]).toEqual([IMAGE_SH, '-c', 'sh']);
|
|
317
|
+
expect(script.startsWith(IMAGE_ONLY_PATH)).toBe(true);
|
|
318
|
+
// Quoted: the stand-in's folder name has a space in it, which the image's own PATH never has.
|
|
319
|
+
const localScript = `PATH='${insideEnvironment.PATH}';${script.slice(IMAGE_ONLY_PATH.length)}`;
|
|
320
|
+
return runCommand('/bin/sh', ['-c', localScript, zero, ...args.map((arg) => (arg.startsWith('/spaces/') ? local(arg) : arg))], { env: insideEnvironment, timeoutMs });
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
};
|
|
324
|
+
}
|
|
@@ -10,9 +10,12 @@ import fs from 'node:fs/promises';
|
|
|
10
10
|
import os from 'node:os';
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
|
|
13
|
+
import {
|
|
14
|
+
EXT_UNCARRIABLE, INNER_MARGIN_SECONDS, SNAPSHOT_IDENTITY, buildExtUrl, createTransferSession, innerSeconds, line,
|
|
15
|
+
objectIdPattern, requireInnerMargin, requireTimeout, spaceRefPrefix, startRef, zeroObjectId,
|
|
16
|
+
} from './code-transfer.js';
|
|
13
17
|
import { SpaceError } from './errors.js';
|
|
14
18
|
import { tail } from './exec-http.js';
|
|
15
|
-
import { isGitVersionAtLeast, parseGitVersion } from './host-git.js';
|
|
16
19
|
import { requireSpaceId } from './labels.js';
|
|
17
20
|
import { IMAGE_GIT, IMAGE_ONLY_PATH, IMAGE_SH, IMAGE_TIMEOUT, requireSpaceProjectPath, spaceHistoryPath, spaceProjectPath } from './layout.js';
|
|
18
21
|
|
|
@@ -23,28 +26,9 @@ const HISTORY_TIMEOUT_MS = 30 * 60_000;
|
|
|
23
26
|
// A push prints nothing with `--quiet`. What it does print comes from the receiving side, which
|
|
24
27
|
// is the space. More than this, and the transfer is killed like a hanging one.
|
|
25
28
|
const PUSH_MAX_OUTPUT_BYTES = 1024 * 1024;
|
|
26
|
-
// The limit inside is the host's limit plus this margin, so it never ends a push the host still
|
|
27
|
-
// waits for, and a push the host killed leaves nothing running inside once it passes.
|
|
28
|
-
const INNER_MARGIN_SECONDS = 5;
|
|
29
|
-
const MAX_INNER_MARGIN_SECONDS = 3600;
|
|
30
29
|
const INSIDE_TIMEOUT_MS = 5 * 60_000;
|
|
31
|
-
// The oldest host git code in works with. `git init --object-format` first appears in the git-init
|
|
32
|
-
// manual of 2.27.0; `rev-parse --show-object-format` and `add --pathspec-from-file` in 2.25.0; every
|
|
33
|
-
// other option used here is older. See DOCUMENTATION.md, "Host git".
|
|
34
|
-
const GIT_VERSION_FLOOR = Object.freeze({ major: 2, minor: 27, patch: 0 });
|
|
35
|
-
const GIT_VERSION_FLOOR_TEXT = '2.27';
|
|
36
|
-
|
|
37
|
-
// The snapshot commits carry a fixed identity, so a user without `user.name` can still create a space.
|
|
38
|
-
const SNAPSHOT_IDENTITY = Object.freeze({
|
|
39
|
-
GIT_AUTHOR_NAME: 'OpenChamber',
|
|
40
|
-
GIT_AUTHOR_EMAIL: 'spaces@openchamber.invalid',
|
|
41
|
-
GIT_COMMITTER_NAME: 'OpenChamber',
|
|
42
|
-
GIT_COMMITTER_EMAIL: 'spaces@openchamber.invalid',
|
|
43
|
-
});
|
|
44
30
|
|
|
45
31
|
const MODES = new Set(['uncommitted', 'clean']);
|
|
46
|
-
// A git `ext::` URL splits on spaces itself and cannot carry these at all.
|
|
47
|
-
const EXT_UNCARRIABLE = /[\x00-\x1f\x7f]/;
|
|
48
32
|
// A value that is copied into the space's git config: one line, and short.
|
|
49
33
|
const COPYABLE_VALUE = /^[^\x00-\x1f\x7f]{1,256}$/;
|
|
50
34
|
|
|
@@ -53,9 +37,6 @@ const INSIDE_BASE = 'refs/openchamber/base';
|
|
|
53
37
|
const INSIDE_START_INDEX = 'refs/openchamber/start-index';
|
|
54
38
|
const INSIDE_START = 'refs/openchamber/start';
|
|
55
39
|
|
|
56
|
-
const spaceRefPrefix = (spaceId) => `refs/openchamber/spaces/${requireSpaceId(spaceId)}/`;
|
|
57
|
-
const startRef = (spaceId) => `${spaceRefPrefix(spaceId)}start`;
|
|
58
|
-
|
|
59
40
|
// The fixed scripts that run inside the space. Every value is a positional argument.
|
|
60
41
|
// Exit code 3 of the init script means the project path is already there.
|
|
61
42
|
const PATH_IN_USE_EXIT_CODE = 3;
|
|
@@ -91,52 +72,6 @@ const UNSHALLOW_SCRIPT = [
|
|
|
91
72
|
].join(' ');
|
|
92
73
|
const REMOVE_SIDE_SCRIPT = `${IMAGE_ONLY_PATH} rm -rf "$1"`;
|
|
93
74
|
|
|
94
|
-
// One line of git output, without its line end.
|
|
95
|
-
const line = (text) => text.replace(/\r?\n$/, '');
|
|
96
|
-
const zeroObjectId = (objectFormat) => '0'.repeat(objectFormat === 'sha256' ? 64 : 40);
|
|
97
|
-
const objectIdPattern = (objectFormat) => (objectFormat === 'sha256' ? /^[0-9a-f]{64}$/ : /^[0-9a-f]{40}$/);
|
|
98
|
-
const innerSeconds = (timeoutMs, marginSeconds) => Math.ceil(timeoutMs / 1000) + marginSeconds;
|
|
99
|
-
|
|
100
|
-
const MIN_TIMEOUT_MS = 1_000;
|
|
101
|
-
const MAX_TIMEOUT_MS = 24 * 60 * 60_000;
|
|
102
|
-
|
|
103
|
-
/** The host's limit on a push, in whole milliseconds from a second to a day. */
|
|
104
|
-
const requireTimeout = (value) => {
|
|
105
|
-
if (!Number.isInteger(value) || value < MIN_TIMEOUT_MS || value > MAX_TIMEOUT_MS) {
|
|
106
|
-
throw new SpaceError('invalid_timeout', `The time limit of a transfer is a whole number of milliseconds from ${MIN_TIMEOUT_MS} to ${MAX_TIMEOUT_MS}`);
|
|
107
|
-
}
|
|
108
|
-
return value;
|
|
109
|
-
};
|
|
110
|
-
|
|
111
|
-
/** The margin between the host's limit and the one inside, in whole seconds: a length, and nothing else. */
|
|
112
|
-
const requireInnerMargin = (value) => {
|
|
113
|
-
if (!Number.isInteger(value) || value < 1 || value > MAX_INNER_MARGIN_SECONDS) {
|
|
114
|
-
throw new SpaceError('invalid_inner_margin', `The margin of the limit inside the space is a whole number of seconds from 1 to ${MAX_INNER_MARGIN_SECONDS}`);
|
|
115
|
-
}
|
|
116
|
-
return value;
|
|
117
|
-
};
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* A git `ext::` URL for an argv. git-remote-ext splits the text after `ext::` on spaces itself, so a
|
|
121
|
-
* space inside an argument is written `% ` and a percent `%%`, as its documentation says. The docker
|
|
122
|
-
* CLI on Windows can live under a path with spaces. An empty argument or one with a control
|
|
123
|
-
* character cannot be carried, and is refused. The URL goes to git as one argv element, never
|
|
124
|
-
* through a shell.
|
|
125
|
-
*/
|
|
126
|
-
export function buildExtUrl(argv) {
|
|
127
|
-
if (!Array.isArray(argv) || argv.length === 0) {
|
|
128
|
-
throw new SpaceError('invalid_ext_argument', 'A command for git is a non-empty list of arguments');
|
|
129
|
-
}
|
|
130
|
-
const escaped = argv.map((argument) => {
|
|
131
|
-
const text = String(argument);
|
|
132
|
-
if (text === '' || EXT_UNCARRIABLE.test(text)) {
|
|
133
|
-
throw new SpaceError('invalid_ext_argument', 'A command for git cannot carry an empty argument or a control character');
|
|
134
|
-
}
|
|
135
|
-
return text.replaceAll('%', '%%').replaceAll(' ', '% ');
|
|
136
|
-
});
|
|
137
|
-
return `ext::${escaped.join(' ')}`;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
75
|
/** The receiving side of a push into the space: fixed programs by absolute path, under the space's own time limit. */
|
|
141
76
|
const receivePackUrl = (execArgv, seconds, target) => buildExtUrl([
|
|
142
77
|
...execArgv, IMAGE_TIMEOUT, '-s', 'KILL', String(seconds), IMAGE_GIT, 'receive-pack', target,
|
|
@@ -181,86 +116,8 @@ const asHistoryFailure = (error, step) => {
|
|
|
181
116
|
return new SpaceError('history_transfer_failed', ours ? error.message : `The history failed on the host: ${error.message}`, details);
|
|
182
117
|
};
|
|
183
118
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
export function createCodeIn({ git, place, temporaryDirectory = os.tmpdir(), removeDirectory = removeWithRetries }) {
|
|
187
|
-
/**
|
|
188
|
-
* Runs `work(g, directory)` with a temporary directory and a host git `g` that starts nothing of
|
|
189
|
-
* the user's. `core.hooksPath` points at an empty folder of ours: without it the user's
|
|
190
|
-
* `post-index-change` hook ran three times per snapshot, with `GIT_INDEX_FILE` pointing at our
|
|
191
|
-
* copy, and their `reference-transaction` hook ran on every write of our ref. `core.fsmonitor` is
|
|
192
|
-
* empty: a user's fsmonitor program ran ten times per snapshot, and `core.fsmonitor=true` left a
|
|
193
|
-
* `git fsmonitor--daemon` running on the host with its files in the user's `.git`. Empty, and not
|
|
194
|
-
* `false`, because before git 2.36 the value is a command path, where `false` names a program.
|
|
195
|
-
*
|
|
196
|
-
* A failure that is not a SpaceError becomes one with `failureCode`. Removing the folder is retried,
|
|
197
|
-
* and it never replaces the error of the work: when it still fails, the error says which folder was
|
|
198
|
-
* left in `details.temporaryDirectoryLeft`. After a successful call a leftover folder is let go.
|
|
199
|
-
*/
|
|
200
|
-
const withHostGit = async (failureCode, work) => {
|
|
201
|
-
let directory;
|
|
202
|
-
try {
|
|
203
|
-
directory = await fs.mkdtemp(path.join(temporaryDirectory, 'openchamber-code-in-'));
|
|
204
|
-
} catch (error) {
|
|
205
|
-
throw new SpaceError(failureCode, `Could not make a temporary folder for code in: ${error.message}`, { step: 'make a temporary folder', cause: error.code ?? null });
|
|
206
|
-
}
|
|
207
|
-
let outcome;
|
|
208
|
-
let failed = false;
|
|
209
|
-
try {
|
|
210
|
-
const hooks = path.join(directory, 'no-hooks');
|
|
211
|
-
await fs.mkdir(hooks);
|
|
212
|
-
const isolated = ['-c', `core.hooksPath=${hooks.replaceAll('\\', '/')}`, '-c', 'core.fsmonitor='];
|
|
213
|
-
const g = {
|
|
214
|
-
run: (where, args, options) => git.run(where, [...isolated, ...args], options),
|
|
215
|
-
output: (where, args, options) => git.output(where, [...isolated, ...args], options),
|
|
216
|
-
};
|
|
217
|
-
outcome = await work(g, directory);
|
|
218
|
-
} catch (error) {
|
|
219
|
-
failed = true;
|
|
220
|
-
outcome = error instanceof SpaceError
|
|
221
|
-
? error
|
|
222
|
-
: new SpaceError(failureCode, `Code in failed on the host: ${error.message}`, { step: 'host side', cause: error.code ?? null });
|
|
223
|
-
}
|
|
224
|
-
const left = await removeDirectory(directory).then(() => false, () => true);
|
|
225
|
-
if (failed) {
|
|
226
|
-
if (left) outcome.details = { ...outcome.details, temporaryDirectoryLeft: directory };
|
|
227
|
-
throw outcome;
|
|
228
|
-
}
|
|
229
|
-
return outcome;
|
|
230
|
-
};
|
|
231
|
-
|
|
232
|
-
/** Refuses a host git older than the floor, with the version it needs. */
|
|
233
|
-
const requireGitVersion = async (g, directory) => {
|
|
234
|
-
const unreadable = (cause) => new SpaceError('git_version_unreadable', 'Could not tell which git this computer has. Code in needs git to be installed and working.', { cause });
|
|
235
|
-
let result;
|
|
236
|
-
try {
|
|
237
|
-
result = await g.run(directory, ['version']);
|
|
238
|
-
} catch (error) {
|
|
239
|
-
// A git that cannot be started at all, missing among them.
|
|
240
|
-
throw unreadable(error.code ?? null);
|
|
241
|
-
}
|
|
242
|
-
const version = result.code === 0 ? parseGitVersion(result.stdout) : null;
|
|
243
|
-
if (!version) {
|
|
244
|
-
throw unreadable(null);
|
|
245
|
-
}
|
|
246
|
-
if (!isGitVersionAtLeast(version, GIT_VERSION_FLOOR)) {
|
|
247
|
-
throw new SpaceError('git_too_old', `Code in needs git ${GIT_VERSION_FLOOR_TEXT} or newer, and this computer has git ${version.major}.${version.minor}.${version.patch}. Update git, then create the space.`, { version });
|
|
248
|
-
}
|
|
249
|
-
};
|
|
250
|
-
|
|
251
|
-
/** The top level of the work tree that `repository` is in, as a native path, or a refusal. */
|
|
252
|
-
const requireWorkTree = async (g, repository) => {
|
|
253
|
-
const found = await fs.stat(repository).then((stats) => stats.isDirectory(), () => false);
|
|
254
|
-
if (!found) {
|
|
255
|
-
throw new SpaceError('project_folder_missing', `${repository} does not exist, or is not a folder.`);
|
|
256
|
-
}
|
|
257
|
-
const inside = await g.run(repository, ['rev-parse', '--is-inside-work-tree']);
|
|
258
|
-
if (inside.code !== 0 || line(inside.stdout) !== 'true') {
|
|
259
|
-
throw new SpaceError('not_a_git_work_tree', `${repository} is not inside a git working tree. Code can travel into a space only from a git repository.`);
|
|
260
|
-
}
|
|
261
|
-
// git answers with forward slashes on Windows too.
|
|
262
|
-
return path.resolve(line(await g.output(repository, ['rev-parse', '--show-toplevel'])));
|
|
263
|
-
};
|
|
119
|
+
export function createCodeIn({ git, place, temporaryDirectory = os.tmpdir(), removeDirectory }) {
|
|
120
|
+
const { withHostGit, requireGitVersion, requireWorkTree } = createTransferSession({ git, temporaryDirectory, removeDirectory, name: 'code in' });
|
|
264
121
|
|
|
265
122
|
/** What a transfer needs to know about the repository, after the refusals that come first. */
|
|
266
123
|
const inspectRepository = async (g, repository) => {
|
|
@@ -425,7 +282,10 @@ export function createCodeIn({ git, place, temporaryDirectory = os.tmpdir(), rem
|
|
|
425
282
|
*/
|
|
426
283
|
const takeSnapshot = (request) => withHostGit('code_in_failed', (g, directory) => snapshot(g, directory, request ?? {}));
|
|
427
284
|
|
|
428
|
-
/**
|
|
285
|
+
/**
|
|
286
|
+
* Deletes every host ref of this space under `refs/openchamber/spaces/<id>/`, in one transaction: the
|
|
287
|
+
* start of code in and the result of code out. Resolves the deleted names.
|
|
288
|
+
*/
|
|
429
289
|
const removeSpaceRefs = (request) => withHostGit('code_in_failed', async (g) => {
|
|
430
290
|
const { repository, spaceId } = request ?? {};
|
|
431
291
|
requireSpaceId(spaceId);
|
|
@@ -9,11 +9,11 @@ import path from 'node:path';
|
|
|
9
9
|
|
|
10
10
|
import { afterAll, describe, expect, it } from 'vitest';
|
|
11
11
|
|
|
12
|
-
import { SECRET, SETUP_GIT, blob, createTestHost, forConfig, hostState, makeBait, readTree, removeTestHosts, shortStatus, unexpectedChanges } from './code-in-bait.js';
|
|
13
|
-
import {
|
|
12
|
+
import { SECRET, SETUP_GIT, blob, createLocalPlace, createTestHost, forConfig, hostState, makeBait, readTree, removeTestHosts, shortStatus, unexpectedChanges } from './code-in-bait.js';
|
|
13
|
+
import { createCodeIn } from './code-in.js';
|
|
14
|
+
import { buildExtUrl } from './code-transfer.js';
|
|
14
15
|
import { SpaceError } from './errors.js';
|
|
15
16
|
import { createHostGit } from './host-git.js';
|
|
16
|
-
import { IMAGE_ONLY_PATH, IMAGE_SH } from './layout.js';
|
|
17
17
|
import { runCommand } from './run-command.js';
|
|
18
18
|
|
|
19
19
|
const WIN = process.platform === 'win32';
|
|
@@ -441,6 +441,7 @@ describe('the git version floor', () => {
|
|
|
441
441
|
});
|
|
442
442
|
|
|
443
443
|
it.each([
|
|
444
|
+
['git version 2.28.1\n', 'git_too_old'],
|
|
444
445
|
['git version 2.26.2\n', 'git_too_old'],
|
|
445
446
|
['git version 1.9.5\n', 'git_too_old'],
|
|
446
447
|
['something else\n', 'git_version_unreadable'],
|
|
@@ -451,14 +452,14 @@ describe('the git version floor', () => {
|
|
|
451
452
|
const codeIn = createCodeIn({ git: gitAnswering(host, text), place: null, temporaryDirectory: host.root });
|
|
452
453
|
const failure = await codeIn.takeSnapshot({ repository: repo, spaceId: SPACE_ID, mode: 'uncommitted' }).catch((error) => error);
|
|
453
454
|
expect(failure).toMatchObject({ code });
|
|
454
|
-
if (code === 'git_too_old') expect(failure.message).toMatch(/needs git 2\.
|
|
455
|
+
if (code === 'git_too_old') expect(failure.message).toMatch(/needs git 2\.29 or newer/);
|
|
455
456
|
expect(changedSince(before, repo, [])).toEqual([]);
|
|
456
457
|
});
|
|
457
458
|
|
|
458
|
-
it('lets 2.
|
|
459
|
+
it('lets 2.29.0 and every build suffix through, and refuses an old git for the history too', async () => {
|
|
459
460
|
const host = createTestHost();
|
|
460
461
|
const { repo } = makeBait(host);
|
|
461
|
-
for (const text of ['git version 2.
|
|
462
|
+
for (const text of ['git version 2.29.0\n', 'git version 2.50.1 (Apple Git-155)\n', 'git version 2.54.0.windows.1\n']) {
|
|
462
463
|
const codeIn = createCodeIn({ git: gitAnswering(host, text), place: null, temporaryDirectory: host.root });
|
|
463
464
|
await codeIn.takeSnapshot({ repository: repo, spaceId: SPACE_ID, mode: 'clean' });
|
|
464
465
|
await codeIn.removeSpaceRefs({ repository: repo, spaceId: SPACE_ID });
|
|
@@ -605,83 +606,6 @@ describe('failures keep their codes', () => {
|
|
|
605
606
|
});
|
|
606
607
|
});
|
|
607
608
|
|
|
608
|
-
// The receiving side of a push, standing in for `docker exec ... git receive-pack` in a real space.
|
|
609
|
-
// It checks that the host wrote out the fixed command, and maps /spaces/ into a local folder.
|
|
610
|
-
const RECEIVER = `
|
|
611
|
-
const { spawn } = require('node:child_process');
|
|
612
|
-
const fs = require('node:fs');
|
|
613
|
-
const path = require('node:path');
|
|
614
|
-
const [root, behaviour, ...command] = process.argv.slice(2);
|
|
615
|
-
const [timeoutPath, dashS, signal, seconds, gitPath, service, target] = command;
|
|
616
|
-
if (command.length !== 7 || timeoutPath !== '/usr/bin/timeout' || dashS !== '-s' || signal !== 'KILL' || !/^[0-9]+$/.test(seconds)
|
|
617
|
-
|| gitPath !== '/usr/bin/git' || service !== 'receive-pack' || !target.startsWith('/spaces/')) {
|
|
618
|
-
process.stderr.write('unexpected command ' + JSON.stringify(command) + '\\n');
|
|
619
|
-
process.exit(2);
|
|
620
|
-
}
|
|
621
|
-
fs.writeFileSync(path.join(root, 'last-command.json'), JSON.stringify(command));
|
|
622
|
-
fs.appendFileSync(path.join(root, 'commands.log'), target + '\\n');
|
|
623
|
-
if (behaviour === 'refuse') { process.stderr.write('refused by the space\\n'); process.exit(1); }
|
|
624
|
-
if (behaviour === 'flood') {
|
|
625
|
-
const chunk = 'x'.repeat(65536);
|
|
626
|
-
const pump = () => { while (process.stderr.write(chunk)) {} process.stderr.once('drain', pump); };
|
|
627
|
-
pump();
|
|
628
|
-
} else if (behaviour === 'hang') {
|
|
629
|
-
const grandchild = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' });
|
|
630
|
-
fs.writeFileSync(path.join(root, 'hang.pid'), String(grandchild.pid));
|
|
631
|
-
setInterval(() => {}, 1000);
|
|
632
|
-
} else {
|
|
633
|
-
const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.startsWith('GIT_')));
|
|
634
|
-
const child = spawn('git', ['receive-pack', path.join(root, target)], { stdio: 'inherit', env: { ...env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: ${JSON.stringify(os.devNull)} } });
|
|
635
|
-
child.on('exit', (code) => process.exit(code ?? 1));
|
|
636
|
-
}
|
|
637
|
-
`;
|
|
638
|
-
|
|
639
|
-
/**
|
|
640
|
-
* A stand-in place for one space in a local folder. `exec` runs the module's fixed scripts with the
|
|
641
|
-
* host's `sh` and `git`, with /spaces/ mapped into the folder and a `timeout` that only drops its
|
|
642
|
-
* limit, since the host may have none. `execArgv` points at RECEIVER, kept in a folder whose name has
|
|
643
|
-
* a space and a percent sign, so the ext escaping is exercised through git itself.
|
|
644
|
-
*/
|
|
645
|
-
function createLocalPlace(host, behaviour = 'receive') {
|
|
646
|
-
const root = path.join(host.root, 'stand in 50% place');
|
|
647
|
-
const bin = path.join(root, 'bin');
|
|
648
|
-
fs.mkdirSync(path.join(root, 'spaces', SPACE_ID), { recursive: true });
|
|
649
|
-
fs.mkdirSync(bin);
|
|
650
|
-
const receiver = path.join(root, 'receiver.cjs');
|
|
651
|
-
fs.writeFileSync(receiver, RECEIVER);
|
|
652
|
-
fs.writeFileSync(path.join(bin, 'timeout'), '#!/bin/sh\nshift 3\nexec "$@"\n', { mode: 0o755 });
|
|
653
|
-
const emptyConfig = path.join(root, 'inside-gitconfig');
|
|
654
|
-
fs.writeFileSync(emptyConfig, '');
|
|
655
|
-
const insideEnvironment = { PATH: `${bin}${path.delimiter}${process.env.PATH}`, HOME: root, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: emptyConfig };
|
|
656
|
-
const local = (inside) => path.join(root, inside);
|
|
657
|
-
return {
|
|
658
|
-
root,
|
|
659
|
-
local,
|
|
660
|
-
inside: (args) => {
|
|
661
|
-
const result = spawnSync('git', args, { env: insideEnvironment, encoding: 'utf8' });
|
|
662
|
-
return { code: result.status, stdout: result.stdout };
|
|
663
|
-
},
|
|
664
|
-
lastCommand: () => JSON.parse(fs.readFileSync(path.join(root, 'last-command.json'), 'utf8')),
|
|
665
|
-
/** How many pushes reached the history side repository. */
|
|
666
|
-
historyPushes: () => (fs.existsSync(path.join(root, 'commands.log')) ? fs.readFileSync(path.join(root, 'commands.log'), 'utf8').split('\n').filter((line) => line.endsWith('.openchamber-history.git')).length : 0),
|
|
667
|
-
hangPid: () => Number(fs.readFileSync(path.join(root, 'hang.pid'), 'utf8')),
|
|
668
|
-
place: {
|
|
669
|
-
execArgv: async (spaceId) => {
|
|
670
|
-
if (spaceId !== SPACE_ID) throw new SpaceError('space_not_found', 'no such space');
|
|
671
|
-
return [process.execPath, receiver, path.join(root), behaviour];
|
|
672
|
-
},
|
|
673
|
-
exec: async (spaceId, argv, { timeoutMs }) => {
|
|
674
|
-
const [shell, flag, script, zero, ...args] = argv;
|
|
675
|
-
expect([shell, flag, zero]).toEqual([IMAGE_SH, '-c', 'sh']);
|
|
676
|
-
expect(script.startsWith(IMAGE_ONLY_PATH)).toBe(true);
|
|
677
|
-
// Quoted: the stand-in's folder name has a space in it, which the image's own PATH never has.
|
|
678
|
-
const localScript = `PATH='${insideEnvironment.PATH}';${script.slice(IMAGE_ONLY_PATH.length)}`;
|
|
679
|
-
return runCommand('/bin/sh', ['-c', localScript, zero, ...args.map((arg) => (arg.startsWith('/spaces/') ? local(arg) : arg))], { env: insideEnvironment, timeoutMs });
|
|
680
|
-
},
|
|
681
|
-
},
|
|
682
|
-
};
|
|
683
|
-
}
|
|
684
|
-
|
|
685
609
|
// Every hook that git on the host could run during code in, each writing a marker of its own name.
|
|
686
610
|
const HOOK_NAMES = ['pre-push', 'post-index-change', 'reference-transaction', 'post-checkout', 'pre-commit', 'post-commit', 'pre-auto-gc', 'post-rewrite', 'push-to-checkout', 'pre-receive', 'post-receive', 'update', 'post-update'];
|
|
687
611
|
|