@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
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Design, words, and product decisions are in [docs/isolated-spaces/DESIGN.md](../../../../../docs/isolated-spaces/DESIGN.md). Read it first. Stages are in `STAGES.md` next to it, test rules in `TESTING.md`.
|
|
4
4
|
|
|
5
|
-
This is stage
|
|
5
|
+
This is stage 4a. The server imports the module behind one switch, `isolatedSpacesEnabled` in `settings.json`, read once at start: with the switch on it forwards requests under `/api/spaces/<id>/` to the server inside that space, see "Dispatcher"; with it off nothing of the feature exists, no place, no manager, no route, no `docker`. There is no UI yet. A space has one way out, its own gatekeeper, and nothing the host has not allowed passes through it. Since 3a the module can also bring a project's code into a space, see "Code in". Since 3b it can bring the space's work out again and apply it to the user's repository, see "Code out".
|
|
6
6
|
|
|
7
7
|
## What the module owns
|
|
8
8
|
|
|
@@ -15,19 +15,26 @@ Creating, finding, stopping, starting, and removing isolated spaces on a place,
|
|
|
15
15
|
- `gatekeeper-program.cjs`: the program that runs inside a gatekeeper. One self-contained CommonJS script on Node's standard library, run by the container and by its own unit test with the same arguments.
|
|
16
16
|
- `exec-http.js`: the pieces the two channels share, and the only place where the dangerous details live: the curl argv, a request as a curl config, and the parser for an answer that came out of a container.
|
|
17
17
|
- `code-in.js`: `createCodeIn({ git, place, temporaryDirectory, removeDirectory })`. The snapshot on the host, the list of files that travel, the first-release limits, the transfer in, the history after it, and the removal of the host refs. See "Code in".
|
|
18
|
+
- `code-out.js`: `createCodeOut({ git, place, temporaryDirectory, removeDirectory })`. The snapshot inside the space, the quarantine fetch with its caps, the promote into the user's repository, and the two ways to apply. See "Code out".
|
|
19
|
+
- `code-transfer.js`: what code in and code out share: the host git session that starts nothing of the user's, the one git version floor, the work tree check, the `ext::` URL builder, and the time limits of a transfer.
|
|
18
20
|
- `host-git.js`: `createHostGit({ runCommand, gitPath, environment })`. Every git command the host runs against the user's repository goes through it, with the environment rule in one place.
|
|
19
21
|
- `tools.js`: tools sources and the content key. `createRegistryToolsSource`, `createPackedToolsSource`, `readHostToolVersions`, `toolsContentKey`.
|
|
20
22
|
- `tools-filler.js`: the fixed program of the filler container and the framing of its stdin.
|
|
21
23
|
- `tools-pack.js`: `packLocalTools` packs the local `web` and `sdk` packages of a checkout for a development build. Nothing calls it yet except tests. Deciding when a host is a development build is wiring for a later stage.
|
|
24
|
+
- `dispatcher.js`: `createSpaceDispatcher({ transport })`. The thin layer in front of the host's routes that forwards a request addressed to a space, and the two guards. See "Dispatcher".
|
|
25
|
+
- `host.js`: `createSpacesHost({ dataDir, dockerPath, gitPath, hostEnvironment, listProjectDirectories })`. What `server/index.js` builds when the switch is on: the owner id, the Docker place, the registry, the manager, the dispatcher, the gatekeeper channel, code in and code out on one host git, the records and the journey, with the hooks the rest of the server takes from it.
|
|
26
|
+
- `journey.js`: `createSpaceJourney({ manager, place, gatekeeper, codeIn, codeOut, records, listProjectDirectories, announce, onSpacesChanged })`. The journey of a space as the host drives it, written once on top of those: create in the background with progress, list with state, start with the network said again, stop, remove with the refs and the record, stop all for the switch, the journal, the apply preview and the apply. See "The journey".
|
|
27
|
+
- `routes.js`: `registerSpaceRoutes(app, { getJourney, getPlaces, readSwitch, setSwitch })`, the HTTP of the journey under `/api/openchamber/spaces`, which translates and nothing else; and `createSwitchController({ getHost, setHost, buildHost, startHost, persist })`, the switch as `index.js` runs it, one change at a time.
|
|
28
|
+
- `space-records.js`: `createSpaceRecords({ dataDir })`. What the host remembers about a space beside the labels: the network choice, the repository, the space path, the base of the history and whether the history arrived. One file per space under `spaces/records/`, no secret in it.
|
|
22
29
|
- `places/registry.js`: place registry, same pattern as the tunnel provider registry. Sealed after boot.
|
|
23
30
|
- `places/docker.js`: the local Docker place. `createDockerPlace({ runCommand, dockerPath, owner, toolsSource, wait, now })`. `wait` is the pause used by rollbacks and by the readiness wait. `now` stamps the tools labels and is the clock of the readiness wait. Both are injectable so tests do not sleep.
|
|
24
31
|
- `places/docker-tools.js`: the tools volume of the Docker place. Ensure, fill, fill rollback, pruning.
|
|
25
32
|
- `places/docker-engine.js`: how both of them call the docker CLI. Run, inspect, find by label, remove.
|
|
26
33
|
- `hardening.js`: the `docker create` argv for a space and for its gatekeeper, the argv for both networks and for the three one-shot containers, and the pure checkers that compare `docker inspect` output with them.
|
|
27
34
|
- `labels.js`: label keys, resource names, space ids, tools keys, label parsing.
|
|
28
|
-
- `run-command.js`: the only file that starts a process.
|
|
35
|
+
- `run-command.js`: the only file that starts a process. Since 4a also `openCommandStream`, a command's stdin and stdout as one duplex stream that an `http.Agent` uses as a socket: the transport of `connect`.
|
|
29
36
|
- `errors.js`: `SpaceError` with a `code`.
|
|
30
|
-
- Test support, never imported by product code: `places/contract-suite.js`, `places/escape-suite.js`, `places/memory-place.js`, `places/fake-docker.js`, `places/docker-live-support.js`, and since 3a `code-in-bait.js`, the bait repository and the host state that code in must leave alone.
|
|
37
|
+
- Test support, never imported by product code: `places/contract-suite.js`, `places/escape-suite.js`, `places/memory-place.js`, `places/fake-docker.js`, `places/docker-live-support.js`, and since 3a `code-in-bait.js`, the bait repository and the host state that code in must leave alone. Since 3b `code-in-bait.js` also holds the stand-in for a space in a local folder that the unit tests of both directions use, and knows what code out may change in the user's repository.
|
|
31
38
|
|
|
32
39
|
## Place contract
|
|
33
40
|
|
|
@@ -39,15 +46,18 @@ A place has a non-empty `id` and these operations. The registry rejects a place
|
|
|
39
46
|
| `create(spec)` | `spec` is `{ id, name, project, created, memoryBytes }`. Resolves when the gatekeeper runs with its corridor accepting connections, the space runs, both verify clean, and the server inside the space reports OpenCode ready. Rejects with code `space_name_taken` when the id is in use. A rejected create has rolled back. `details.rollbackFailures` lists what it could not remove, and `details.uncertain: true` means a step was interrupted and the place may still finish it, so the caller should show the spaces list. |
|
|
40
47
|
| `list()` | Resolves `[{ id, name, project, created, state, orphans, damaged, missing }]`. `state` is the state of the space container: `running`, `exited`, or `missing`, because that is what `start`, `stop` and `remove` act on. `orphans` lists `{ kind, name }` resources whose space container is gone. `damaged: true` means the container exists and `missing` names what the space needs and does not have right now: a lost network or volume, or a gatekeeper that is absent, or one that is not running while the space is. `list` only reads and repairs nothing. A place failure rejects. It never resolves an empty list instead. |
|
|
41
48
|
| `exec(spaceId, argv, { stdin, timeoutMs, target })` | Runs `argv` as the space user, in the space or, with `target: 'gatekeeper'`, in its gatekeeper. Resolves `{ code, stdout, stderr }` for any exit code of the command. Any other target is rejected with `invalid_exec_target`. |
|
|
49
|
+
| `connect(spaceId)` | Since 4a. Resolves a duplex stream that carries bytes to the loopback port of the server inside the space and back, after the same ownership and running checks as `execArgv`, so a missing, foreign or stopped space is refused with the same codes. The space's network never sees it. For Docker it is the bridge of `layout.js` run over the `execArgv` argv, see "Dispatcher". |
|
|
42
50
|
| `execArgv(spaceId)` | Since 3a. Resolves the argv that runs a command in the space, as the space user, with stdin and stdout attached, for a caller that has to start the process itself: git starts it for a push over `ext::`. The space only, never the gatekeeper. The same ownership check as `exec` comes first, so a missing container or one this installation did not create is refused, and a container that is not running is refused with `space_not_running`, since git would get nothing but the runtime's own words from it. The argv carries no secret, no empty argument and no control character. For Docker it is `<docker> exec --interactive --user 1000:1000 <space container>`. |
|
|
43
51
|
| `stop(spaceId)`, `start(spaceId)` | Keep files. `stop` stops the space and then its gatekeeper; `start` starts the gatekeeper, waits for its corridor, and only then the space. A running space is left alone. `start` resolves when the server inside is ready again. A stopped space moves to the current tools first, see "New tools at the next start". `start` is the only operation that repairs a half-done move. |
|
|
44
52
|
| `remove(spaceId)` | Resolves `{ removed, failed }`. Missing resources count as removed. Removing an unknown space resolves with both lists empty. |
|
|
45
53
|
| `verify(spaceId)` | Resolves the list of `{ check, message }` violations of the space and of its gatekeeper. A gatekeeper that is absent is the violation `gatekeeper_missing`, and every check of the gatekeeper's own is named `gatekeeper_*`. Empty means verified. |
|
|
46
54
|
|
|
47
|
-
|
|
55
|
+
Stage 2 talks to the server inside and to the gatekeeper through `exec` only, see "The exec channel" and "The gatekeeper". `connect` arrived in 4a for the dispatcher; the channels of stage 2 keep using `exec`.
|
|
48
56
|
|
|
49
57
|
The contract suite is fixed against being rewritten per place, not against a new stage. Stage 2 added three assertions to it and changed none: the gatekeeper target runs in a container of its own, which a hostname of its own shows and the absence of the space's work directory confirms; `exec` takes those two targets and rejects any other with `invalid_exec_target`; and the gatekeeper is gone after `remove`, which is checked against the container and not against the space record. From here on a place cannot host a space without a gatekeeper, so every later place is held to that too. `places/memory-place.js` has two containers for this reason, and the three assertions were each confirmed to fail against a place that fakes one.
|
|
50
58
|
|
|
59
|
+
Stage 4a added three assertions and changed none: `connect` gives a stream on which one HTTP request reaches the server inside this space and is answered, and that ends cleanly when the caller destroys it; a stopped space gets no stream, with `space_not_running`; and a space that is gone gets none. The memory place answers them with a loopback listener per space, and `connect` is in the registry's list of required operations.
|
|
60
|
+
|
|
51
61
|
Stage 3a added three more and changed none. The argv from `execArgv` runs a command in the very container `exec` reaches, and not in the gatekeeper, which a hostname shows. It does not check the user: a Docker space already runs as uid 1000 by default, so no check through the argv could fail, and the exact-argv test in `places/docker.test.js` is what proves `--user` (removing the flag turns it red). A stopped space gets no argv, with `space_not_running`. And a space that is gone gets no argv. All three are marked in the file. Each was confirmed to fail against a memory place that fakes it: one that built the argv from the id alone failed the first and the last, one whose argv reached the gatekeeper failed the first, and one without the running check failed the second. `execArgv` is in the registry's list of required operations, so a place without it is rejected at registration.
|
|
52
62
|
|
|
53
63
|
A new place is done when `runPlaceContractSuite` and `runEscapeSuite` pass against it.
|
|
@@ -103,13 +113,13 @@ Rules the Docker place keeps:
|
|
|
103
113
|
| `--network <space network>` | The only network is the space's own. Its only other member is the gatekeeper. |
|
|
104
114
|
| `--mount type=volume` twice | Work volume at `/spaces/<id>`, home volume at `/home/space` with `HOME` set. `type=volume` cannot turn into a bind mount, unlike `-v`. |
|
|
105
115
|
| `--mount type=volume,...,readonly` once | The tools volume at `/opt/openchamber-tools`. Read-only, because every space of this owner runs its programs from it. An agent that could write there would change what the next space runs. |
|
|
106
|
-
| `--env` twelve times | See "The server inside a space". Never the server password, never
|
|
116
|
+
| `--env` twelve times | See "The server inside a space". Never the server password, never a provider key, and never a grant's secret. The checker allows these names and the base image's own, `PATH`, `NODE_VERSION` and `YARN_VERSION`, and nothing else. |
|
|
107
117
|
|
|
108
118
|
The inner network is created with `--internal`, `--ipv6=false`, and `--opt com.docker.network.bridge.gateway_mode_ipv4=isolated`.
|
|
109
119
|
|
|
110
120
|
`--internal` alone is not enough. Measured on Docker 29.2.1: from a space on a plain internal network, a TCP connect to the network's gateway address reached a listener on the Docker host. With the isolated gateway mode the bridge has no address on the host, `docker network inspect` shows no `Gateway`, and the same connect fails. The option needs Docker Engine 28. The checker looks at the option and at the missing gateway address, so an engine that ignores the option fails verification and `create` rolls back. `check()` reports such an engine with `hostIsolation: false`.
|
|
111
121
|
|
|
112
|
-
Never present: bind mounts, `--volumes-from`, the runtime socket, `--privileged`, extra security options, shared namespaces, sysctls, another runtime than `runc`, devices, published ports, a second tmpfs, `OPENCHAMBER_UI_PASSWORD`
|
|
122
|
+
Never present: bind mounts, `--volumes-from`, the runtime socket, `--privileged`, extra security options, shared namespaces, sysctls, another runtime than `runc`, devices, published ports, a second tmpfs, `OPENCHAMBER_UI_PASSWORD` in the container environment. The checker reports each of these. The environment is an allowlist: every variable must be one the create args set or one the pinned base image sets itself, read from the image's config and pinned in `hardening.js`. Any other variable fails verification, so it fails closed at every create and every start. That matters because OpenCode 2 turns variables into logins: a provider key under any of the 218 names its catalog lists for 226 integrations, such as `OPENAI_API_KEY`, or a key inside `OPENCODE_CONFIG_CONTENT`. OpenCode 1's `OPENCODE_AUTH_CONTENT` appears nowhere in the 2.0.15 binary. The server password is still refused by name, with a message of its own. The allowlist does not judge values, so a key added to the space environment in `layout.js` itself would pass it; the escape suite's connection check catches a catalog provider's key there, see "Tests". For the namespace modes it accepts `''` and `'private'`, which is what this engine reports for a correct container: `''` for pid, uts, and userns, `'private'` for ipc and cgroupns.
|
|
113
123
|
|
|
114
124
|
The checker demands that every mount is a volume whose name starts with this space's prefix. The mount at `/opt/openchamber-tools` is the one exception, and it has rules of its own:
|
|
115
125
|
|
|
@@ -184,7 +194,7 @@ The command is `/usr/local/bin/node -e <program> /opt/openchamber-tools /tmp/ope
|
|
|
184
194
|
|
|
185
195
|
The program is one line. 1a verified many special characters in an argument on Docker Desktop for Windows, but never a newline.
|
|
186
196
|
|
|
187
|
-
`--ignore-scripts
|
|
197
|
+
`--ignore-scripts` was measured with `web` 1.24.2 and OpenCode 1.18.31, whose package was then called `opencode-ai`. Three packages in that tree had an install script: `node-pty`, `msgpackr-extract`, and OpenCode's. `node-pty` ships prebuilt binaries for Linux arm64 and x64, and the terminal works without its script. `msgpackr-extract` is optional. OpenCode's package did need its script: its launcher `bin/opencode.exe` was a stub that printed "postinstall script was not run" until `postinstall.mjs` linked the OpenCode binary for this CPU and C library into its place. Since OpenCode 2 that package is `@opencode/cli`, and the filler runs `node postinstall.mjs` in `node_modules/@opencode/cli`, and no other script. It makes a hard link and needs no network. Checked again with OpenCode 2.0.15 only in part: in a filled volume the launcher is a second hard link to the binary of `@opencode/cli-linux-arm64`, which is what the script makes, and the space starts from it. The stub and the list of packages with scripts were not looked at again.
|
|
188
198
|
|
|
189
199
|
A fill has its own timeout of 30 minutes and its own output cap of 16 MB.
|
|
190
200
|
|
|
@@ -217,12 +227,12 @@ Environment:
|
|
|
217
227
|
| `HTTPS_PROXY`, `https_proxy`, `HTTP_PROXY`, `http_proxy`, all four `http://gatekeeper:3128` | The corridor. curl 7.88.1, the version in this image, ignores an uppercase `HTTP_PROXY` on purpose, the httpoxy protection, and honours the lowercase one. Without the lowercase spelling, plain-HTTP traffic from curl and from everything that uses libcurl never reaches the corridor. |
|
|
218
228
|
| `NO_PROXY`, `no_proxy`, both `gatekeeper,localhost,127.0.0.1` | Window traffic skips the corridor, and so does the space's own loopback. Measured: with `http_proxy` set, curl sends even a request to 127.0.0.1 to the corridor, and Node with `NODE_USE_ENV_PROXY=1` proxies loopback too. curl reads both spellings. |
|
|
219
229
|
| `NODE_USE_ENV_PROXY=1` | Node 22 ignores proxy variables without it. Measured: `EAI_AGAIN` for every fetch, and with it Node honours the uppercase spelling. |
|
|
220
|
-
| `OPENCODE_DISABLE_MODELS_FETCH=1`, `OPENCODE_DISABLE_AUTOUPDATE=1` | The catalog and the update check would spend corridor attempts on downloads a space does not need. |
|
|
230
|
+
| `OPENCODE_DISABLE_MODELS_FETCH=1`, `OPENCODE_DISABLE_AUTOUPDATE=1` | The catalog and the update check would spend corridor attempts on downloads a space does not need. Measured with OpenCode 2.0.15: without `OPENCODE_DISABLE_MODELS_FETCH` the journal shows `models.opencode.ai:443 deny:not-on-allowlist`, and with it the journal stays empty. The 2.0.15 binary does read `OPENCODE_DISABLE_AUTOUPDATE`, `1` or `true`, and then logs that it skipped the update check. What it changes in a space was not seen: `opencode serve` made no update attempt with it or without it. It stays because the binary honours it and it costs nothing. |
|
|
221
231
|
| `OPENCHAMBER_RELAY_HOST=off` | The server inside a space must never host the relay passively. It has a way out now, and paired devices must not land on a space. `server/index.js` reads this variable when it computes `allowPassiveHost`. |
|
|
222
232
|
|
|
223
|
-
`npm_config_fetch_retries=0` is gone. Stage 1b set it because a space had no route at all, and OpenCode's background install of `@opencode/plugin
|
|
233
|
+
`npm_config_fetch_retries=0` is gone. Stage 1b set it because a space had no route at all, and OpenCode 1.18.31's background install of its plugin package, then `@opencode-ai/plugin`, held its tool registry for a long time: with no network, the first request for a project with `.opencode/tool/*.ts` took 131 seconds. Measured again in stage 2 with `npm view opencode-ai version` from a space-like container: with no route, 70 seconds; through a corridor that answers an immediate 403, 0.3 seconds with npm's own retry settings, because npm does not retry a 403. That measurement is about npm, not OpenCode. So the corridor's refusal does the work the variable did, and the agent's own `npm install` gets its retries back. OpenCode 2.0.15 has no such install for a local plugin at all: it imports the file and either finds the package or fails at once.
|
|
224
234
|
|
|
225
|
-
The plugin must also resolve from project files
|
|
235
|
+
The plugin must also resolve from project files. OpenCode 2 loads a project's plugins from `.opencode/plugin/` and `.opencode/plugins/`, `.ts` or `.js`, and imports each file directly. Measured with OpenCode 2.0.15 and no link: such a plugin reports `failed`, and its log says `Cannot find package '@opencode/plugin'`, with no download attempted. After the start, `create` makes `/spaces/<id>/node_modules/@opencode/plugin` a symlink to the package in the tools volume. Module resolution walks up from the project, so one link above every project is enough. Only the plugin is linked, so project code does not quietly resolve our other packages. With the link the plugin reports `active` and the plugin's own dependencies resolve from the link's real path. With OpenCode 1.18.31 a tool that used `tool.schema` was listed in 141 ms. The agent can delete the link. That hurts only itself and is not a boundary.
|
|
226
236
|
|
|
227
237
|
### Token
|
|
228
238
|
|
|
@@ -230,7 +240,7 @@ The plugin must also resolve from project files, or such a project fails with `C
|
|
|
230
240
|
|
|
231
241
|
The honest limit: the agent runs as the same user. It can read the token file and the environment of the server process. The token is not a secret from the agent. It keeps the server closed to anything else, and it stays out of container metadata.
|
|
232
242
|
|
|
233
|
-
The host keeps no copy. `readToken` reads it back over `exec`. `stop` and `start` keep it, so the server comes up by itself.
|
|
243
|
+
The host keeps no copy. `readToken` reads it back over `exec`. `stop` and `start` keep it, so the server comes up by itself. **Decided in 4a: the host does not issue a fresh token at its own start.** The token is read back over `exec` at the first request to a space and kept in the host's memory for the life of the process, and read again only when the server inside refuses a session made from it. A fresh token would need a restart of the server inside, which ends whatever the agent is doing there, and buys nothing: the token protects the server inside from others and never from the agent, who can read the file, and the file and the host's copy both live where only the user reaches them. A rotation belongs with the repair actions of a later stage, where restarting the server inside is what the user asked for.
|
|
234
244
|
|
|
235
245
|
### The exec channel
|
|
236
246
|
|
|
@@ -296,10 +306,10 @@ Docker refuses `--network-alias` together with a second `--network`, so `create`
|
|
|
296
306
|
`gatekeeper-program.cjs` is one self-contained CommonJS script on Node's standard library. It is far larger than the tools filler, and stage 1a only ever verified a 2,641 character `node -e` argument on Windows, so it does not travel as an argument. The container's command waits for the file in its tmpfs and then execs Node on it, the same shape as the space's wait for its token file:
|
|
297
307
|
|
|
298
308
|
```
|
|
299
|
-
while [ ! -s /tmp/openchamber-gatekeeper/gatekeeper.cjs ]; do /bin/sleep 0.2; done; exec /usr/local/bin/node /tmp/openchamber-gatekeeper/gatekeeper.cjs
|
|
309
|
+
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
|
|
300
310
|
```
|
|
301
311
|
|
|
302
|
-
The host writes the file over `exec` on stdin, through a temporary name, so the waiting container never runs half a file. `docker cp` is not an option: measured, it fails with "container rootfs is marked read-only" against a read-only container, even for a tmpfs destination.
|
|
312
|
+
The host writes the file over `exec` on stdin, through a temporary name, so the waiting container never runs half a file. Since 5b the same script first writes the bind file the command names, holding the gatekeeper's own address on the space's inner network, which the place reads from `docker inspect` after the start; the program reads it when it starts and listens there, see "Known limits" for why. A bind file that is missing, empty, not an address, or that names every interface ends the program with exit code 2, and so does such an address given directly; the readiness wait then fails the create or the start: nothing of ours listens on the outer network. The checker does not compare the container's command, so a gatekeeper made before 5b, whose command still says `0.0.0.0`, is caught here instead: it does not start, and its space has to be made again. The feature was never released, so only development spaces meet this. `docker cp` is not an option: measured, it fails with "container rootfs is marked read-only" against a read-only container, even for a tmpfs destination.
|
|
303
313
|
|
|
304
314
|
Its size crosses no boundary, and the question is worth answering once rather than every time it grows. It travels on the stdin of `docker exec --interactive` and never as an argument, `run-command.js` writes it and ends that stdin with the pipe's own backpressure, so no single write of ours can block or truncate, and it is in any case under the 65,536-byte default pipe buffer on Windows. The 2,641 characters stage 1a verified were the limit of a `node -e` argument, which is exactly what this path avoids.
|
|
305
315
|
|
|
@@ -341,7 +351,7 @@ An HTTP CONNECT proxy on the space-facing interface. Stage 0 proved that OpenCod
|
|
|
341
351
|
|
|
342
352
|
### Window
|
|
343
353
|
|
|
344
|
-
A reverse proxy on the same interface, for services whose secret must not enter the space. `/model/<grant id>/<rest>` goes to that grant's upstream with `<rest>` appended to the upstream's path unchanged, and with the grant's own header set: `Authorization: Bearer <key>` for OpenAI-style APIs, `x-api-key: <key>` for Anthropic. Whatever the space sent as `authorization`, `x-api-key
|
|
354
|
+
A reverse proxy on the same interface, for services whose secret must not enter the space. `/model/<grant id>/<rest>` goes to that grant's upstream with `<rest>` appended to the upstream's path unchanged, and with the grant's own header set: `Authorization: Bearer <key>` for OpenAI-style APIs, `x-api-key: <key>` for Anthropic, `x-goog-api-key` for Google. Whatever the space sent as `authorization`, `x-api-key`, `proxy-authorization` or under the grant's own header name is removed first. Since 5b a grant may have no secret, an opened domain: the window then adds nothing and forwards the request as the space made it, less those credential headers. The path stays `/model/` for both kinds; it names the window, not what is behind it. An unknown grant id is 403 and journalled. A grant id is never guessable from inside, and it is treated as public anyway: nothing relies on it being secret.
|
|
345
355
|
|
|
346
356
|
A grant opens a place on a host, not the host: a rest of the path that climbs out of it is refused with `deny:path-walks-out`, because with git and a private registry the path is what says which repository or which package. It is the same host and the same credential either way, so this is a door closed before those windows land rather than an escape today.
|
|
347
357
|
|
|
@@ -353,7 +363,7 @@ The window is written for any upstream and any header name, because git over htt
|
|
|
353
363
|
|
|
354
364
|
The upstream may be `https`, and stage 0 only ever proved a plain-HTTP one. TLS to the upstream is tested live: a grant that points at a public HTTPS endpoint answers through the window, with no credential of anyone's.
|
|
355
365
|
|
|
356
|
-
The window applies no private-address rule to a grant's upstream, on purpose. A grant is the user's decision, delivered over the control channel, and a private npm registry on the user's own network is exactly the case the design wants
|
|
366
|
+
The window applies no private-address rule to a grant's upstream, on purpose. A grant is the user's decision, delivered over the control channel, and a private npm registry on the user's own network is exactly the case the design wants. The space cannot choose an upstream, only a grant id that the host has already set. The one refusal is the gatekeeper itself: any of its own addresses, not only loopback, on one of its three ports; a grant pointing at the window would feed it into itself until its 64 sockets out were gone. The consequence is a rule the host must keep: never build a grant out of anything a space said. The grant route takes its upstream from the client, which is the user at the screen with the host's own provider catalog, never from the space.
|
|
357
367
|
|
|
358
368
|
### Control
|
|
359
369
|
|
|
@@ -363,7 +373,8 @@ An HTTP listener on the gatekeeper's own loopback, which the space cannot reach.
|
|
|
363
373
|
|---|---|
|
|
364
374
|
| `GET /health` | Answers 200 once all three listeners are up. It is the last to bind, so an answer means the corridor accepts connections. |
|
|
365
375
|
| `POST /network` | `{ mode, domains }`. Replaces both. |
|
|
366
|
-
| `POST /grants` | `{ id, upstream, header, secret }
|
|
376
|
+
| `POST /grants` | `{ id, upstream, header, secret }`, or `{ id, upstream }` for an opened domain. Adds or replaces one grant. A header without a secret, or a secret without a header, is refused. |
|
|
377
|
+
| `GET /policy` | Since 5b: `{ mode, domains, grants }`, the ids of the grants and never a secret, so the host can tell a gatekeeper that forgot from one that remembers. |
|
|
367
378
|
| `GET /journal` | The records and how many were dropped. |
|
|
368
379
|
|
|
369
380
|
Anything else is 404, and a body that is not an object is 400. Grants and the allowlist live in the program's memory only.
|
|
@@ -385,7 +396,7 @@ The decisions are `allow`, `failed:<error code>` for a host that was allowed and
|
|
|
385
396
|
- `stop` stops the space and then the gatekeeper. `start` starts the gatekeeper, waits for its corridor, and only then starts the space, whether the space starts as it is or moves to new tools first. A space is never running while its way out is not under the host's control, and a failed stop of the gatekeeper leaves the space stopped, which is the safe side of that order.
|
|
386
397
|
- A `start` that races a `stop` can leave the space running with its gatekeeper stopped, because `stop` waits for a start of the same space and then stops the space first. That direction is the safe one: the space has no way out at all while its gatekeeper is down, `list` reports it as damaged, and the next `start` brings the gatekeeper back. It is written here so that nobody has to find it twice.
|
|
387
398
|
- `start` looks after the gatekeeper even when the space container is already running, and that is the one thing it does to a running space. "A running space is never touched" is about the space's own container and its tools. A gatekeeper can die on its own, from its memory limit among other things, and `list` reports such a space as damaged; without this, the one action a user would take would skip the only step that repairs it, and only a stop and a start would help.
|
|
388
|
-
- The program lives in a tmpfs, so it is gone after every stop, and `start` writes it again. Grants and the allowlist are gone with it and are not restored
|
|
399
|
+
- The program lives in a tmpfs, so it is gone after every stop, and `start` writes it again, with the bind file before it. Grants and the allowlist are gone with it and are not restored by the place: the journey says the network again and every grant it can, see "Grants", and what it cannot is "needs access" in the list, which is what DESIGN.md asks for after a machine restart. No container of ours has a restart policy, so a Docker machine that comes back up leaves both containers stopped and the next `start` brings them up in this order.
|
|
389
400
|
- The move to new tools does not touch the gatekeeper. It mounts no tools volume, so a new tools version changes nothing about it, and the move remakes the space container only.
|
|
390
401
|
- `remove` removes both containers, both networks and the volumes, by label, containers first. `create`'s name check covers the two new names, and so does the second by-name sweep after an interrupted step.
|
|
391
402
|
- A space whose gatekeeper container is gone does not start, and neither does one that is already running: `start` rejects with `gatekeeper_missing` and changes nothing. Known limit: the gatekeeper is not made again, although it holds no state, so such a space has to be applied or discarded and made again. Making it again would be a repair path with a rollback of its own, and stage 2 does not build one.
|
|
@@ -393,10 +404,8 @@ The decisions are `allow`, `failed:<error code>` for a host that was allowed and
|
|
|
393
404
|
|
|
394
405
|
### Known limits
|
|
395
406
|
|
|
396
|
-
- The corridor and the window listen on
|
|
397
|
-
|
|
398
|
-
The space cannot use it: it still cannot reach the host, and that is what the escape tests prove. What it means is that an unprivileged local process on a Linux desktop could spend the user's model key once a real one sits behind the window. Nothing in this stage puts one there, and the stage that first does has to close it. Two ways are on the table, neither chosen here: a secret the host hands the window together with the grant, which every request must carry, or binding the window to the space's inner network address alone. The second needs no change to the create order, whatever an earlier draft of this paragraph said: the bind address is an argument of the program, and the program arrives over `exec` after the container is running, when Docker has already given it both addresses. `STAGES.md` carries this as an obligation on that stage.
|
|
399
|
-
- Anything else attached to the space's outer network could use the two listeners as well. The place attaches nothing else, and the escape suite's stand-in upstream is the only exception, in tests.
|
|
407
|
+
- The corridor and the window listen on the gatekeeper's address on the space's inner network only, since 5b. Before that they listened on every interface, and **on Linux that included the user's own machine**: a Linux Docker host routes into the bridge subnet, so any local process could reach both listeners without a published port. Measured on Debian 13, amd64, Engine 29.8.1, before the change: from the host, `curl http://<the gatekeeper's outer address>:8080/` connected and read the window's answer. On macOS and Windows a virtual machine stands between the host and the bridge, which is why a Mac-only run could never have shown it. The space could never use it, and that was not the point: an unprivileged local process on a Linux desktop could have spent the user's model key once a real one sat behind the window, which 5b is the first stage to do. Of the two ways STAGES.md named, the bind address was taken: the host decides where the gatekeeper listens, and the program has no policy of its own. The escape suite proves it with a neighbour on the outer network, which reaches the stand-in upstream beside it and is refused on the gatekeeper's outer address on all three ports, and the same measurement from a Linux host is repeated with each stage that touches the listeners.
|
|
408
|
+
- Nothing attached to the space's outer network can use the two listeners either, for the same reason; the stand-in upstream of the escape suite is the neighbour that proves it.
|
|
400
409
|
- A tunnel the upstream half-closed stays open, because a close travels in each direction on its own. Measured: 64 of those with live clients means the 65th is refused, and all 64 places come back as the clients let go. That is the tunnel cap doing its job on genuinely open tunnels rather than a leak, and the idle limit ends one that goes quiet.
|
|
401
410
|
- Port 443 only. A tool that needs another port cannot be reached until the grant dialog can open one.
|
|
402
411
|
- The corridor takes names, so a tool that only knows an address, and that the user cannot give a name, cannot be reached either.
|
|
@@ -406,7 +415,146 @@ The decisions are `allow`, `failed:<error code>` for a host that was allowed and
|
|
|
406
415
|
- In open mode, refusing `auth.openai.com` is a lock and not a guarantee. See the corridor's rules.
|
|
407
416
|
- The gatekeeper is not made again when it is gone.
|
|
408
417
|
- The journal is memory only. A restart of the gatekeeper loses it, and so does a host that never reads it between two starts.
|
|
409
|
-
- Grants and the allowlist have to be set again after every start of a space.
|
|
418
|
+
- Grants and the allowlist have to be set again after every start of a space; since 5b the journey does that, and a key the user typed once is the one thing it cannot say again, see "Grants".
|
|
419
|
+
|
|
420
|
+
## Dispatcher
|
|
421
|
+
|
|
422
|
+
The design is in DESIGN.md, "Dispatcher, sessions, events". 4a built HTTP and SSE forwarding under `/api/spaces/<id>/`, the two guards, and the route rules; 4b added the WebSocket forwarding, see "Sockets" below, and the merged session list with the event connection of every space, see "Sessions and events". 4c made the UI address a space through the prefix; the list the sidebar reads per project is the host's alone, and the global list, without a directory, carries the spaces.
|
|
423
|
+
|
|
424
|
+
### The switch
|
|
425
|
+
|
|
426
|
+
`isolatedSpacesEnabled` in `settings.json`, a boolean, false by default, in the settings registry as an instance field with no UI binding. `server/index.js` reads it once at start: when it is true it makes the host of `host.js`. The place of the dispatcher in the middleware chain, after the `/api` auth gate and before every feature route and the OpenCode proxy, and the `upgrade` handler of the server are each a slot mounted once, which reads the host at call time and passes on when there is none; the body parsers and the directory gate read their hooks from the host at call time as well. So while the switch is off none of the feature exists: the module is imported and nothing of it runs, no `docker` is started, no file is written, and a request under `/api/spaces/` falls through to the host's own routes and the OpenCode proxy, which know nothing about it. A request that names a space's directory without the prefix runs on the host as any other while the switch is off: the guards belong to the feature, and the feature is absent.
|
|
427
|
+
|
|
428
|
+
Since 5a the switch changes live, through `GET` and `PUT /api/openchamber/spaces/switch` in `routes.js`, the one route of the feature that exists while it is off, because turning it on is what makes the feature exist (decision 19). `GET` answers `{ enabled, spaces }`, the spaces with their id, name and state while it is on and an empty list while it is off, so the banner of decision 18 can say what turning it off would stop; while off it runs nothing. When the place cannot be asked, Docker being down among the reasons, `spaces` is null with a `failure` beside it, so the switch stays reachable and the screen can say that the list is unknown rather than showing an error where the switch was. `PUT { enabled: false }` stops every running space, one by one, writes the setting, closes the host and takes it out of the slots; the answer carries `stopped` and `stillRunning`, a space that could not be stopped named with its code and never counted as stopped. A creation under way refuses the turn-off with `space_preparing`, and from the first stop on the journey takes no new creation and no start, `isolated_spaces_off`, so nothing slips in between the stops and the moment the feature is gone; a setting that cannot be written leaves the feature on and the journey open again. `PUT { enabled: true }` writes the setting, makes the host and starts its event connections, exactly what the next start would do; when the host cannot be made, the setting is written all the same and the answer is the failure, as a start with the switch on logs it. The controller runs one change at a time: a second request waits for the first and sees what it left, so a double click builds one host and closes one host, never two. The settings toggle of 4c still writes the setting directly and says that a restart is needed; the settings screen that calls this route is a later part of stage 5.
|
|
429
|
+
|
|
430
|
+
### What is forwarded
|
|
431
|
+
|
|
432
|
+
A request goes to a space when its path starts with `/api/spaces/<id>/` and the id is one of this host's spaces, read from the runtime's labels through the manager's list. The list is read again after two seconds, and at once for an id it does not hold, at most twice a second, so a space made a moment ago is found and a wrong id costs no more than one `docker ps` per half second. The dispatcher reads the path and nothing else to decide where a request goes: no directory, no body, no session id, no terminal id.
|
|
433
|
+
|
|
434
|
+
The prefix comes off, so `/api/spaces/<id>/session` is `/api/session` inside. The user's `cookie`, `authorization` and `proxy-authorization` headers come off, and so do `oc_url_token` and `oc_client_token` from the query; `host`, `origin`, `referer`, the hop-by-hop headers and the `x-forwarded-*` family come off as well. The request gets the space's own session cookie, `Host: 127.0.0.1:27600`, and `accept-encoding: identity`. Everything else, method, path, query, headers and body, travels as it is, and the body streams: nothing on the host reads it first. The host's `express.urlencoded` is global, so `registerCommonRequestMiddleware` takes a `skipBodyParsing(req)` predicate and leaves a prefixed request's body alone. A body a parser did read anyway is answered with 500 and `space_request_body_consumed` rather than waited for.
|
|
435
|
+
|
|
436
|
+
The answer streams back the same way. An event stream is written with `writeSseChunkWithBackpressure` of the host's own proxy, headers flushed at once, `Cache-Control: no-cache`, `X-Accel-Buffering: no`, and a client that leaves ends the request inside. Anything else is piped. Errors on the way are one JSON answer with a stable `code` when nothing has been sent yet, also while the client's body was still on its way in, and a closed connection after that. Route rules read the path without regard to case, as Express inside does.
|
|
437
|
+
|
|
438
|
+
### The session inside
|
|
439
|
+
|
|
440
|
+
The space's token is the password of the server inside, and the UI auth takes a password only through `POST /auth/session`, never as a bearer. So the dispatcher logs in once per space, with the token read back over `exec` and kept in memory, keeps the cookie, and renews it at ninety percent of the `Max-Age` the server inside gave, twelve hours by default. A login that the server inside refuses with the remembered token is repeated once with the token read again, because the agent can rewrite the file and the server inside can restart on the new one; a second refusal is the failure. One login runs at a time per space; requests that arrive during it wait for it. When the server inside answers 401 all the same, the session is forgotten, the token is read again, because the space may have been made again, and the login repeats: a request without a body is then sent again, once; a request with a body cannot be, its body went with the first attempt, so it is answered with 503 and `space_session_expired`, and the next one goes through on the renewed session. A login that the server inside refuses is 502 with `space_login_failed`. The token is in no answer, no error text and no log line: the dispatcher logs the code of a failure and the space id, nothing else.
|
|
441
|
+
|
|
442
|
+
### The transport
|
|
443
|
+
|
|
444
|
+
`connect` of the place contract gives the dispatcher its channel. For Docker it is `docker exec --interactive --user 1000:1000 <space container> /usr/local/bin/node -e <bridge> 127.0.0.1 27600`: the bridge, `CONNECT_BRIDGE_PROGRAM` in `layout.js`, joins the exec's stdin and stdout to the loopback port of the server inside and ends when either side ends. It runs on the image's Node by absolute path, uses `net`, which no proxy variable of the space touches, and reads nothing from the space but its two arguments. It leaves only after its last write to stdout has gone out; measured, an exit on the socket's close dropped what was still buffered, and a plain file stream on the descriptor failed with a system error once the pipe was full, so the two standard streams are Node's own.
|
|
445
|
+
|
|
446
|
+
On the host `openCommandStream` in `run-command.js` wraps that process as a `CommandStream`, a duplex with everything an `http` client calls on a socket: `setNoDelay`, `setKeepAlive`, `setTimeout`, `ref`, `unref` and `destroy`, which kills the command. It ends when the command's stdout ends, and a command that exits with a code other than zero, or before it wrote anything, ends it with `command_stream_failed` and the tail of the command's stderr, so a bridge that found nothing on the port says `ECONNREFUSED`. Backpressure runs both ways through the pipes; a four-megabyte answer while the other side is still sending arrives whole, sha256 compared, in the unit test.
|
|
447
|
+
|
|
448
|
+
One `http.Agent` per space holds these streams with keep-alive, at most eight, and reuses them: measured on the host, seven requests through one bridge cost one process, and a request on a warm stream took two milliseconds against seventy for the first. A free stream is closed after four seconds, before the server inside closes its side at five, Node's default keep-alive timeout, so a request never lands on a stream the server is closing. A stream that is closed kills its `docker exec`, and the bridge inside ends with it: the live file counts the bridge's processes inside after `close` and finds none. The agent asks `connect` for every new stream, so a space that stopped in between is refused with the place's own code. A stream from the pool can still die under a request, when the space stopped or the server inside closed it a moment earlier: a request without a body is then sent once more on a fresh stream, and a request with a body is answered 502, because its body went with the first attempt. The live file shows the first: a stopped space with a stream still in the pool answers 503 with `space_not_running`.
|
|
449
|
+
|
|
450
|
+
### The two guards
|
|
451
|
+
|
|
452
|
+
Both only reject. Neither routes anything.
|
|
453
|
+
|
|
454
|
+
- A prefixed request whose `directory` or `location[directory]` query, or `x-opencode-directory` header, decoded when the encoding header says `uri`, names a directory outside `/spaces/<id>` is refused with 400 and `directory_outside_space`. Directories are normalised as POSIX paths first, so `/spaces/<id>/../<other>` is outside.
|
|
455
|
+
- An unprefixed request under `/api/` that names a directory under `/spaces/` the same way is refused with 400 and `space_directory_needs_prefix`, before any host route and before the last-directory fallback. The directory gate of the host, `validateDirectoryPath`, takes a `refuseDirectory(candidate)` hook and refuses such a directory before it looks at the disk, which covers the OpenChamber routes that pass a body's directory through the gate, `markdown-image-grants` and the OpenChamber session routes among them; those answer with the gate's own 400 and its message. A body the OpenCode proxy forwards untouched, `location.directory` of `POST /api/session` among them, is read by nobody on the host: such a session lands wherever the host's OpenCode puts a directory that does not exist there.
|
|
456
|
+
|
|
457
|
+
The dispatcher reads no body, so a prefixed request whose body names a directory outside the space is not caught by the first guard. The server inside then works in a directory that does not exist there, which is the space's own business. Neither guard is a defence against the agent; both catch a client that addresses the wrong side.
|
|
458
|
+
|
|
459
|
+
### Route rules
|
|
460
|
+
|
|
461
|
+
Refused with 403 and `host_only_route`, whatever the server inside would say: `/api/provider`, `/api/providers`, `/api/integration`, `/api/credential`, `/api/auth`, `/api/config/settings`, `/api/config/themes`, `/api/themes`, `/api/github`, `/api/linear`, `/api/voice`, `/api/tts`, `/api/dictation`, `/api/guests`, `/api/projects`, `/api/client-auth`, `/api/system`, `/api/push`, `/api/notifications` and `/api/openchamber/tunnel`. Refused with 403 and `refused_across_boundary`: `/api/git/worktrees`, `/api/git/worktree-type`, `/api/git/integrate`, `/api/fs/serve`, `/api/preview/proxy` and `/api/session/<id>/move`. A route matches as a whole path segment, so `/api/config/agents` is forwarded while `/api/config/settings` is not. The lists are `HOST_ONLY_ROUTES` and `REFUSED_ACROSS_BOUNDARY_ROUTES` in `dispatcher.js`.
|
|
462
|
+
|
|
463
|
+
An unknown or malformed space id is 404 with `space_not_found`. A space that is stopped or in the middle of a move is 503 with the place's code. A token that cannot be read, a setup step that failed inside, and a refused login are 502 with their code. Everything else on the way, the bridge included, is 502 with `space_unreachable`.
|
|
464
|
+
|
|
465
|
+
### What comes back
|
|
466
|
+
|
|
467
|
+
Everything from a space is untrusted. Only the headers a client needs to read and cache the body come back, an allowlist: the content headers, `cache-control`, `expires`, `pragma`, `etag`, `last-modified`, `vary`, `date`, `x-accel-buffering` and `x-next-cursor`. So `Set-Cookie` never comes back and a space cannot set a cookie on the app's origin, `Clear-Site-Data` cannot wipe it, and `Location` is never followed: a 3xx from a space is answered as 502 with `space_redirected`, because a browser follows a same-origin redirect with the user's credentials and the request's method and body, which would let the agent aim the user's next request at any host route. Every answer carries `X-Content-Type-Options: nosniff`. An answer whose content type a browser would render as a page, `text/html`, `application/xhtml+xml`, `image/svg+xml`, `text/xml`, `application/xml` or `application/pdf`, or that has no content type at all, which a browser sniffs on a navigation, is served as `text/plain`. `/api/fs/raw` is served as it is only for an image a browser merely draws, `png`, `jpeg`, `gif`, `webp`, `avif`, `bmp` and icons, and for audio and video; anything else, SVG and HTML among them, becomes `application/octet-stream` with `Content-Disposition: attachment`. `/api/fs/serve` and `/api/preview/proxy` are refused, see above, so no file of a space renders as a page under the app's origin. Hop-by-hop headers and `www-authenticate` are outside the allowlist as they are outside the host's proxy.
|
|
468
|
+
|
|
469
|
+
### Sockets
|
|
470
|
+
|
|
471
|
+
`websocket.js` takes every `upgrade` under the prefix; `host.js` mounts its handler on the HTTP server beside the host's own. Four sockets of the server inside are forwarded, by their inner path: `/api/terminal/ws`, `/api/dev-tunnel`, `/api/event/ws` and `/api/global/event/ws`. Any other upgrade under the prefix is refused: a host-only or refused family with its 403 and code, anything else with 404 and `space_socket_unknown`, so the dictation socket and the guest surfaces stay the host's. The same guard as for HTTP applies first: a `directory` in the query outside the space is 400 with `directory_outside_space`.
|
|
472
|
+
|
|
473
|
+
The host authenticates the user the way its own socket handlers do, and per socket the same way as that socket's host twin: the terminal and the two event sockets need a session or a URL token and an origin the host trusts; the dev tunnel also takes a client that authenticated with its own token and sends no origin, as the desktop's tunnel client does. Then the user's `cookie`, `authorization`, the `oc_url_token` and the rest of the dispatcher's dropped headers come off, the space's session cookie goes on, and the handshake is written into a fresh stream from the place's `connect`, one `docker exec` per socket, never one from the agent's pool: a pooled stream can die under a request and an upgrade cannot be sent again on the same client socket. The client's `Sec-WebSocket-*` headers travel as they are, so the two ends negotiate the protocol and the extensions between themselves. The `Origin` the server inside gets is the loopback one, `http://127.0.0.1:27600`, the same origin the relay host presents when it dials the host's loopback, and never the client's: the server inside refuses an upgrade without an origin its host header matches, exactly as the host does.
|
|
474
|
+
|
|
475
|
+
The answer inside is read up to its header block, at most 16 KiB and within 30 seconds. A 401 renews the session once, as for HTTP, and repeats the handshake on a fresh stream. A 101 is answered to the client with the handshake headers only, `upgrade`, `connection`, `sec-websocket-accept`, `-protocol` and `-extensions`, and from then on the two sockets are joined byte for byte with `pipe` in both directions, so backpressure runs both ways and nothing of the frames is read on the host; a 4 MiB frame comes back whole in the unit test, sha256 compared. Any other status is answered on the client's socket as that status, 502 when it makes no sense, with `space_socket_refused`, so the dev tunnel's "that port is not an available dev server" from inside reaches the client as the 403 it is. A place that refuses `connect` answers with the dispatcher's codes, 503 `space_not_running` among them. Every refusal before the upgrade is one HTTP answer with a JSON body and a stable `code`.
|
|
476
|
+
|
|
477
|
+
After the upgrade, the stream inside ending or failing sends the client a close frame, 1012 `space_closed` or 1011 `space_unreachable`, and ends the socket; a client that leaves, at its `end` already and not only at `close`, since the host's sockets allow a half-open state, kills the stream and with it the `docker exec` and the bridge inside. A client that leaves while the handshake inside is still under way ends that handshake at once, so no stream waits out the 30 seconds for nobody. At most 72 sockets per space are open at once, the handshakes under way counted, 503 with `space_socket_limit` beyond that, because each one is a process on the host and inside from the moment `connect` is called. All of them end at the host's close.
|
|
478
|
+
|
|
479
|
+
The new paths joined the three allowlists by shape, as the guest surfaces did, in `ALLOWED_WS_PATHS` of `relay/tunnel-host.js`, `isUrlAuthWebSocketPath` of `ui-auth/ui-auth.js` and the Electron realtime proxy's list, and `/api/spaces/<id>/fs/raw` joined `isUrlAuthReadableHttpPath`, so a browser-owned image URL works with a URL token. Each prefixed shape is in exactly the lists its unprefixed twin is in: the realtime proxy carries no dev tunnel today, so it carries none for a space either.
|
|
480
|
+
|
|
481
|
+
### Sessions and events
|
|
482
|
+
|
|
483
|
+
The merged list and the rules for it live in `space-sessions.js`, pure; `host.js` reads the spaces and the proxy asks for the merge.
|
|
484
|
+
|
|
485
|
+
**The merged session list.** `GET /api/session` on the host is the host's list, sanitized and overlaid as before, plus every space's sessions after it, on the first page of the global list only: a request with a `cursor` is a later page of the host's list, and a request with a `directory`, in the query or in the `x-opencode-directory` header, which is what the sidebar reads once per project, is that directory's list; neither carries a space. The host reads its list of spaces, cached for two seconds, and asks every space at the same time for `/api/session` over the dispatcher's own channel, in pages of a hundred, at most ten pages, with a ten-second limit per page; a space's accepted list is served again for two seconds without asking it, so a burst of list requests costs each space one read. The answer carries a mark per space in `spaces`: `complete`, `partial` when the space had more pages than were read, `stale` when the space did not answer and its last accepted list stands in, and `unknown` when it never answered, in which case it has no records. Each mark also names the space: its `name` from the label, the registered host project it was made for as `projectDirectory`, resolved from the label's hash of the project path against the projects `server/index.js` hands the host, and the project's path inside as `directory`, both `null` when the project is not registered here. The UI's sidebar hangs the space's group on that project. A missing answer never means deletion: a stopped or unreachable space keeps the sessions it last listed, and the client learns from the mark that they are old. An empty answer is an answer, and empties the space's list. A host list when no space exists goes back as the very same object, untouched. The order is the place's list, so the merged list reads the same from one call to the next.
|
|
486
|
+
|
|
487
|
+
**What a space may claim.** Every record of a space is untrusted. A record is dropped, and the drop logged as a code with the space id and never with the record, when it is not an object with an `id`, when its `location.directory` is missing or lies outside `/spaces/<id>`, another space's directory included, when its id is one of the host's, or when another space already listed that id; a duplicate within one list keeps the first. What is kept is passed through the proxy's own field allowlist, so a space cannot add a field the host's list would not carry. The host's ids are learned from every host list that passes through the proxy and from every `session.created` event of the host's, the last fifty thousand of them. The rule is one-way on purpose: the host's id always wins, and a space that lists a host id loses that record, whatever it says about it. Which of two spaces keeps an id they both list is whichever answered first; neither is the host, so nothing of the user's is at stake, and a real collision of two random ids does not happen. Once a space holds an id, listed or created, no other space speaks for it: any event of another space that names that session is dropped as `space_session_claimed`.
|
|
488
|
+
|
|
489
|
+
**Events.** `space-events.js` opens one event connection per space, over the dispatcher's channel to `/api/event` inside, with the reusable upstream reader and a state of its own: its own `Last-Event-ID`, its own stall timer, and a backoff of its own that doubles from one second to a minute and resets when a connection is made. Every event passes the same rules as the list first: a directory on the event or on a created or moved session outside the space's root drops it, and so does a session id of the host's; an execution event carries no location and passes on its session id alone, as DESIGN.md records. What passes enters the host's global hub through `injectEvent`, marked with the space's id and with the space's directory as its directory: it is numbered, coalesced, replayed and fanned out with the host's events, so a client keeps one cursor for everything. Only subscribers that asked for space events see them, `subscribeEvent(fn, { spaces: true })`: the global WS bridge, so browsers get them; the OpenCode watcher, so live status, unread marks and notifications work for a space's sessions; and the SSE proxy for `/api/global/event`, which writes each one as a block of its own between the upstream's blocks, so a block of the host's is never cut; a stream scoped to one directory, by query or by header, stays the host's. Every other consumer of the hub, the session assist, the goal runtime, the message queue and the permission auto-accept among them, sees the host's events only, because each of them acts on the host's OpenCode by directory and must never act on a space's. When a connection is made the host asks the space for `/api/sessions/status` and enters each busy or idle session as a `session.status` event the same way, so a session that was busy while OpenChamber was closed shows busy from the first moment; and the connection itself is announced to the clients as `openchamber:space-stream` with `connected` or `disconnected`, so a client can re-read that one space after a gap. The host follows its list of spaces every fifteen seconds while the switch is on, so a new space gets its connection and a removed one loses it, with everything the host knew of it.
|
|
490
|
+
|
|
491
|
+
### Known limits
|
|
492
|
+
|
|
493
|
+
- A request with a body that meets a refused session is answered with `space_session_expired` once; the client has to send it again. With the renewal at ninety percent of the cookie's life this needs the server inside to have lost its sessions, which a restart of that server does not do, since its signing secret is on its disk.
|
|
494
|
+
- The list of spaces is a two-second cache over `docker ps`, so a space removed a moment ago can still be addressed for up to two seconds and is then refused by `connect`.
|
|
495
|
+
- The host reads the token once per process. A token file the agent rewrote is noticed when a login with the remembered token is refused, or when the server inside answers 401 to a forwarded request: the token is then read again once and the login repeated once; a token the agent keeps changing costs one extra read per refused login and never more.
|
|
496
|
+
- Nothing rotates the token, see "Token".
|
|
497
|
+
- One `docker exec` per stream in the agent's pool, at most eight per space, and each costs a process on the host and a `node` inside the space while it is open.
|
|
498
|
+
- The switch is read at start only. A host that cannot be made with the switch on, because the data directory cannot be written, starts without the feature and logs why.
|
|
499
|
+
- The dispatcher's connections are closed at the server's graceful shutdown; a server killed outright leaves its `docker exec` children to end on their pipes.
|
|
500
|
+
- The host's ids are what passed through this process: the lists it served and the sessions it saw created. A host session of a project not read since the server started, an archived one among them, is unknown to the rule, so a space can list it or report for it until the host lists it; from then on the space's record is dropped. A space cannot touch a session the host knows, and the host's own record of a session is never changed by a space's.
|
|
501
|
+
- A space's list and events are read whenever the switch is on, one `docker ps` every fifteen seconds and one event connection per space, whether or not a client is looking. That is the cost of live status for sessions nobody has opened yet.
|
|
502
|
+
- The relay path of the new sockets, a phone reaching a space's terminal through the private relay, has not been exercised on a device: the three allowlists carry the shapes and the unit tests prove each list, and nothing more.
|
|
503
|
+
- A space's event connection that is still being made cannot be aborted: the host's close waits for the server inside to answer or the stream to die. One request per space at most.
|
|
504
|
+
- The mark names a space's project only while that project is registered on this host under the very path the space was made for: the label holds a hash of that path, and the host compares it with its registered projects' paths as they are. A project removed or re-added under another spelling leaves the space with no project, and the sidebar shows nothing of it until the spaces page of a later stage lists every space.
|
|
505
|
+
- The empty-cwd terminal listing that the sidebar's action badges read is the host's alone; a space's terminals are listed per directory, through the prefix, when the terminal panel of that directory asks.
|
|
506
|
+
|
|
507
|
+
## The journey
|
|
508
|
+
|
|
509
|
+
The design is in DESIGN.md, "User journey". Stage 5a built the host's side of it, `journey.js` behind `routes.js`; the screens that call it are the later parts of stage 5, and grants, with the obligations STAGES.md ties to the first real credential behind the window, are stage 5b. Everything here is written once on top of the manager, the gatekeeper channel, code in and code out.
|
|
510
|
+
|
|
511
|
+
### Routes
|
|
512
|
+
|
|
513
|
+
All under `/api/openchamber/spaces`, a namespace of the host's beside `/api/openchamber/tunnel`, on the JSON-body allowlist of `core-routes.js`. A request to the server inside a space is `/api/spaces/<id>/...` and belongs to the dispatcher; nothing here has that shape. While the switch is off every route but the switch answers 404 with `isolated_spaces_off`.
|
|
514
|
+
|
|
515
|
+
| Route | What it does |
|
|
516
|
+
|---|---|
|
|
517
|
+
| `GET /switch`, `PUT /switch` | The switch, see "The switch" above. |
|
|
518
|
+
| `GET /places` | Every place asked `check()` now, because the user is looking: `{ places: [{ id, available, version, os, arch, hostIsolation }] }`, or `{ id, available: false, code, message }`. |
|
|
519
|
+
| `GET /` | `{ spaces }`: the place's list with what the host remembers about each, the creations under way in their place, and the failed ones after it. |
|
|
520
|
+
| `POST /` | `{ projectDirectory, name, start, network }`. Answers 202 at once with the entry the list carries; the steps follow as events. |
|
|
521
|
+
| `POST /<id>/start`, `POST /<id>/stop` | The place's start and stop; start says the network again. Answers the listed entry, start with `networkRestored`. |
|
|
522
|
+
| `DELETE /<id>` | Removes the containers, networks and volumes, the service refs in the user's repository and the record; forgets a failed creation. `{ id, removed, refsRemoved, failures }`. |
|
|
523
|
+
| `POST /<id>/grants` | Since 5b: `{ kind: 'model', provider, upstream, secret: { kind: 'typed', value } \| { kind: 'env', name } }` or `{ kind: 'domain', upstream }`. Answers `{ grant }`, the grant as the list carries it, with its window URL and never a value. See "Grants". |
|
|
524
|
+
| `GET /<id>/journal` | The gatekeeper's journal, `{ records, dropped, since }`; 409 `space_not_running` for a stopped space, because the journal is gone with it. |
|
|
525
|
+
| `GET /<id>/apply` | Brings the work out and describes what an apply would do, writing nothing of the user's: `bringCodeOut`'s answer and `describeApplyState`'s in one object. |
|
|
526
|
+
| `POST /<id>/apply` | `{ as: 'branch', branch, removeAfterwards }` or `{ as: 'changes', removeAfterwards }`. Brings the work out once more and applies it; what is applied is what this call fetched. `{ brought, applied, removal }`, `removal` null unless the space was removed. |
|
|
527
|
+
|
|
528
|
+
A failure is one JSON answer, `{ code, message, details }`, with the status `routes.js` gives that code: 400 for a bad request, 404 for a space or place that does not exist, 409 for a refusal that the user resolves, `space_preparing`, `space_not_running`, `branch_exists`, every refusal of an apply and `place_cannot_restrict_network` among them, and 502 for a failure of the place or the space; anything that is not a `SpaceError` is 500 with `space_journey_failed`. No stack travels.
|
|
529
|
+
|
|
530
|
+
### Create
|
|
531
|
+
|
|
532
|
+
`POST /` answers as soon as the space has an id, and the rest runs in the background, because a first create pulls the image, fills the tools volume and pushes the code, which can take minutes. The steps, each announced on the host's hub as `openchamber:space-progress` with `{ spaceId, step, failure, timestamp }` in `properties`, the way `openchamber:space-stream` is announced: `checking_place`, the place's `check()`, refused with the place's own code when it is unavailable and with `place_cannot_restrict_network` when an allowlist was asked of an engine without host isolation; `creating`, the manager's create, which is the image, the tools volume, both networks, the gatekeeper and the space container, verified; `setting_network`, the record written and the mode and allowlist said to the gatekeeper, which allowed nothing until then; `bringing_code`, the snapshot and the transfer of code in, with the space path and the base kept in the record; `ready`. The history goes after `ready`, in the background, and its outcome is kept in the record as `history`: `sent`, `already_complete`, `host_shallow` or `failed`, `pending` until then. A start of a space whose history is still `pending` sends it again.
|
|
533
|
+
|
|
534
|
+
A failure after `creating` removes what was made: the containers, the refs in the user's repository and the record, and the answer of the failed step is kept in the list as `state: 'failed'` with `failure: { code, message, details, cleanup }` until the user dismisses it with `DELETE /<id>`, so an error is never silent; when that clean-up itself failed, `cleanup` says so and the dismissal removes what is still there, so the space cannot come back in the list as damaged after the user was told it was gone. A creation under way is listed as `preparing` with its `step`, over the place's view of it, and refuses start, stop, remove and apply with `space_preparing` until it is ready or failed; a failed one refuses them with `space_creation_failed`. One action runs per space at a time: start, stop, remove, the journal, the preview and the apply take a lock on the space's id and a second action meets `space_busy`, so a remove from another client cannot pull the service refs from under an apply that is writing them. A start of a space whose history is `pending` or `failed` sends it again, because a stop right after the creation is the usual way it fails. The project must be one the host has registered, compared as the label's hash and the path, because the sidebar resolves a space to its project the same way. The four choices are parsed at the boundary, each field refusing with a code of its own: `project_not_registered`, `invalid_space_name`, `invalid_snapshot_mode`, `invalid_network`.
|
|
535
|
+
|
|
536
|
+
### The record
|
|
537
|
+
|
|
538
|
+
The labels stay the source of truth about which spaces exist. What the host adds is one file per space under `<data dir>/spaces/records/<id>.json`, mode 0600, written through a temporary name: `{ version, network: { mode, domains }, repository, spacePath, base, history, grants }`. It exists so that a gatekeeper that starts with an empty memory is told the network the user chose, and so that the history and the result find the repository and the space path that code in used, which differ from the project directory when the project is a subfolder of its repository. A record the host cannot read is reported as unreadable, never as a fresh one, and is not written over: `start` then leaves the network closed and answers `networkRestored: false`, and the list carries `network: null`, which a screen shows as unknown and never as open. No secret is in a record; that is decision 5. Since 5b `grants` holds each grant without its value: `{ kind: 'model', id, provider, upstream, header, source }` with `source` `{ kind: 'typed' }` or `{ kind: 'env', name }`, or `{ kind: 'domain', id, upstream }`. The schema is strict, so a value under any name is refused rather than written, and a record from before 5b reads back with no grants.
|
|
539
|
+
|
|
540
|
+
### Grants
|
|
541
|
+
|
|
542
|
+
DESIGN.md, "Words" and "Gatekeeper", and decisions 4, 5 and 13. Stage 5b takes two kinds, decided with the maintainer on 2026-09-26: a model API key for a provider, a "uses without seeing" grant, and an opened domain with no credential, for a private registry or a mirror that the corridor would refuse by address. Git over https and npm tokens wait for the stage that verifies npm through the window end to end.
|
|
543
|
+
|
|
544
|
+
- A model grant names the provider as the host's catalog does, `anthropic`, `openai` and so on, and its id is the provider id, so a second grant for the same provider replaces the first: that is how a key is changed, and a grant is never taken back. The upstream is the provider's API, which the client takes from the host's catalog. The header is decided here by provider, `x-api-key` for Anthropic, `x-goog-api-key` for Google, a bearer token for the rest. The key comes typed once, or as the name of an environment variable of the host's, read from the host's own environment now; OpenChamber remembers the name and never the value, and a variable that is not set is refused with `secret_source_missing`.
|
|
545
|
+
- An opened domain gets a random id, `open-<12 hex>`, and its window URL, `http://gatekeeper:8080/model/<id>`, which the answer carries so a screen can show it. Nothing inside is configured for it in this stage.
|
|
546
|
+
- The key goes to the gatekeeper first, then the grant without its value goes to the record, and for a model grant OpenCode inside is told: `space-opencode.js` writes the whole global configuration, `$HOME/.config/opencode/opencode.json`, from the record's model grants, each provider with `options.baseURL` at its window URL and a placeholder `apiKey`, so OpenCode's own auth plugins stay out of the way and the window throws the placeholder away. That file cooperates and enforces nothing: the agent can change it, and the gatekeeper is what keeps the key out. The catalog a space offers is the host's, so the model chosen in the composer works inside unchanged; that was the second decision of 2026-09-26. A grant the gatekeeper refused is not remembered. A grant goes to a running space only, `space_not_running` otherwise, and to one whose record can be read, `space_record_unreadable` otherwise, because a grant that cannot be remembered would be gone at the next start without a word. The configuration is the whole file, so anything the agent wrote into its global `opencode.json` is gone at the next model grant or start; a project's own `opencode.json` is not touched.
|
|
547
|
+
- The providers a model grant takes are the ones whose header this module knows: `anthropic` and `google` with their own headers, and `openai`, `openrouter`, `groq`, `mistral`, `deepseek` and `xai` with a bearer token. Any other is refused with `provider_not_supported` rather than accepted and failing every turn with a 401 upstream; Azure, Bedrock and Vertex read their key another way or sign requests. The grant dialog decides how the list grows.
|
|
548
|
+
- After every start the journey says the record's grants again, after the network: an environment key is read again and delivered, an opened domain is delivered, and a typed key cannot be, because it was never kept. The provider configuration inside is written again from the record too, so a write that failed at the grant is repaired by the next start; a write that fails at the start is logged and the start goes on. A grant the host cannot read in the record, from a later version, is dropped with a warning and the rest of the record stays readable. The start answers `grantsRestored` and `needsAccess` with the ids. A gatekeeper that refuses one at the start leaves the space running and that grant in `needsAccess`, logged; the alternative, a start that fails with the space up and the network said, would be worse.
|
|
549
|
+
- The list, with `access`, asks each running space that has grants what its gatekeeper holds, `GET /policy`, one request per such space and none for a space without grants or one that is stopped, and says `access: 'granted'`, `'needs_access'` with the missing ids in `needsAccess`, or `'unknown'` when the gatekeeper could not be asked, never "granted" on a failed read. While an action holds the space, a start saying its grants again among them, `access` is null rather than a "needs access" that is about to be untrue. `GET /` asks with `access`; the other routes answer the list without it, `access: null`. This is the "needs access" of DESIGN.md after a machine restart, read from the gatekeeper rather than from what this process remembers, so a host that restarted shows the truth.
|
|
550
|
+
|
|
551
|
+
- The list route runs one `docker ps` and one record read per space on every call; there is no cache, because the user is looking and the answer must be the state now.
|
|
552
|
+
- A creation is one process's memory: a host that restarts in the middle of one leaves the space where the step got to, listed from the labels with whatever the record holds; a space whose code never arrived has an empty `spacePath` in its record and its apply fails on the project path. Removing such a space is the way out.
|
|
553
|
+
- The history transfer is not retried on its own; a start of the space retries it while the record says `pending` or `failed`.
|
|
554
|
+
- The lock on a space is one process's memory, like code out's turns: two OpenChamber processes on one Docker daemon are not coordinated.
|
|
555
|
+
- Nothing of the setup commands, the chat archive or the idle stop is here; the later parts of stage 5 add each.
|
|
556
|
+
- Whether OpenCode inside takes a provider granted after the instance of the space's project started, without a restart, is what the live file of 5b measures; see the pull request of 5b for the outcome. The restart action of the repair stage is the lever if it does not.
|
|
557
|
+
- A grant for a provider the host's catalog does not know is written inside all the same; OpenCode inside then has no `npm` package for it and the turn fails with a provider error. The grant dialog offers the host's catalog, so this is a request nothing in the app makes.
|
|
410
558
|
|
|
411
559
|
## Code in
|
|
412
560
|
|
|
@@ -448,13 +596,13 @@ What each function rejects with, read from the code:
|
|
|
448
596
|
|
|
449
597
|
Every git command the host runs goes through `createHostGit` in `host-git.js`: `run-command.js`, an argv, no shell, and a git path the caller can inject. The environment is the user's own, so their global config applies, and that is on purpose: their global ignore file must keep its files out of a space. A snapshot run without the global config would carry every file they keep out that way. What is removed is anything inherited that points git somewhere else: `GIT_DIR`, `GIT_WORK_TREE`, `GIT_INDEX_FILE`, `GIT_OBJECT_DIRECTORY`, `GIT_ALTERNATE_OBJECT_DIRECTORIES`, `GIT_NAMESPACE`, `GIT_COMMON_DIR`, the shallow, graft and replace variables, `GIT_CONFIG_PARAMETERS` and the `GIT_CONFIG_COUNT` family, the default hash and ref format, `GIT_ALLOW_PROTOCOL`, and the author and committer variables. On Windows the names are compared without case, as the system does. Two are added: `GIT_OPTIONAL_LOCKS=0`, so a status read never rewrites the index, and `GIT_TERMINAL_PROMPT=0`.
|
|
450
598
|
|
|
451
|
-
It is a denylist: everything it does not name passes, so `GIT_TEMPLATE_DIR`, `GIT_SSH_COMMAND` and the like reach our git, and no code-in path is affected by them. It is also the environment of the `docker exec` that git starts through `ext::`, so the Docker settings, `DOCKER_HOST`, `DOCKER_CONTEXT`, `DOCKER_CONFIG`, `DOCKER_TLS_VERIFY`, `DOCKER_CERT_PATH`, and `SSH_AUTH_SOCK` for a Docker over SSH, must pass, and a test holds them there. The one `init` on the host runs with `--template=`, which wins over `GIT_TEMPLATE_DIR`, and nothing uses ssh. `GIT_LITERAL_PATHSPECS`, `GIT_GLOB_PATHSPECS`, `GIT_NOGLOB_PATHSPECS` and `GIT_ICASE_PATHSPECS` are removed, because they change how every pathspec is read: with `GIT_LITERAL_PATHSPECS=1`, `:(exclude,literal)<folder>` would be a path, `add` would fail for any repository with an untracked nested repository, and `:(attr:filter=lfs)` would match nothing. The `GIT_TRACE` family, `GIT_TRACE2` included, is removed: it writes to stderr, which a push counts against its 1 MB output cap, so an inherited `GIT_TRACE_PACKET` would have turned every push into `command_output_too_large`.
|
|
599
|
+
It is a denylist: everything it does not name passes, so `GIT_TEMPLATE_DIR`, `GIT_SSH_COMMAND` and the like reach our git, and no code-in path is affected by them. It is also the environment of the `docker exec` that git starts through `ext::`, so the Docker settings, `DOCKER_HOST`, `DOCKER_CONTEXT`, `DOCKER_CONFIG`, `DOCKER_TLS_VERIFY`, `DOCKER_CERT_PATH`, and `SSH_AUTH_SOCK` for a Docker over SSH, must pass, and a test holds them there. The one `init` on the host runs with `--template=`, which wins over `GIT_TEMPLATE_DIR`, and nothing uses ssh. `GIT_LITERAL_PATHSPECS`, `GIT_GLOB_PATHSPECS`, `GIT_NOGLOB_PATHSPECS` and `GIT_ICASE_PATHSPECS` are removed, because they change how every pathspec is read: with `GIT_LITERAL_PATHSPECS=1`, `:(exclude,literal)<folder>` would be a path, `add` would fail for any repository with an untracked nested repository, and `:(attr:filter=lfs)` would match nothing. `GIT_ATTR_SOURCE` is removed too: it makes git read every attribute from a tree instead of the working tree, and code out reads the working tree back with exactly the attributes it holds. The `GIT_TRACE` family, `GIT_TRACE2` included, is removed: it writes to stderr, which a push counts against its 1 MB output cap, so an inherited `GIT_TRACE_PACKET` would have turned every push into `command_output_too_large`.
|
|
452
600
|
|
|
453
601
|
Every host git call of code in also gets `-c core.hooksPath=<an empty folder of ours>` and `-c core.fsmonitor=` (empty), so it starts nothing of the user's: not in the snapshot, the ref writes, the sender's `init`, the pushes, the history, or the two lists. Measured with git 2.50.1 before these: the user's `post-index-change` hook ran three times per snapshot with `GIT_INDEX_FILE` pointing at our copy, their `reference-transaction` hook ran on every write of our ref, a `core.fsmonitor` program ran ten times per snapshot, and `core.fsmonitor=true` left a `git fsmonitor--daemon` running on the host and its files and socket in the user's `.git`. The value is empty and not `false`, because before git 2.36 `core.fsmonitor` is a command path, where `false` would name a program; empty means "none" in both readings, measured on 2.50.1 for a program and for `true`. The tests give the user a global `core.hooksPath` with thirteen hooks, a `core.fsmonitor` program and `core.fsmonitor=true` (not on Linux, whose git has no daemon), each with a control that shows it running on an ordinary command, and require nothing of it after a whole code in, the history, both lists and the ref removal: no marker, no running daemon, no new file.
|
|
454
602
|
|
|
455
603
|
Other config that starts a program was checked by reading git's documentation, not by trying each: auto gc and maintenance run after commit, merge, fetch, am and rebase, and none of our commands; the test gives the user `gc.auto=1`, `gc.autoPackLimit=1` and `maintenance.auto=true` on a repository with two packs, shows an ordinary commit consolidating them, and requires both packs untouched after code in. Clean filters, Git LFS among them, do run during `add`, on purpose, as the user's own `git add` would. Credential helpers, `core.sshCommand` and pagers are not reached: nothing authenticates, nothing uses ssh, and nothing runs on a terminal.
|
|
456
604
|
|
|
457
|
-
Before anything else, code in reads `git version` and refuses a git older than 2.
|
|
605
|
+
Before anything else, code in reads `git version` and refuses a git older than 2.29.0 with `git_too_old`, naming the version it needs, or with `git_version_unreadable`. Code out does the same with the same floor, which is the one floor of the feature. The floor comes from git's manual pages, not from a run: the suites ran with git 2.47.3 on Debian 13, 2.50.1 on macOS and 2.54.0 on Windows, so a git from 2.29.0 that is older than 2.47.3 is accepted but untested. It was 2.27.0 in 3a and rose to 2.29.0 in 3b: `fetch --no-write-fetch-head` first appears in the git-fetch manual of 2.29.0, and the 2.28.0 page does not have it; without it the promote would write `FETCH_HEAD` into the user's `.git`. Code out's other options are older, checked in the manuals of 2.29.0: `--update-shallow`, `--refmap`, `--no-recurse-submodules`, `--no-tags` and `--no-auto-gc` in git-fetch (the 2.28.0 page has all five); `--output`, `--binary`, `--full-index`, `--no-renames`, `--no-ext-diff`, `--no-textconv`, `--no-color`, `--src-prefix` and `--dst-prefix` in git-diff-tree; `--check`, `--binary` and `--whitespace=nowarn` in git-apply, whose configuration section names exactly two settings, `apply.ignoreWhitespace` and `apply.whitespace`; `--objects` and `--no-object-names` in git-rev-list. For code in the floor comes from git's own manual pages per version on git-scm.com: `git init --object-format` first appears in the git-init manual of 2.27.0 (the 2.26 page is the 2.21 text); `rev-parse --show-object-format` and `add --pathspec-from-file` with `--pathspec-file-nul` are in the 2.25.0 manuals. The rest is older than 2.27.0: `--is-shallow-repository`, `--git-path`, `--show-prefix` in the git-rev-parse manual of 2.27.0; `-C`, `GIT_OPTIONAL_LOCKS` and `GIT_TERMINAL_PROMPT` in git's own manual of 2.27.0; `core.hooksPath` and the `post-index-change` hook in githooks of 2.27.0; `--no-verify`, `--no-signed`, `--follow-tags`, `--no-recurse-submodules`, push options and `--quiet` in the git-push manual of 2.25.0; the `literal`, `exclude` and `attr` pathspec magic in gitglossary of 2.23.0. Two behaviours were measured on 2.50.1 only and not checked on older versions: that an empty `push.pushOption` clears the list, and that an empty `core.fsmonitor` turns it off. The parser reads the first three numbers and ignores a build's suffix, such as `2.50.1 (Apple Git-155)` or `2.54.0.windows.1`.
|
|
458
606
|
|
|
459
607
|
### The snapshot
|
|
460
608
|
|
|
@@ -555,6 +703,189 @@ The live test plants the hanging hook above and checks that no host process name
|
|
|
555
703
|
- The project folder name is the top level's base name with every run of characters outside letters, digits, `.`, `_` and `-` turned into one `-`, no leading dot or dash, at most 64 characters, and `project` when nothing readable is left or the name is `node_modules`, where the plugin link lives.
|
|
556
704
|
- Not verified: a partial clone, a sparse checkout, a repository over 1 GB, a slow link, and a newer git inside.
|
|
557
705
|
|
|
706
|
+
## Code out
|
|
707
|
+
|
|
708
|
+
The space's work travels back as git objects over the same exec channel, this time `git fetch` over `ext::`, and is applied to the user's repository in one of two ways. Everything that comes out of a space is untrusted data. The command sequences start from `docs/isolated-spaces/stage-0/e4-git-over-exec.md` and carry the changes below, most of them from the maintainer's probe of 2026-09-22 on macOS with Colima, on Debian 13 over `DOCKER_HOST=ssh://`, and on Windows 11 with Docker Desktop. Nothing calls this yet.
|
|
709
|
+
|
|
710
|
+
`createCodeOut({ git, place, temporaryDirectory, removeDirectory, platform, readBackTimeoutMs, files })` in `code-out.js` returns four functions. `platform` is `process.platform` unless a test says otherwise, and decides which file names this computer can hold. `readBackTimeoutMs`, five minutes unless a test says otherwise, bounds the time one apply, or one `describeApplyState`, spends reading the working tree back; see "Apply". `files` is how the working tree is read back, `node:fs/promises` unless a test counts what is read. It shares its host git session, the version floor and the `ext::` URL builder with code in, through `code-transfer.js`.
|
|
711
|
+
|
|
712
|
+
| Function | What it does |
|
|
713
|
+
|---|---|
|
|
714
|
+
| `bringCodeOut({ repository, spaceId, spacePath, timeoutMs, innerMarginSeconds, maxTransferBytes, maxChangedBytes, maxChangedEntries })` | The snapshot inside, the fetch into a quarantine repository, the checks there, and the promote into `refs/openchamber/spaces/<id>/result`. Resolves `{ result, changedPaths, changedBytes, nestedRepositories, unmerged }`: the result commit, how many paths it changes against the start, how many bytes those paths hold in the result, which is what applying writes, and the two things the caller has to warn about, each `{ count, paths }` with at most a hundred paths. `nestedRepositories` are repositories the agent made inside its project, which travel as a gitlink and nothing else; `unmerged` are the paths the agent left in a conflicted merge, whose markers are in the result as ordinary content. |
|
|
715
|
+
| `applyAsBranch({ repository, spaceId, branch })` | Creates `refs/heads/<branch>` at the result. Nothing is checked out. Resolves `{ branch, commit }`. |
|
|
716
|
+
| `describeApplyState({ repository, spaceId })` | Reads where the space stands for an apply as uncommitted changes and changes nothing: `{ changesRoute, result, lastApplied, newPaths, newPathsOverLimit, newPathsUndecided, interruptedApply }`. `changesRoute` is `open` or `closed`, `newPaths` how many paths an apply would write now, or null before any bring-out, and `interruptedApply` whether an earlier apply was cut off, which the next apply sorts out first; an intent beside a closed route is not reported, because nothing applies as changes there any more. When the working tree shows that the interrupted apply finished, `newPaths` counts from what it wrote, as the next apply will. When the user threw applies away, it counts from where the next apply will start, decided the same way, and when the user threw away part of the last apply, which the apply refuses, from the last apply. When reading the working tree back does not end in time, `newPaths` is null and `newPathsUndecided` true, and the apply answers `changes_undecided`. When the list of those paths is longer than the host reads, 64 MiB, `newPaths` is null and `newPathsOverLimit` true: an apply of that many is refused as too large, and the branch is the way. It takes its turn with the applies of the same space, see "Apply". It is the one way to learn whether the route is closed without applying. It reads the working tree back as an apply does and costs what that costs, see "Apply": ask it when the user is about to apply, and never poll it. On a closed route it reads nothing back. |
|
|
717
|
+
| `applyAsChanges({ repository, spaceId, maxChangedBytes, maxChangedEntries })` | Applies to the working tree, as uncommitted changes, what the space changed since the last apply of this space, or since its start when there was none. When the user threw the last apply away, the whole work since before the last apply, see "Apply". Once a round of this was refused, the route is closed for that space and every later call refuses at once. Resolves `{ status: 'applied', appliedPaths, remembered, nestedRepositories, conflicted }` or `{ status: 'nothing_to_apply' }`. |
|
|
718
|
+
|
|
719
|
+
What the caller must do:
|
|
720
|
+
|
|
721
|
+
- Hand `bringCodeOut` the `spacePath` that `bringCodeIn` returned. It is checked with `requireSpaceProjectPath`, as `sendHistory` checks it, and never worked out again from the repository.
|
|
722
|
+
- Call `bringCodeOut` before either apply, and again whenever the user wants the newer state of the space: a later call moves the result ref. One `bringCodeOut` per space at a time; two at once are not coordinated. Applies as changes and `describeApplyState` take turns by themselves, within one process.
|
|
723
|
+
- Keep `changedPaths` of `bringCodeOut` and `appliedPaths` of `applyAsChanges` apart in whatever the user reads. They count different things: the first the paths the result changes against the space's start, the second the paths one apply wrote, which after a first apply is only what is new.
|
|
724
|
+
- Ask `describeApplyState` before offering an apply as uncommitted changes, and show its `newPaths`: after rounds were applied, that is what the next apply writes, which `changedPaths` of `bringCodeOut` is not.
|
|
725
|
+
- Offer the branch, and only the branch, once `applyAsChanges` has refused once for a space: `changes_route_closed` says the same thing every later time, and its message is written for the user to read as it is.
|
|
726
|
+
- Warn about `nestedRepositories` and `unmerged` before the user applies. Both are real work of the agent that does not arrive whole, and nothing else says so.
|
|
727
|
+
- Show `changedPaths` and `changedBytes` in the apply dialog. When `applyAsChanges` rejects with `changes_do_not_apply`, offer the branch: its message says that nothing was changed.
|
|
728
|
+
- Call `removeSpaceRefs` of code in when the space is removed. It deletes every ref under `refs/openchamber/spaces/<id>/`, so the result ref, the applied ref and what it was built from, the intent and the closed ref go with the start ref, and a test holds that.
|
|
729
|
+
|
|
730
|
+
What each function rejects with, read from the code:
|
|
731
|
+
|
|
732
|
+
| Function | Codes |
|
|
733
|
+
|---|---|
|
|
734
|
+
| `bringCodeOut` | The refusals, before anything reaches the space: `invalid_space_id`, `invalid_space_path`, `invalid_timeout`, `invalid_inner_margin`, `invalid_limit`, `git_version_unreadable`, `git_too_old`, `project_folder_missing`, `not_a_git_work_tree`, `space_start_missing` for a repository that holds no start of this space. Then `result_ref_missing` when a fetch exited 0 and the ref is not there, `result_not_a_commit`, `result_transfer_too_large`, `result_too_large` and `result_too_many_changes`, each with `details.step`. Everything else is `code_out_failed` with `details.step` and `details.cause`, the cause being the place's or the runner's code, `inside_command_failed` for the snapshot inside, or null for a fetch git refused, whose message carries at most 2,000 characters of what git and the space printed. The steps are `reach the space`, `snapshot the space`, `fetch into the quarantine`, `measure the result` and `promote the result`. |
|
|
735
|
+
| `applyAsBranch` | `invalid_space_id`, `invalid_branch_name`, `result_missing`, `branch_exists` for a branch of that name and for one that git cannot keep beside it, such as `feature/x` beside `feature` or `topic` beside `topic/one`, with `details.branch` naming the one in the way, `git_command_failed`, the refusals of the repository and the git version above, a runner code, `code_out_failed`. |
|
|
736
|
+
| `applyAsChanges` | `invalid_space_id`, `invalid_limit`, `space_start_missing`, `result_missing`, `changes_route_closed`, `changes_too_large`, `name_not_allowed_here`, `case_only_rename`, `changes_blocked_by_link`, `patch_not_possible`, `changes_do_not_apply`, `changes_partly_applied`, `git_command_failed`, the same refusals, a runner code, `code_out_failed`. |
|
|
737
|
+
|
|
738
|
+
### The snapshot inside
|
|
739
|
+
|
|
740
|
+
A fixed script under the image's `timeout -s KILL`, every value a positional argument, with the image-only PATH: it copies the space repository's index, runs `add --all` into the copy with `core.splitIndex=false`, writes the tree, and when that tree differs from `HEAD^{tree}` makes one commit on top of HEAD with the fixed identity `OpenChamber <spaces@openchamber.invalid>` and the message `openchamber: uncommitted changes from the space`. When nothing is uncommitted, the result is HEAD itself. It writes `refs/openchamber/result` inside.
|
|
741
|
+
|
|
742
|
+
Then it reports two things on a channel of its own, file descriptor 3, while everything it runs writes to `/dev/null`: the id of the commit it made, and the unmerged entries of the space's index, read before `add --all` stages their conflicted content and cut at 256 KiB, so a space cannot fail a code out by printing without end. The id is what the fetch then requires. A space can move its own `refs/openchamber/result` between the snapshot and the fetch — a reviewer did it live with a loop inside, and the host delivered one commit while warning about another — so the fetch takes that one commit and nothing else. Everything the script runs writes to `/dev/null` and runs with descriptor 3 closed; only the two commands at the end write the report. That keeps what git and the agent's hooks print out of the report, and a hook from writing into it through what it inherits: its parent git's stdout through `/proc/$PPID/fd/1`, or descriptor 3 itself. A live test does the first from inside a real space, and a unit test the second in the stand-in, each red with its part removed. It does not make the channel private, and nothing here claims so. On Linux a process of the agent's can still reach it: while the block runs, the shell keeps a copy of descriptor 3 to restore afterwards, `dash` on descriptor 10, and `/proc/<pid>/fd/10` opens it for the same user. Checked on 2026-09-24 in the base image, where a child wrote a line through its parent shell's `/proc/$PPID/fd/10` and the line came out on the report's channel. So the report is data from the space, like everything else from it: the id only names the one object the fetch must bring, which is then checked like any result, and a report the agent wrote itself can name another commit of its own, which the agent could as well have made its result, and paths that do not belong to it. The paths are shown, never acted on. The agent's working tree, index and branch stay as they were, and a test compares the status inside before and after. A repository with no commit at HEAD is refused inside, as `code_out_failed` with the cause `inside_command_failed`. The limit inside is five minutes plus the margin.
|
|
743
|
+
|
|
744
|
+
The space is the agent's machine, so its own config and hooks run during this script: that is inside, and it changes nothing about what the host checks afterwards. The live test plants a `reference-transaction` hook there and sees it run inside, and nothing on the host.
|
|
745
|
+
|
|
746
|
+
### The quarantine
|
|
747
|
+
|
|
748
|
+
On the host, a throwaway bare repository in the temporary folder, made with `--template=`, with `objects/info/alternates` pointing at the user's object store, as the sender of code in. It reads the user's objects and copies none, so the space sends only what the user does not have. Then:
|
|
749
|
+
|
|
750
|
+
```
|
|
751
|
+
git -c protocol.ext.allow=always -c fetch.fsckObjects=true -c transfer.fsckObjects=true
|
|
752
|
+
-c fetch.prune=false -c fetch.writeCommitGraph=false -c fetch.unpackLimit=1 -c transfer.unpackLimit=1
|
|
753
|
+
fetch --quiet --no-tags --no-recurse-submodules --no-write-fetch-head --no-auto-gc --refmap= --update-shallow
|
|
754
|
+
ext::<execArgv> /usr/bin/timeout -s KILL <seconds> /usr/bin/git upload-pack <spacePath>
|
|
755
|
+
+refs/openchamber/result:refs/openchamber/result
|
|
756
|
+
```
|
|
757
|
+
|
|
758
|
+
- `--update-shallow` is on this fetch only. Measured by the maintainer's probe: while the space's repository is still shallow, before `sendHistory` finished or after it failed, which code in treats as a warning, a fetch without it printed `rejected ... because shallow roots are not allowed to be updated` and exited 0 without writing the ref.
|
|
759
|
+
- After the fetch the host reads `refs/openchamber/result` in the quarantine itself, and a missing ref is `result_ref_missing`. It checks that the ref names a commit. Nothing the space printed is read.
|
|
760
|
+
- The object checks refused every hostile tree name the probe tried: `.git`, `.GIT`, `.Git`, `git~1`, `.git ` with a trailing space, `.git.`, `..`, `.`, `a/../../x` and `.git/hooks/x`. The unit test and the live test try them again, built inside with `hash-object -t tree --literally`, and require the refusal at the quarantine, not later.
|
|
761
|
+
- The unpack limit of 1 keeps what arrives as a pack that grows while it arrives, so the size cap below can see it. Below the default limit of 100 objects git would write loose objects, each only once it is whole. Measured with git 2.50.1: `fetch.fsckObjects` alone already makes git keep a pack; the setting does not rely on that.
|
|
762
|
+
- `--no-tags`, the explicit refspec and `--refmap=` keep every other ref and tag of the space out. The tests plant tags, an annotated tag, a branch and a ref named like another space's start, and the control shows an ordinary fetch following the tags.
|
|
763
|
+
|
|
764
|
+
The quarantine is removed on every path, a killed fetch's partial pack with it; the tests check that no temporary folder of code out is left.
|
|
765
|
+
|
|
766
|
+
### Caps and the deadline
|
|
767
|
+
|
|
768
|
+
- **The deadline.** `timeoutMs`, ten minutes by default, the same rule and range as code in, ends the fetch's whole process tree with `killTree`. The inner limit is the deadline plus `innerMarginSeconds`, so the `upload-pack` inside and what it started are gone by then. Measured by the probe with a `uploadpack.packObjectsHook` that never ends, planted in the space's global config: the tree kill left no host process on any of the three machines.
|
|
769
|
+
- **The transfer.** `maxTransferBytes`, 256 MiB by default, twice the changed-bytes cap, because a pack of the new objects is not larger than what they inflate to plus commits and trees. The host polls the size of the quarantine folder every 100 ms while the fetch runs and stops the fetch through `runCommand`'s `signal` when it passes the cap; a fetch that finished between two polls is held to the same cap afterwards. A transfer that was already over the cap when it ended between two polls is refused the same way, and a test proves that check by a result that arrives in one piece, where the space's own sender ends normally. The rejection is `result_transfer_too_large`, whatever exit code the killed tree left: on Windows a tree ended by `taskkill /T /F` exits with code 1, not a signal, measured by the probe, so the code knows it stopped the fetch because it did, not from the exit code. The overshoot is what arrives in one poll: the probe measured about 13 MB at 130 MB/s locally and about 45 KB over SSH.
|
|
770
|
+
- **What the result writes.** A cap on the transfer is not enough. The probe sent 1.5 GB of zeros as a 1.4 MB pack, and building the patch then took 1.5 GB of memory on the host. So before anything is promoted, in the quarantine, the host lists the changed paths with `diff-tree -r -z --no-renames <start> <result>`, lists the new objects of the result's history with `rev-list --objects --no-object-names <result> --not <start>`, and sizes both with `cat-file --batch-check`, which inflates nothing: the probe measured 0.02 s and 4 MB of memory for the 1.5 GB blob. Two sums come out of it. `newBytes` is everything the result's history brings that the user does not have, which is what the repository keeps: it must stay within `maxChangedBytes`, 128 MiB by default, and no single object of it that is not a file may be larger than 4 MiB. `changedBytes` is what a patch between the start and the result would read and write, both sides of every changed path, deletions included, which is what an apply costs; `bringCodeOut` reports it and `applyAsChanges` is where it is capped, because that is where it is paid. So a space that deletes a large file, or fills the project with copies of a file the user already has, brings almost nothing into the repository and can still be applied as a branch, while applying it as changes is refused with the branch as the way. Every new object counts, whatever it is, and not only files: a reviewer brought a 64 MiB commit message out under both caps while only files counted, and the object was in the user's repository for good. A commit message and a tree weigh as much as a file, repetitive text travels in a pack of a few kilobytes, and a test refuses a megabyte of commit message and a tree with forty thousand entries, neither of which changes a file. On top of the sum, one object that is not a file may hold at most 4 MiB, which is a tree of about a hundred thousand entries and a commit message no one writes: such an object is the cheapest thing to send and the most expensive to hold, and a reviewer measured 1.03 GB of host memory for a 1 GiB message and 2.94 GB for a 4 GiB one, spent by the fetch before any cap can see them. The transfer cap counts compressed bytes and says nothing about that. `newBytes` catches a file of zeros, a large file the agent committed and deleted again, which the diff never shows and a later checkout of the branch would write, and anything the agent put in a commit message or a tree. `changedBytes` catches what only a patch pays for: a deletion brings no object at all, and measured with git 2.50.1, deleting one 431 MB file cost `diff-tree` 1.09 GB and `apply` 1.67 GB, while deleting a 1.36 GB one made `diff-tree` refuse to build a diff at all. Over a cap is `result_too_large` at the bring-out and `changes_too_large` at the apply.
|
|
771
|
+
- **How many.** `maxChangedEntries`, 100,000 by default, bounds both the changed paths and the new objects, and with them every list the host reads. Over the cap is `result_too_many_changes`. A list longer than 64 MiB is refused the same way, and the message says that it was the list's length, not a count: 64 MiB holds about 60,000 paths of a kilobyte each.
|
|
772
|
+
- **Folders.** The folders the changed paths pass through are a cost that neither the paths nor the bytes show. A reviewer brought out 20,000 files 500 folders deep, which shared one chain of trees, 503 objects and 663 KB: they passed every cap, and the checks before an apply then took 4.4 GB and ran the server out of memory, every time it was tried. So `applyAsChanges` counts two more things, in the same pass over the listing as the paths, before anything else is built: the folders, each once, against a cap of the same size as the paths, `maxChangedEntries`; and the steps, how deep each of those folders and each changed path lies, against 32 times that, 3.2 million by default. The folders bound the host's memory: the name check and the link check keep one entry per folder. The steps bound its time: every look at a folder and every file `git apply` writes goes down from the top of the work tree one folder at a time. The folders alone were not enough, measured on macOS: 95,520 files in 95,520 folders 480 deep, within the folder cap, took the checks 173 s and `git apply` 148 s for its dry run and 307 s for the apply, which is half the apply's deadline. diff-tree lists the paths of one folder together, so a folder is counted when a path enters it and not again for the paths inside it that follow, holding one path at a time; a listing in another order would count a folder again, which refuses sooner and never lets more through. Over either count is `changes_too_large`, touching nothing, and the branch holds the work. `bringCodeOut` refuses neither, because the folders are what a patch pays for, like `changedBytes`, and the refusal has to be able to point at the branch.
|
|
773
|
+
|
|
774
|
+
Measured for the folder counts on macOS 26 with git 2.54.0, on APFS, with the objects packed as a fetch leaves them, wall time and peak memory of the whole call from `/usr/bin/time -l`, before and after:
|
|
775
|
+
|
|
776
|
+
| Result | Before | After |
|
|
777
|
+
|---|---|---|
|
|
778
|
+
| 20,000 files `x<i>/a/.../a/f`, 500 folders deep, one shared chain | out of memory after 21 s, 4.4 GB | `changes_too_large` in 5.7 s, 117 MB; 5.4 s of it is git listing the change |
|
|
779
|
+
| 2,000 files 450 deep, their folders on disk, a repository that compares case | applied, 157 s, 549 MB | `changes_too_large` in 0.7 s, 53 MB |
|
|
780
|
+
| 95,520 files at every level of 199 chains 480 deep, folders on disk | applied, 595 s, 462 MB | `changes_too_large` in 0.4 s |
|
|
781
|
+
| 6,123 files at every level of 13 chains 470 deep, just under the step cap | | applied in 37 s, 107 MB: checks 10 s, dry run 8 s, apply 18 s |
|
|
782
|
+
| 20,000 files in shared folders, three deep | applied in 1.9 s, 104 MB | applied in 2.0 s, 105 MB |
|
|
783
|
+
| 20,000 files, each three deep in folders of its own | applied in 5.4 s, 142 MB | applied in 5.4 s, 145 MB |
|
|
784
|
+
|
|
785
|
+
Why 128 MiB, measured on macOS with git 2.50.1 on one machine, for the worst case this cap allows: a 64 MiB text file rewritten line by line, which charges 128 MiB because both sides count, made a 137 MB patch on disk, `diff-tree -p --binary` peak at 0.41 GB of memory and `apply` at 0.54 GB. The same shape at twice the cap took 1.6 GB and 2.2 GB, and a patch of 1 GiB is refused by `git apply` itself as too large. The caps are arguments, so a caller can give a larger one to a user who asks, and `applyAsChanges` takes its own pair.
|
|
786
|
+
|
|
787
|
+
What the host can still be made to spend before any of this refuses: the fetch inflates every object it indexes, so one object costs its own inflated size in memory, and a transfer costs the cap in disk. Both are paid inside the quarantine, which is thrown away, and git offers nothing to bound a fetch while it runs.
|
|
788
|
+
|
|
789
|
+
### The promote
|
|
790
|
+
|
|
791
|
+
From the quarantine into the user's repository, with the same config and flags except the ext switch, the unpack limit and `--update-shallow`: `+refs/openchamber/result:refs/openchamber/spaces/<id>/result`, killed with its tree at the deadline. The user's repository never becomes shallow. A space that forges its `shallow` file so that the promote would need to make it shallow gets the same warning and exit 0 from git, measured by the probe; the host then reads the ref, finds it missing or not holding the quarantine's commit, and rejects with `result_ref_missing`. The unit test forges one and checks that no `shallow` file appears in the user's `.git`.
|
|
792
|
+
|
|
793
|
+
### Apply
|
|
794
|
+
|
|
795
|
+
`applyAsBranch` checks the name with `git check-ref-format --branch` and requires the output to be the name as given, because that command also expands `@{-1}`. A name that starts with `-`, holds a control character, or is not a string is refused before git sees it. An existing branch is refused with `branch_exists` and never moved: the host looks first, and `update-ref` creates the branch against the zero id, so a branch that appeared in between is not moved either. Nothing is checked out and no configuration is written, so `branch.autoSetupMerge` has nothing to act on. The branch holds the agent's commits and, on top, the uncommitted rest as one commit when there was any.
|
|
796
|
+
|
|
797
|
+
`applyAsChanges` measures its own pair first, the result against where the last apply left the working tree, which is a pair `bringCodeOut` never saw: over its caps it refuses with `changes_too_large` and names the branch. Then it builds the patch in the user's repository, into a file in the temporary folder, never in the host process's memory:
|
|
798
|
+
|
|
799
|
+
```
|
|
800
|
+
git diff-tree -r -p --binary --full-index --no-renames --no-ext-diff --no-textconv --no-color --src-prefix=a/ --dst-prefix=b/ --output=<file> <start> <result>
|
|
801
|
+
git -c apply.whitespace=nowarn -c apply.ignoreWhitespace=no apply --binary --whitespace=nowarn --check <file>
|
|
802
|
+
git -c apply.whitespace=nowarn -c apply.ignoreWhitespace=no apply --binary --whitespace=nowarn <file>
|
|
803
|
+
```
|
|
804
|
+
|
|
805
|
+
A space is applied more than once: the user applies, the agent works on, the work comes out again, and the user applies again. The maintainer's decision of 2026-09-22 is that the second apply brings only what is new since the first one. So the host remembers what it applied, as `refs/openchamber/spaces/<id>/applied`, a service ref beside the start and the result, which also keeps that commit through a garbage collection. The patch goes from that commit when it exists and from the start when it does not, and the ref is written only once the apply has gone through, never after a refusal. `applyAsBranch` does not touch it, in either order. Applying the same result twice has nothing to apply, rather than a refusal, and a second apply that collides with the user's own work touches nothing and offers the branch, like the first. When writing the ref fails after the changes are already in the working tree, `remembered` is false and the changes are there all the same; the intent the apply wrote stays, and the next call finishes the record, see below.
|
|
806
|
+
|
|
807
|
+
The user may also throw an apply away: apply, dislike it, `git restore` what it changed and remove what it added, have the agent redo it, bring the work out again and apply. The patch from `applied` would then bring only what is new, or nothing, and the thrown-away round would be lost. The maintainer's decision of 2026-09-25, made exact in the seventh and eighth rounds: "thrown away" is decided path by path from what is authoritative, a part thrown away is never passed over in silence, and an edit of the user's, committed or not, is never a throw-away. So every apply also records what its patch was built from, `refs/openchamber/spaces/<id>/applied-from`, the start or the apply before it, and the commit the user's HEAD named then, `refs/openchamber/spaces/<id>/applied-head`, absent while HEAD has no commit. Both move with `applied` in one ref transaction. Each path of the last apply is then one of three things, in `whatBecameOf`:
|
|
808
|
+
|
|
809
|
+
- **kept**: the working tree holds the side the apply wrote;
|
|
810
|
+
- **edited**: it holds neither side, an edit of the user's; or it holds the side before the apply and a commit of the user's changed that path since: a commit reachable from this worktree's HEAD and not from `applied-head`, found in one `git log --name-only` over that range and every path, asked once per call and shared by every question of the call, bounded by the read-back time and the listing cap; see below for why and what it costs. A revert is such a commit. A move back, a reset or a checkout of an older commit, adds none, so the paths it put back count as thrown away;
|
|
811
|
+
- **thrown away**: it holds the side before, and no commit since changed it.
|
|
812
|
+
|
|
813
|
+
`decideBase` then chooses where the next patch goes from, in this order:
|
|
814
|
+
|
|
815
|
+
1. A route that is closed stays closed: the refusal comes first, before anything is read, whatever the working tree looks like.
|
|
816
|
+
2. Every path of every apply so far thrown away, by the same rule over the change from the start to `applied`: the whole work comes from the start. A reviewer threw away both of two applies, and the patch from the first apply's result applied without round one and said nothing.
|
|
817
|
+
3. Every path of the last apply thrown away: the patch goes from `applied-from`, even where the patch from `applied` would fit, and even when the same result is applied again with nothing new brought out.
|
|
818
|
+
4. Some paths of the last apply thrown away and others kept or edited: the apply is refused with `changes_do_not_apply` and the route closes. `details.thrownAway` and `details.stillThere` name the two parts, `{ count, paths }` each, and so does the message, which says that the branch holds everything. It says the files still there are "still there or changed since", not changed by the user: a colleague's change pulled into one of them lands there too. Where one of them differs from what HEAD holds, as `git status` compares, the message adds that the files of the space still in the project must be removed or committed before that branch can be merged or switched to: a reviewer took the branch after this refusal, and git refused both the merge and the switch over those files. A file the user committed as it is stands in the way of neither, and then the message says nothing of it; when the host cannot tell, it keeps the advice. The bot had `git restore .` put back what round one changed, leaving the file it added, and a redo that touched only another file: the patch from `applied` fit and the thrown-away part was gone without a word.
|
|
819
|
+
5. Nothing thrown away: from `applied`.
|
|
820
|
+
|
|
821
|
+
Only commits the last apply's HEAD does not have count, whatever HEAD is now: in another linked worktree, which shares the refs of a space and has a HEAD of its own, on another branch, or after history was rewritten, the commits of the user's since are the ones that count. A first version compared the two HEADs' trees instead, and that took a reset to before the apply for the user's change, so a user who committed a round, applied the next and reset hard to before both got the rounds no commit touched and lost the committed one without a word; and a revert, whose trees are the same as before, for nothing at all. A reviewer applied round one in one worktree and round two from a second, which held nothing of round one, and got round two alone; now round one is thrown away there and the whole work comes, and back in the first worktree, which holds round one and not round two, the part is refused. The seventh round took any move of HEAD for keeping the whole of the last apply, and reviewers showed that wrong both ways: a user who threw an apply away and then pulled, switched branch or reset got the next round without the first and no word, and one who then had the agent redo it got a refusal that closed the route.
|
|
822
|
+
|
|
823
|
+
The list of the user's commits is one `git --literal-pathspecs log --format= --name-only -z --no-renames --no-show-signature <HEAD> ^<applied-head> --`, with no path filter, read into a set once per call and range and asked in memory by every question of the call: the look at every apply and the look at the last one share it. The eighth round limited the log to the paths still undecided, and git then compared each commit against each of those paths. Measured on macOS 26 with git 2.54, one run each, a last apply of 100,000 files three folders deep, every one put back, then 10,000 commits of the user's, eighth round against this: commits of other files, the log 74 s against 2.9 s and `describeApplyState` 78 s against 6.8 s; commits that touch 10,000 of those files, the log past the five minutes, so every call answered `changes_undecided`, against 0.3 s and a `describeApplyState` of 4.0 s; the same 10,000 commits ahead in a second linked worktree, 79 s against 6.6 s. Peak memory 210 MB against 240 MB. No history is simplified, so a commit of a side branch that a merge brought counts even where the merge dropped its change: that path is then taken for the user's edit, the safe side, never for a throw-away. Merges list nothing of their own. The log is not asked when no path is back as before, and its list prints while git walks, so a host that dies stops it at its next write; see "A folder of ours after a host crash". `--no-show-signature` keeps a user's `log.showSignature` from checking a signature per commit.
|
|
824
|
+
|
|
825
|
+
Reading the working tree back writes nothing, anywhere, and runs no smudge filter. The host looks at each path itself: a folder on the way that is not a plain folder, a link to another place among them, makes the path read as gone, as `git status` reads it, and nothing is read through it. A file is hashed as a blob by the host, 64 KiB at a time and never held whole. It is opened without following a link and without waiting, `O_NOFOLLOW | O_NONBLOCK`, and must be a plain file once open: a verifier swapped a FIFO in between the host's `lstat` and its open, and the open waited for ever, where no deadline is checked. A file that changes size while it is read, as when the user's editor saves it at that moment, is read again, up to four reads 100 ms apart within the call's time; one that keeps changing, or that cannot be read as one plain file, because the open or a read fails or it is something else once opened, makes the call `changes_undecided` with `details.cause` `read_back_failed` and the file named in the message. Tests make a file change during two reads, then hold still, and during every read. It is never handed to git instead, which would follow a link or wait on the FIFO. A test swaps a FIFO in, and another a link to a file outside that holds exactly what the apply wrote. A file that is byte for byte one side's blob holds that side, the applied side first: that is what `git apply` wrote, or `git restore` put back, wherever no filter and no line-ending conversion changes a byte, and it needs no git at all. Only a file that is neither side's bytes is read the way `git status` reads it, through the user's clean filters and line endings, by one `git hash-object --stdin-paths` for all such files, whose answers stream back; a name with a line break or a leading quote, which that input cannot carry, is asked on its own. The attributes are exactly those the working tree holds now, with the user's global attributes file and `info/attributes`: git reads each folder's `.gitattributes` from the working tree and, where one is missing, would fall back to the index, so it is given an index that does not exist. A `.gitattributes`, `.lfsconfig` or `.gitmodules` from the space's side is never written anywhere and never read. The seventh round compared through a temporary index holding the sides of the change and checked `to` out into a folder of ours, and a verifier had the space's filter run twice: on the user's own file, when an interrupted apply had not written the `.gitattributes` it brought and git fell back to the temporary index, and in our folder, on a `.gitattributes` the user had thrown away. A `.gitattributes` the user kept from an apply is the user's now, and the apply itself honours it, so reading back does too; a test shows both. A path whose two sides no disk can tell apart, a gitlink on either side or a change of the exec bit alone where `core.filemode` is off, is left out. When nothing is left, that is no evidence that anything was thrown away, and counts as kept.
|
|
826
|
+
|
|
827
|
+
The check of step 2 stops at the first path that is not thrown away, looking at one path first and sixteen times more each time; a test counts the files it reads. Step 3 reads every path of the last apply, because a thrown-away one can be any of them: by bytes alone where the files are the applied blobs, which a test proves with a clean filter that would take seconds and never runs. Measured on macOS 26 with git 2.54, a last apply of 100,000 files three folders deep, then a round of one file, one run each, the seventh round's code against this: kept, `describeApplyState` 8.6 s against 3.7 s and the apply 7.9 s against 3.7 s; kept with a clean filter that starts a program per file, 273 s against 3.7 s, measured by a reviewer before; every file but one thrown away, the refusal 24 s and 425 MB of temporary files against 3.6 s and none; a clean filter that is neither side's bytes for every file still pays the filter, and at 3 ms a file ran into the five minutes and answered `changes_undecided`. Peak memory 230 MB either way. The refusals of the patch that follows are the ones any apply has: too large or a name this computer cannot hold leave the route open, a link on the way and a failed dry run close it. On success `applied`, `applied-from` and `applied-head` move as for any apply, `applied-from` staying where it was after a throw-away, so a second throw-away comes back to the same place. A repository whose last apply was recorded before `applied-from` existed has no earlier base and behaves as before.
|
|
828
|
+
|
|
829
|
+
Reading the working tree back is bounded in time, `readBackTimeoutMs`, five minutes, for the whole call: each git command gets what is left of it, and the host checks it while it hashes a file. The seventh round read every file of the last apply through the user's clean filters, and a reviewer measured 276 s for a hundred thousand kept files with a clean filter that starts a program per file; now such files are read by bytes, and only files that are neither side's bytes pay for the filter. Where that does not end in time, it is `changes_undecided`: the route closes, with an intent where there is one in the same transaction, `details.recorded` saying whether it went through, and the message says that it is not known how much of the last apply is still there. A clean filter of the user's that fails while reading back ends the same way, with `details.cause` `read_back_failed` and what git said, rather than failing every later call. A dry run that does not end within the deadline of the apply ends the same way. `describeApplyState` says so with `newPathsUndecided` and writes nothing. Every git command of the read-back, every dry run and the real apply end with their whole process tree, `killTree`, when they run out of time, and all but the real apply also when the server leaves, see "Killing a transfer": git starts the user's filters as children of its own, and a verifier found one hanging clean filter left behind by each of five calls that ran out of time, while only git was killed. Tests hold a clean filter that never ends against a read-back, a dry run and an apply that each run out of time, and require it gone.
|
|
830
|
+
|
|
831
|
+
Both apply commands run at the top level of the work tree, whatever folder of it the caller names: from a subfolder, `git apply` ignores the paths outside it. Plumbing ignores `diff.noprefix`, `diff.relative`, colours and mnemonic prefixes, measured by the probe; the porcelain `git diff` broke the patch with the first two. `apply.whitespace=fix` made apply change content without a word and `apply.whitespace=error` made it refuse; `apply.ignoreWhitespace=change` would let the patch land on lines whose whitespace differs. The tests show each setting breaking the obvious command first. Never `--3way`, which fails on a working tree with unstaged edits and changes the index; never `--index`; never `--unsafe-paths`, so `git apply` keeps refusing paths outside the work tree and writes beyond a symbolic link. When the work of a space and the user's project have gone apart, this route closes for that space. The maintainer's decision of 2026-09-23: the user is told that once, at the refusal, and from then on the space is applied as a branch, which holds its whole work, the rounds already applied included. Nothing is applied in part and nothing is lost. The closure is `refs/openchamber/spaces/<id>/changes-closed`, at the result that was refused, so it survives a restart, a new `bringCodeOut` and anything else, and `removeSpaceRefs` takes it with the rest. A refused dry run closes it, and so does an apply that stopped in the middle, because the project is then in a state this cannot reason about. After that `applyAsChanges` refuses at once with `changes_route_closed`, before it builds anything, whatever the project looks like by then. `applyAsBranch` works throughout and closes nothing, and nothing in this stage opens the route again.
|
|
832
|
+
|
|
833
|
+
Before it builds the patch, the host checks every name the patch creates, each folder and file on the way, against what this computer's file system can hold, and refuses with `name_not_allowed_here`, touching nothing, when one cannot exist here. The message names the file, redacted like every reported path, says the agent made a name this computer cannot hold, and asks for the agent to rename it and the work to be brought out again. That refusal leaves the route open, by the maintainer's decision of 2026-09-24: the project and the space did not go apart, and after a rename the next `bringCodeOut` and apply go through as usual. The rules are per platform on purpose, in `nameNotAllowedHere`, each with its source in a comment: on Windows, from Microsoft's naming documentation and git's own `is_valid_win32_path`, refused when either refuses it, the characters `< > : " / \ | ? *` and 1 to 31, a trailing space or period, and the device names CON, PRN, AUX, NUL, COM1 to COM9, LPT1 to LPT9, alone or followed by spaces and an extension or a colon, in any case, with the superscript digits Microsoft names and the LPT0, CONIN$ and CONOUT$ git adds. macOS and Linux refuse only NUL and `/`, which never come out of a git tree. On every platform a name longer than 255 bytes is refused, the longest name ext4, APFS and NTFS hold, and so is a path of 1024 bytes or more with the project's own path in front of it, macOS's PATH_MAX with its terminating null: a reviewer found that a path within the limit below the project still ended in `ENAMETOOLONG` and a generic `code_out_failed` once the project's path was added, and 1023 bytes opens on this Mac where 1024 does not. `git apply` itself would write such a path, because it works below the project, but the file is then out of reach of whatever opens it by its whole path: the test's own clean-up failed on it with `ENAMETOOLONG` when the check was removed. On Windows also, unless the repository has `core.longpaths`, a whole path past 259 characters and a folder whose whole path is past 247, the limit Microsoft gives for `CreateDirectory` (MAX_PATH - 12, room for an 8.3 name) and the one Git for Windows' `mingw_mkdir` applies. A path too long has a message of its own that says so.
|
|
834
|
+
|
|
835
|
+
Whether two names that differ only in case, or only in the Unicode form of an accent, are one file is the repository's own answer: `core.ignorecase` and `core.precomposeunicode`, which git sets by probing the disk when it makes the repository. So a case-sensitive macOS volume is not refused, and a case-blind disk on Linux is checked; the platform still decides the Windows character rules. How wide "only in case" is, is the disk's, and APFS is wider than lowering a name: it folds case fully, so `strasse` and `straße`, `σ` and `ς`, `fi` and `fi`, `s` and `ſ`, `β` and `ϐ` are one name there, and a reviewer lost a file to the first pair. So names are folded lower, upper, lower again, which also joins `ẞ` with `ss`, and composed wherever case is ignored, because APFS ignores the form there too; the name check and the link check use this one fold. Checked on this Mac's APFS for every character of the Basic Multilingual Plane, each after an `x`: the disk took 1,678 names for one already there, and the fold joined every one of those pairs and every longer spelling the disk joined, where lowering alone missed 57; it joins one pair the disk keeps apart, `ı` and `I`. A test repeats the pairs above on the disk itself. NTFS compares through a table that upcases one UTF-16 unit at a time, which is narrower, so there the fold is wider than the disk. That only ever refuses more in the name check, and never lets a lost file through; in the link check it lets a real path pass as the same folder when it differs from git's spelling by more than the disk's own fold, and by reasoning that lets no redirection through, because a real path names a different folder only through a link, which `lstat` reports first, or through a volume mount point, whose real path is on another volume. A rename `strasse.txt` to `straße.txt` on such a disk is a `case_only_rename`, and the route stays open. Where the repository says so, a name the patch creates is compared with the other names of its folder in the tree the patch leads to. Another spelling that stays there is `name_not_allowed_here` with `details.other` naming it: without the refusal, `git apply` would take the second name for the first, refused as "already exists" with the wrong cause when the first is on disk, or written over it when both are new. Another spelling that the patch removes is a rename that changes only the case, `case_only_rename`: `git apply` cannot turn one into the other on such a disk, because the new name "already exists", so the message says that, that nothing was changed, and that the branch holds it; the route stays open, and a new name brings the work out normally. That was chosen over making the rename apply, which would take a patch that `git apply` does not write in one step here, and all or nothing matters more. A pair of spellings that both existed before the patch is the user's own and passes, so a project that already has `Docs/` and `docs/` can take new files in either.
|
|
836
|
+
|
|
837
|
+
The check keeps only the folders on the way of the paths the patch creates, one node per name, and passes over every other path of the tree without holding it. Measured on macOS: a tree of 203,000 files with one new file took 58 ms and 3 MB of memory, against 477 ms and 69 MB before; one path 2048 folders deep with 255-character names, which used to take 572 MB and ran Node out of memory at sixteen such paths, is now refused by the length rule in a millisecond.
|
|
838
|
+
|
|
839
|
+
Before it builds the patch, the host looks at every folder on the way to every path the patch writes or deletes, in the user's work tree, and refuses with `changes_blocked_by_link`, touching nothing, when one of them leads somewhere else. `git apply` is not trusted with this: measured on Windows 11 with Git for Windows 2.54, it wrote through a directory junction in the user's work tree into the folder the junction points at, where git on POSIX refuses with "beyond a symbolic link". A folder leads somewhere else when `lstat` reports it as a link, which Node does for a symbolic link everywhere and for a junction on Windows, because libuv reports both kinds of reparse point as a link; or when its real path is not that same folder inside the real work tree, which catches a redirection that is not reported as a link. Names are compared the way the repository says its disk compares them, `core.ignorecase` and `core.precomposeunicode`, the same folding the name check uses: a folder stored with its accent decomposed, as a macOS disk may hold it, is the folder git names in the composed form and not a link. The real path is asked once per path, of its deepest folder that exists, which covers every folder above it. Measured on macOS against asking every folder: 20,000 paths each in a chain three folders deep of its own took 1.1 s instead of 2.0 s, 5,000 chains 16 deep 1.1 s instead of 3.5 s, and 1,000 chains 64 deep 1.1 s instead of 6.3 s; where paths share their folders the two were the same, because every folder was already asked once. Folders that do not exist yet are fine, because apply makes them. A link that points inside the work tree is refused too: the space made a plain folder where the user has a link, so the two went apart. The one link that is not in the way is one the patch itself deletes as a link, mode 120000, where the space turned a tracked link into a folder: `git apply` removes the link before it makes the folder, and its dry run first requires the link on disk to be the one the patch deletes. Nothing else is passed over. The refusal closes the route like any other, and its message names the folder. How many folders it looks at, and how deep, is bounded before it runs; see "Folders" under the caps. A symbolic link trips both checks at once on POSIX, so a unit test gives the check a fake of each kind to prove each one alone; the junction itself is covered by the same tests on Windows, which build one there.
|
|
840
|
+
|
|
841
|
+
An apply can be cut off by the host itself: the server killed, the computer turned off, a Ctrl-C to the whole process group. `git apply` runs with `killTree` for its timeout and with `keepAtExit`, so a server that leaves, through its own handlers or killed outright, leaves it running to its end on POSIX: killed in the middle it would leave a part written and close the route, while left to run it usually finishes, and the next call records it. On Windows libuv's job object ends git with the server whatever is set, as it did before. Such a child has no pipe back to the host on its output either: a reviewer's apply, kept alive, still wrote its stderr into a pipe to the host that had gone, and a smudge filter that printed there got SIGPIPE and stopped the apply with files deleted, 15 and 17 of 20. Its stdout goes nowhere, and its stderr into a file the host opened in the system's temporary folder and removed at once, which the host and the child each keep open, so no clean-up can break the child's writes; the host reads the last 64 KiB of it at `close`, the window `keepTail` keeps. A test holds the options of both apply commands, and `run-command.test.js` a `keepAtExit` child alive after `process.exit`, and a real `git apply` with a smudge filter that prints to stderr, finishing every file after its host left, red with the pipes back. So just before it runs, the host writes the intent, `refs/openchamber/spaces/<id>/applying` at the result, with `applying-from` at what its patch was built from and `applying-head` at the user's HEAD, in one transaction, and replaces them with `applied`, `applied-from` and `applied-head` in one transaction once the apply is done. That is how a crash in the middle of an apply of the whole work again is read against the right base; an intent written before `applying-from` existed was built from `applied`, which has not moved since. A later call that finds the intent looks at the working tree first, for the paths of that patch only, read back as above: when every path holds the result, the apply finished and was not recorded, so the record is finished and the call goes on from there; when every path still holds what was there before, nothing was written, so the intent is dropped and the call goes on. The user may have edited something since, which leaves a path as neither, and a reviewer had an ordinary edit of another line in a touched file called a partial apply and the route closed. So then the dry run an apply makes decides: when no path holds the result alone and the patch still applies, nothing was written; when no path holds the old content alone and the patch applies backwards, all of it was. The first half of each condition matters: `git apply` writes a whole file at a time, so a file it wrote holds the result alone, and a test shows that without it a file of repeated lines would take the same insertion a second time. When that reads as a part, the working tree is read again against where the applies before it came from, `applied-from` and then the start, and the first answer that is not a part counts, with that base: the user may have thrown an earlier apply away after the host went away, and a reviewer had an attempt that wrote nothing, followed by such a throw-away, taken for a part and the route closed. Every dry run keeps only the last 64 KiB of what it prints, and so does the real apply, because the exit code is the answer: a reviewer had a check fail on tens of thousands of paths and print past the host's cap of four megabytes, which stopped it, and every later call failed the same way with the intent still there. A test makes a dry run fail on 8,000 long paths, about 5 MB of output, for an apply and for the recovery of an intent. Anything else is `changes_partly_applied`, with a message that says an earlier apply was interrupted and part of it is in the project, and it closes the route at the result that was being applied. The closure and the removal of the intent are one ref transaction, there and after an apply that stopped in the middle, so a host that dies in between leaves both or neither, and never a closed route beside an intent; `details.recorded` says whether the transaction went through, and when it did not, the next call finds the intent and sorts it out again. The same intent is what a record that failed after the apply leaves, where `remembered` is false, and the next call finishes that record the same way. `describeApplyState` reports the intent as `interruptedApply`, reads it the same way without writing anything, and counts `newPaths` from what the interrupted apply wrote when the working tree holds it.
|
|
842
|
+
|
|
843
|
+
Two applies of one space at once each passed the dry run against the same working tree; the second then wrote over the first, failed and closed the route, three times out of three for a reviewer. So applies as changes take turns, per repository and space: the repository's common git folder, where every worktree of it keeps these refs, by its real path. Two linked worktrees of one repository share the refs of a space, so a call from one waits for a call from the other; a test holds an apply in one worktree at its `git apply` and requires a read from the other to wait for it. A call waits for the one before it, however that one ended, and then answers from what it left, which is nothing to apply, or only what is new. `describeApplyState` takes the same turns, so it never reports an apply under way as interrupted. The turns are kept in the process, at module level like the history runs of code in, so two instances of code out share them; two processes are not coordinated, and neither is `bringCodeOut`.
|
|
844
|
+
|
|
845
|
+
Three ways it can end other than applied. A patch git cannot build at all, which happens for a single file too large for it, is `patch_not_possible` in our words with git's text after it, and points at the branch. A dry run that fails is `changes_do_not_apply`, nothing was touched, and the message says whether the two grew apart since the space was made or since the work was last applied here. When a file the patch adds is already on disk and git ignores it here, through the user's global ignore file or `info/exclude`, the message says that instead, because the file was there before the space, and names it in `details.ignoredInTheWay`; the route closes all the same. When a file the patch changes is kept by a filter such as Git LFS, and the working file is byte for byte the blob an earlier apply wrote while git reads it back through that filter as another file, the message says that files like that cannot be updated as uncommitted changes a second time, and names them in `details.filteredInTheWay`; the route closes. A reviewer had the agent change a file an LFS-like filter keeps: the first apply wrote the agent's bytes, which the filter reads back as another pointer, and the next round on that file was told that the project had changed. Both hints are asked after the route is closed, and a failure of either leaves the ordinary message. A real apply that fails after a clean dry run, for a folder that cannot be written or a disk that is full, is `changes_partly_applied`: `git apply` writes file by file, so part of the work may already be in the project, the message says so and asks the user to look before going on, and the remembered ref is not moved, because it no longer describes what is there. A test makes that happen with a folder the user cannot write, and holds the promise to what is true: the locked file kept its content, what else git had written by then is its own order, and nothing was remembered.
|
|
846
|
+
|
|
847
|
+
The dry run is the documented clean-apply decision: a user who edited a line the space changed, an untracked file of the user's where the space adds one, a file the user deleted, or a link of the user's where the space writes a folder each give `changes_do_not_apply`, and the repository folder is byte-identical afterwards, `.git` and the working tree included. An empty result resolves `nothing_to_apply` and touches nothing.
|
|
848
|
+
|
|
849
|
+
### What code out does to the user's repository
|
|
850
|
+
|
|
851
|
+
- `bringCodeOut` adds the space's new objects, as loose objects or as a pack, whichever git's own unpack limit picks, and writes `refs/openchamber/spaces/<id>/result`, which a later call moves. Nothing else: no `FETCH_HEAD`, no `shallow` file, no tag, no other ref, no commit graph, no gc or maintenance, and none of the user's hooks or fsmonitor. The tests compare the whole repository folder with `hostState` and allow exactly those differences through `unexpectedChanges(..., { codeOut: true })`. A test of the comparison itself feeds it a pack of the user's rewritten, `FETCH_HEAD`, a `shallow` file, a commit graph, a branch, the result ref of another space and a changed working tree file, and requires it to see each. After a refusal of the space's content, nothing of it is in the repository at all: the tests compare with no allowance.
|
|
852
|
+
- `applyAsBranch` adds the branch and its reflog.
|
|
853
|
+
- A refused `applyAsChanges` writes one thing, `refs/openchamber/spaces/<id>/changes-closed`, and nothing else: the tests compare the whole repository folder and allow exactly that one path.
|
|
854
|
+
- `applyAsChanges` reports what it wrote as well: `nestedRepositories` from the paths it applied, and `conflicted`, the paths whose applied content holds a conflict marker, found in one pass over the patch it built. That one is a text check and no more: a file that talks about merge markers counts too. It changes the working tree, and in `.git` only `refs/openchamber/spaces/<id>/applied`, `applied-from` and `applied-head`, which a later apply moves, and the intent while it runs. The index, HEAD and every other ref stay as they were, and a test compares `.git` with and without that one allowance.
|
|
855
|
+
- The user's global hooks and fsmonitor run in none of it, with the same empty hooks folder and empty `core.fsmonitor` as code in. The test gives the user fourteen global hooks and an fsmonitor program, shows them running on ordinary commands, and requires nothing of them after a whole code out and both applies. A second test gives the user automatic gc, maintenance and `fetch.writeCommitGraph=true`, shows an ordinary fetch consolidating two packs and writing a commit graph, and requires neither after code out.
|
|
856
|
+
|
|
857
|
+
### Known limits
|
|
858
|
+
|
|
859
|
+
- **Filters.** `applyAsChanges` runs the user's own configured filters, clean and smudge, Git LFS among them, for files that a `.gitattributes` already in the user's working tree covers, as a `git pull` from someone else would. A filter named by a `.gitattributes` that arrives in the same patch does not run: the test defines both filters globally, shows both running on an ordinary `add` where their attributes are, and then sees only the user's own run during apply. `applyAsBranch` runs none. This is the maintainer's decision of 2026-09-22, recorded in DESIGN.md. Reading the working tree back runs only clean filters, only for files that are neither side's bytes, and only with the attributes the working tree holds; it runs no smudge filter, so it never downloads an LFS object.
|
|
860
|
+
- **A file a filter such as Git LFS keeps, changed a second time.** Git LFS files arrive in a space as pointers, so the agent changes such a file by writing its own bytes, and the first apply writes those bytes into the user's file. The filter then reads that file back as a pointer to new content, which is not what the result holds, so a later round that changes the file again cannot fit: the apply is refused with a message that says so, names the files, and closes the route. The branch holds everything. The same holds for any clean filter that changes the bytes, such as an encrypting one.
|
|
861
|
+
- **The exec bit on Windows.** With `core.filemode=false`, measured by the probe, the uncommitted variant writes the content and loses the exec bit. The branch keeps it. On macOS and Debian the working tree after the apply equals the result tree, mode included.
|
|
862
|
+
- **Line endings.** The user's `core.autocrlf` and `eol` attributes convert what apply writes, as they would for any patch; the content is the same once git reads it back.
|
|
863
|
+
- **A repository the agent made inside its project** arrives as a gitlink, from the snapshot's `add --all`: its commits never travel, the uncommitted variant makes an empty folder for it, and the branch points at a commit whose gitlink the user's repository cannot resolve. `bringCodeOut` reports those paths in `nestedRepositories`, as code in reports untracked repositories in `notTravelling`, so the caller can warn. Nothing is refused.
|
|
864
|
+
- **A conflicted merge in the space** comes out as content: `add --all` stages the conflicted files as they are, so the conflict markers are in the result, the merge's second parent is not, and applying writes the markers into the user's files. `bringCodeOut` reports those paths in `unmerged`, from the snapshot's own report, and `applyAsChanges` reports the markers it wrote in `conflicted`.
|
|
865
|
+
- **A path a space names with a control character** is counted like any other and shown with those characters replaced by `?`. Counting comes before redacting, so a warning cannot be made to disappear by naming a file with a newline.
|
|
866
|
+
- **Work the user undid by hand after an apply** is not brought again: the next apply starts from what was applied, not from the working tree, and nothing in the result says that a path was skipped. The branch holds all of it.
|
|
867
|
+
- **A name the host cannot hold** is refused before an apply with `name_not_allowed_here`, and a rename that changes only the case with `case_only_rename`, see "Apply". The branch is not checked: it still holds such a file, and checking that branch out on Windows will fail on the same name. The case check covers names the patch creates, not a user's own files that differ in case from each other.
|
|
868
|
+
- **A folder of ours after a host crash.** A call that is killed with the host leaves its temporary folder, `openchamber-code-out-*` in the system's temporary folder: for `bringCodeOut` measured at 128 to 139 MB with a quarantine in it; for an apply or `describeApplyState` only the patches it built, which the caps bound, since reading the working tree back writes nothing. A `git hash-object` that is reading back when the host dies ends at its next answer, because its output goes nowhere: measured on macOS, a host killed while `hash-object` read 100,000 edited files through a slow clean filter left no process one second later, where the seventh round's `update-index --refresh` ran on under PID 1. A clean filter that never ends would still keep it waiting, as it would keep `git status`. The `git log` of the user's commits prints as it walks, so it ends at its next write once the host is gone: measured with the host killed 0.5 s into a log of 10,000 commits, nothing was left at the first look, where the eighth round's log limited to 100,000 paths ran on for 76 s, and past ten minutes, when it was killed by hand, for commits that touch those paths. Before its first name git walks the whole range once, so a long range keeps it that long: with 400,000 commits it was gone 1.3 s after a host killed 0.4 s in. The dry run, `git apply --check`, is not killed with a host killed outright; it only reads, and ends on its own; a filter of the user's that prints to stderr after the host is gone gets SIGPIPE there, which ends only the read. The apply is not killed at any exit of the host, and its output goes to a file rather than to a pipe to the host, so a filter printing after the host is gone does not stop it half written, see "Apply". It is not swept: a later call cannot tell a folder whose process died from one whose process is still running, because a process id can belong to somebody else by then, and removing a live call's quarantine would break that call. The system's own clean-up of its temporary folder removes it in time.
|
|
869
|
+
- **Work the user threw away after an apply** comes again only when the whole of the last apply is gone, path by path, see "Apply". After throwing away part of it the next apply is refused, and only the branch holds what was discarded. A part thrown away of an apply before the last one is not seen: only the last apply's paths are read for that.
|
|
870
|
+
- **A throw-away, then a colleague's change pulled into a file of it.** The user throws the whole last apply away and then pulls or rebases onto a colleague's commit that changes one of those files. That file now holds neither side of the apply, which is an edit, so the apply is refused as a part and the route closes, though the whole work would fit. Nothing is lost: the branch holds everything. The rule does not look at whose commit brought the change, so it cannot tell this from an edit of the user's; the message says "still there or changed since" for that reason. It stays that way on purpose: an edit is never taken for a throw-away.
|
|
871
|
+
- **An amend that drops a file of the last apply.** The user commits the apply and then amends that commit to drop one of its files, putting it back as before. The amended commit replaces the one that held the file, so no commit since the apply changed it, and it counts as thrown away beside the files that are kept: a part, refused. The same undo made by a new commit on top counts as the user's change, and the next apply brings only what is new.
|
|
872
|
+
- **A change a merge dropped.** The user's commits are listed with no history simplification: a commit on a side branch that a merge brought counts even where the merge dropped its change. The path is then the user's edit, never a throw-away, so at worst the whole work does not come again and a part is refused, with the branch holding everything.
|
|
873
|
+
- **A space made over the user's uncommitted edits** cannot be thrown away with git commands alone. Its start holds those edits, so "as before the apply" means with the user's own lines in; `git restore` takes the file back to the index and removes them as well, and the file then holds neither side, which counts as kept, or, beside files that are back, as a part thrown away. Putting back exactly what was there before, the user's lines included, is what counts.
|
|
874
|
+
- **A dotless ı beside a capital I.** The name check folds `ı` and `I` together, which this Mac's APFS keeps apart, so a new file whose name differs from another only by that pair is refused with `name_not_allowed_here` on a disk that ignores case, though the disk would hold both. The route stays open, and the branch holds it.
|
|
875
|
+
- **Reading back is bounded, and costs a read of every file of the last apply.** An apply after the first, and every `describeApplyState`, reads each file the last apply changed to know whether any of it was thrown away: by its bytes, and through the user's clean filters where the bytes are neither side's; see the measurements under "Apply". Where that does not end within `readBackTimeoutMs`, the route closes with `changes_undecided`.
|
|
876
|
+
- **A change the comparison cannot see counts as kept.** A gitlink, and a change of the exec bit alone where `core.filemode` is off, are left out of the question whether the apply was thrown away. A file that is byte for byte the blob the apply wrote counts as kept even where the user's attributes write both sides the same way, as `eol=crlf` does for a change from LF to CRLF, so throwing such a change away is not seen. A file whose bytes are neither side's and whose side before the apply does not read back as itself through the user's filters is taken for an edit when it is put back, which counts as kept.
|
|
877
|
+
- **A branch that was merged, then an apply as changes.** Taking the work as a branch and merging it, and then applying as uncommitted changes, finds the same changes already there, is refused, and closes the route. Before merging the branch after rounds were applied as changes, the user must drop those uncommitted copies, or the merge meets them.
|
|
878
|
+
- **Conflict markers the agent committed** are reported by `applyAsChanges` in `conflicted`, from the patch it writes, and not by `bringCodeOut`, whose `unmerged` comes from the space's index and sees only a merge the agent left unfinished.
|
|
879
|
+
- **A patch is not one write.** `git apply` writes file by file, so a failure in the middle can leave part of the work in the project; see `changes_partly_applied` above.
|
|
880
|
+
- **The byte caps count what the result would write into a working tree**, not what it takes on disk. Compressed, what a cap refuses can be a few kilobytes in a pack, which is why the transfer cap alone is not enough.
|
|
881
|
+
- **A transfer the cap stopped leaves the sending process inside the space** until the limit inside, which is the host's deadline plus the margin, as a transfer the deadline killed does. The host has nothing left within seconds; the live test waits for the sender inside and sees it go at that limit. That is the space's own machine, and it is a limit of the space.
|
|
882
|
+
- **One huge object is paid for in host memory before any check sees it.** The fetch itself inflates each object it indexes: measured on macOS with git 2.50.1, a 260 MiB commit message made the fetch peak at 279 MB while the quarantine held 804 KB on disk. The caps refuse the result right after, so nothing of it is promoted, but the memory was spent. Git offers nothing to bound that during a fetch.
|
|
883
|
+
- The result ref keeps what the last call that worked put there, also after a later call failed.
|
|
884
|
+
- Two `bringCodeOut` calls for one space at the same time are not coordinated, and applies in two processes are not either; see "Apply" for the turns within one.
|
|
885
|
+
- **An interrupted apply and a user's edit of the same file.** When the user edited a file that the interrupted apply had already written, and the patch's lines repeat there, the dry run may fit the patch a second time. The per-path comparison keeps that from happening for a file the apply wrote and nobody touched since; for one the user edited after it, it is what applying any patch twice does.
|
|
886
|
+
- **A route closed by the code before this round** can have an intent beside it, from a host that died between the two writes. It stays in the repository, is not reported, and goes with `removeSpaceRefs`.
|
|
887
|
+
- Not verified: a partial clone, a sparse checkout, a repository over 1 GB, and a slow link to a place. This stage's code did run on Windows and on Debian; see "Verified and not verified".
|
|
888
|
+
|
|
558
889
|
## New tools at the next start
|
|
559
890
|
|
|
560
891
|
DESIGN.md: spaces pick up a new host version at their next start, and a busy agent is never interrupted. The mounts of a container are fixed at creation. So `start` of a stopped space whose tools mount is not the current tools volume makes the container again:
|
|
@@ -605,12 +936,22 @@ A timeout kills the `docker` CLI only. A command started with `exec` keeps runni
|
|
|
605
936
|
|
|
606
937
|
`killTree: true` makes a kill end the child's whole process tree instead. Only the pushes of code in use it, see "Killing a transfer".
|
|
607
938
|
|
|
939
|
+
Since 3b the fetches of code out use `killTree` as well, and so does every git command of code out that can start one of the user's filters or run long on the user's working tree: reading it back, the dry runs and the apply; see "Apply". `keepAtExit: true` beside `killTree` keeps the kill for a timeout or an abort, leaves the child out of the exit handler's set, and gives it no pipe on its output: stdout goes nowhere and resolves empty, stderr goes to an already removed temporary file read back at `close`, its last `maxOutputBytes` with `keepTail`, and `command_output_too_large` after the end without it. Only the real `git apply` of code out uses it. The fetches also use one more option: `signal`, an `AbortSignal` that lets the caller stop the child from outside. The child is killed the way a timeout kills it, the tree with `killTree`, and the rejection waits for that kill the same way; its value is the signal's reason when that reason is a `SpaceError`, and `command_aborted` otherwise, so every rejection of `runCommand` is a `SpaceError` whatever the caller aborted with. The caller then knows that it stopped the child, which the exit code cannot tell on Windows. A signal that is already aborted starts nothing, the listener goes when the run settles in any way, and an abort after the run settled, or after the timeout has already begun its kill, does nothing: the kill happens once. Code out uses it for the transfer cap.
|
|
940
|
+
|
|
608
941
|
`stdin` is a string or a Buffer. A Buffer reaches the child byte for byte, which is how the tarballs of a development build reach the filler. `cwd` sets the working directory and is used by `tools-pack.js` only. `env` replaces the child's environment and is used by host git only.
|
|
609
942
|
|
|
610
943
|
`check()` says the CLI is missing only for `ENOENT`. Any other spawn error names its errno. `check()` also reads `docker info --format '{{json .SecurityOptions}}'` and reports the place unavailable when the engine has no builtin seccomp profile. Older engines name that profile `default`.
|
|
611
944
|
|
|
612
945
|
## Verified and not verified
|
|
613
946
|
|
|
947
|
+
Stage 5a ran on macOS 26 with Colima, linux/arm64, Docker Engine 29.2.1, host git 2.50.1, on 2026-09-26: the unit suite of the module 1414 passed and 116 skipped of 1530 on the final code, after the local review round, `packages/web` type-check and lint clean, `bunx oxlint` clean on the six new files. Live, against the real server with the switch on and a scratch repository registered as a project: a space made through `POST /api/openchamber/spaces` with an allowlist of one domain, the five progress events on the global stream and the list saying `preparing` with the step until `running`, 70 s in all with the tools volume filled on the way; inside, `git status` equal to the host's, the ignored `.env` absent, the history complete; the journal with a refused attempt and an allowed one; stop, then start with `networkRestored` true, the same refusal and allowance after it and a new `since`; a session made inside through the dispatcher and shown under the space in the sidebar; the apply preview, the apply as a branch with the space's commit and the uncommitted rest on top, and the apply as changes bringing three files into the working tree; the switch turned off with the space stopped, the setting written and every journey route 404, and on again with the space listed as `exited`, and on the final code a turn-off refused while a space was being made, two turn-offs at once stopping the space once, a creation refused while off, and two turn-ons at once building one host; the removal with no container, network, volume, record or ref left and no `docker exec` on the host. Not run for 5a: Debian and Windows, the packaged desktop app, a creation that fails after the containers exist against real Docker (covered by the unit tests only), and any UI of the journey, which does not exist yet.
|
|
948
|
+
|
|
949
|
+
Stage 4b ran on macOS 26 with Colima, linux/arm64, Docker Engine 29.2.1, on 2026-09-25, on the final code after the review fixes: the unit suite of the module 1373 passed and 116 skipped of 1489, the tests of the changed server files (event stream, proxy, SSE routes, realtime proxy, dispatcher, relay tunnel host, UI auth) 186 passed, and `places/sessions.docker.live.test.js` 4 passed in 85 s, with no container, network or volume left and no `docker exec` on the host. Each of the three protections of TESTING.md was removed once and its tests went red: the directory rule of the list (4 cases and the host's merge), the host-id rule of the list (its case and the host's merge), and the rules for events (5 cases, the connection's drop test and the host's follow test); restored, all green. Not run for 4b: Debian and Windows, any real client, and the relay path of the new sockets on a device.
|
|
950
|
+
|
|
951
|
+
Stage 4a ran on macOS 26 with Colima, linux/arm64, Docker Engine 29.2.1, on 2026-09-25: the unit suite of the module with the tests of the changed server files, 1460 passed and 113 skipped of 1573, and `places/dispatcher.docker.live.test.js` 9 passed in 70 s, with no container, network or volume left. Each guard, the parser predicate, the directory-gate hook, the response allowlist with the redirect refusal, and the body piping were removed once and their tests went red. Not run for 4a: Debian and Windows, and any real client, since nothing in the app makes a prefixed request yet.
|
|
952
|
+
|
|
953
|
+
Stage 3b ran on macOS 26 with Colima, linux/arm64, Docker Engine 29.2.1 and host git 2.50.1, on 2026-09-23, after three rounds of review fixes: unit 1094 passed and 99 skipped of 1193 in 154 s, and the whole folder with `OPENCHAMBER_TEST_DOCKER=1` 1189 passed and 4 skipped of 1193 in 275 s, with no container, network or volume left and no host process of a transfer. `code-out.test.js` has 92 tests and the live code-out file 8, which take 200 s alone. Every protection of 3b was removed once in a disposable copy and its test went red, all three rounds of fixes included; the one exception is the report channel of the snapshot, where git sends a hook's output to stderr on 2.50.1, so no mutation of ours could show the difference. Before the merge on 2026-09-25 it also ran, after the rebase onto v2.0.1 and with the copied files hashed against the worktree: on macOS 26 again, unit 1279 passed, and live 1372 passed, 3 failed and 4 skipped of 1379, the three being the server-inside tests that still expected OpenCode 1 routes and are fixed by the OpenCode 2 branch below; with the module on a Windows 11 host against Docker Desktop, Engine 29.6.2, Git for Windows 2.54.0, 1247 passed, 5 failed and 126 skipped of 1378, the same three plus two flaky ones, a read-back timing test at 2160 ms against an 1800 ms bound and the tunnels escape test's JSON read, both green on the previous Windows run of the same files; and the unit suite on a Debian 13 linux/amd64 host itself, git 2.47.3, the tests running on the Linux host for the first time, 1283 passed, 13 failed and 83 skipped of 1379, the 13 triaged as test assumptions and not product defects, git's localized text in one assertion, a Docker bridge address chosen for the gatekeeper's own-address test, and tests that do a real DNS lookup. The serial live suite on Debian with local Docker was still running when the pull request opened; the run before the rebase passed 250. No container, network, volume or host process was left after the macOS and Windows runs.
|
|
954
|
+
|
|
614
955
|
Stage 3a ran on three machines on 2026-09-22, the same files hashed against the worktree before each run:
|
|
615
956
|
|
|
616
957
|
| Machine | Engine, host git | Result |
|
|
@@ -647,7 +988,7 @@ Measured on macOS with Colima, Docker Engine 29.2.1, linux/arm64, with `web` 1.2
|
|
|
647
988
|
- A create with the gatekeeper costs 2.9 seconds when the tools volume is filled, against the 2.3 seconds stage 1b measured without one, and 38.9 seconds when it pays for the fill. `stop` takes 0.2 seconds and `start` 2.5 seconds. The live contract file, one fill plus the whole scenario, took 43 to 49 seconds, and the whole live run of the module 96 seconds.
|
|
648
989
|
- The corridor carries a real CONNECT to `example.com:443` from inside a space, refuses a name that is not on the list, refuses the same name on port 80, 22 and 8443 in both modes, and answers a changed list on the next attempt without a restart.
|
|
649
990
|
- The address of `auth.openai.com`, resolved on the host at test time, is refused in four forms and in both modes, with the gatekeeper's own reach of that address as the control. Before this rule existed a reviewer reached the real service through the corridor by its address.
|
|
650
|
-
- The space holds no long-lived credential: no OpenCode auth record, and nothing shaped like an oauth refresh record, a private key, a GitHub token or a provider key in any file under its HOME or work directory or in any process environment. The search finds all three when the test plants them first.
|
|
991
|
+
- The space holds no long-lived credential: no OpenCode auth record, and nothing shaped like an oauth refresh record, a private key, a GitHub token or a provider key in any file under its HOME or work directory or in any process environment. The search finds all three when the test plants them first. That was OpenCode 1.18.31, which kept logins in `auth.json`. The search skipped binary files and so could not see where OpenCode 2 keeps them; see "Verified with OpenCode 2.0.15" below.
|
|
651
992
|
- A flood of 400 sockets on each listener, and 80 tunnels at once, leave the gatekeeper running, its journal readable, the corridor serving and the space verifying clean.
|
|
652
993
|
- 140 refused clients that hold their sockets are all refused, and the corridor still answers with `tunnels: 0` at 5, 15, 30 and 45 seconds afterwards.
|
|
653
994
|
- A tunnel whose peer dies gives its place back at once rather than at the idle limit: 20 held became 0, while a genuinely half-closed tunnel kept its place. Through a real corridor, a four-megabyte answer with a concurrent upload arrives byte for byte, sha256 equal; the old teardown loses about 460 KB in the same shape. An upstream that half-closes now receives what the space sends afterwards.
|
|
@@ -662,7 +1003,7 @@ Measured on macOS with Colima, Docker Engine 29.2.1, linux/arm64, with `web` 1.2
|
|
|
662
1003
|
- The gatekeeper, seen from inside it: uid 1000, `NoNewPrivs: 1`, empty `CapEff`, `Seccomp: 2`, a read-only root filesystem, no runtime socket, no tools volume, and a writable `/tmp`.
|
|
663
1004
|
- The filler runs as root with every capability dropped. One fill took 36 seconds and left 438 MB in the volume. The marker check takes 0.1 to 0.2 seconds.
|
|
664
1005
|
- An idle space with the server and OpenCode holds 357 to 374 MiB and 25 processes. The contract suite's 512 MiB limit is enough to start.
|
|
665
|
-
- OpenCode
|
|
1006
|
+
- OpenCode 1.18.31 reported the directory of a session byte for byte as the space path, and the real path for a path that went through a symlink. OpenCode 2 changed the second half; see "Verified with OpenCode 2.0.15" below.
|
|
666
1007
|
- The terminal works through its WebSocket with `node-pty`, although install scripts were ignored.
|
|
667
1008
|
- `bun pm pack` of bun 1.4.2 runs `prepack`, rewrites `workspace:*` to the version, writes `<name>-<version>.tgz` into `--destination`, and prints the path as the last line of stdout. Two packs of unchanged sources give the same bytes, so the key of a development build changes only when a source file changes. Every file under `packages/web/server` is in the tarball, so any edit there means a new key and a new fill.
|
|
668
1009
|
|
|
@@ -713,6 +1054,17 @@ Not verified since 1a:
|
|
|
713
1054
|
- Whether a console window flashes in an interactive Windows desktop session. `docker.exe` gets a `conhost.exe` child even with `windowsHide`. The `desktop-shell` skill requires an inspection of the whole process tree before release.
|
|
714
1055
|
- Docker Desktop's credential helper in a real desktop session. Over SSH a pull failed with "A specified logon session does not exist".
|
|
715
1056
|
|
|
1057
|
+
Verified with OpenCode 2.0.15, on 2026-09-24, on macOS with Colima, Docker Engine 29.2.1, linux/arm64. OpenChamber moved to OpenCode 2 after stage 3a, and three live tests and the credential search still described OpenCode 1:
|
|
1058
|
+
|
|
1059
|
+
- A session takes its directory from `location.directory` in the body of `POST /api/session`, and reports it back byte for byte there and in the list for that directory. `?directory=` on that route is ignored, and such a session lands in `/home/space`.
|
|
1060
|
+
- Through a symlink OpenCode 2 reports the link path itself, `/spaces/<id>/link-to-repo`, with `subpath: "../link-to-repo"`, and leaves that session out of the list for the real path. OpenCode 1.18.31 reported the real path. OpenCode normalises nothing any more, so handing it real paths only is the whole defence, and it is the dispatcher stage's job.
|
|
1061
|
+
- OpenCode 2 has neither `/api/instance/dispose` nor `/api/experimental/tool/ids`, and it no longer loads `.opencode/tool/*.ts` at all. A project plugin in `.opencode/plugin/` or `.opencode/plugins/` that imports `@opencode/plugin` loads from the link after `POST /api/location/reload`, and `/api/plugin` reports it `active`. Without the link it reports `failed` at once, with no download attempt.
|
|
1062
|
+
- OpenCode 2 keeps logins as rows of the `credential` table in `~/.local/share/opencode/opencode.db`, a SQLite database in write-ahead-log mode, so a new row lands in `opencode.db-wal` first. In a fresh space the table has no rows. A row written there is listed by `GET /api/integration` as a connection of that integration, but only when OpenCode's catalog knows the integration: a key stored for a provider id of the user's own, such as `acme-llm`, is listed nowhere, so the test reads the table itself. `grep -I` does not see it, `grep -a` does.
|
|
1063
|
+
- A provider key in OpenCode's environment, `OPENAI_API_KEY` for example, is listed as a connection of type `env`. The catalog of 2.0.15 names 218 such variables for 226 integrations. `OPENCODE_AUTH_CONTENT` appears nowhere in the 2.0.15 binary. `OPENCODE_CONFIG_CONTENT` does, and a provider key inside it is a login too; `OPENCODE_CLI_CONFIG_CONTENT` also exists and was not tried. The hardening checker therefore allows only the variables the space is given and the base image's own.
|
|
1064
|
+
- No route of 2.0.15 stores an oauth record handed to it from outside. `/api/credential/<id>` takes a new label, and has `activate` and `DELETE`; a login is stored only by the flows under `/api/integration/<id>/connect/`. How the host will put a short OpenAI token into a space is therefore open again, see DESIGN.md.
|
|
1065
|
+
|
|
1066
|
+
Not verified with OpenCode 2.0.15: the rewritten credential test and the rewritten tests of the server inside have run on macOS with Colima only, not on Debian over `DOCKER_HOST=ssh://` and not with the module on a Windows host.
|
|
1067
|
+
|
|
716
1068
|
## Base image
|
|
717
1069
|
|
|
718
1070
|
`SPACE_BASE_IMAGE` in `places/docker.js` is `node:22-bookworm` pinned as `node@sha256:dd5847a04b0deee391fa145f1f4c6d214196668b6bcc7988ebed67249f226844`.
|
|
@@ -733,7 +1085,7 @@ OPENCHAMBER_TEST_DOCKER=1 bun run --cwd packages/web test -- server/lib/spaces
|
|
|
733
1085
|
OPENCHAMBER_TEST_DOCKER_PACKED=1 bun run --cwd packages/web test -- server/lib/spaces/places/packed
|
|
734
1086
|
```
|
|
735
1087
|
|
|
736
|
-
The second command also runs the live files against the local Docker daemon: `places/contract.docker.live.test.js`, `places/escape.docker.live.test.js`, `places/server.docker.live.test.js`, and since 3a `places/code-in.docker.live.test.js`. The third runs `places/packed.docker.live.test.js`. It compiles the sdk and packs the local `web` and `sdk`, so it needs the workspace dependencies installed.
|
|
1088
|
+
The second command also runs the live files against the local Docker daemon: `places/contract.docker.live.test.js`, `places/escape.docker.live.test.js`, `places/server.docker.live.test.js`, and since 3a `places/code-in.docker.live.test.js`. Since 3b it also runs `places/code-out.docker.live.test.js`, since 4a `places/dispatcher.docker.live.test.js`, since 4b `places/sessions.docker.live.test.js`, and since 5b `places/grants.docker.live.test.js`: a space made through the journey, a real key behind the window against the stand-in upstream, which sees its hash, the key absent from the record, both containers' metadata and everything the space can read, a turn of OpenCode inside arriving at the stand-in with the key, a provider granted after the instance started, measured, and the grants said again after a stop and a start. The escape suite gained two tests in 5b: a neighbour on the outer network refused on the gatekeeper's outer address on all three ports while the space still reaches both listeners by name, and an opened domain that gets no credential from the window. The third runs `places/packed.docker.live.test.js`. It compiles the sdk and packs the local `web` and `sdk`, so it needs the workspace dependencies installed.
|
|
737
1089
|
|
|
738
1090
|
Each live file uses an owner id of its own, removes its spaces, helper containers, and tools volumes in `afterAll`, and then fails if any container, network, or volume with that owner label remains. An owner of its own also means a tools volume of its own, so every live file pays for one fill of about 40 seconds. The first run pulls the image, about 1.6 GB. The whole live run is timed in "Verified and not verified" above.
|
|
739
1091
|
|
|
@@ -741,21 +1093,41 @@ Each live file uses an owner id of its own, removes its spaces, helper container
|
|
|
741
1093
|
|
|
742
1094
|
`places/fake-docker.js` answers like the docker CLI for the subcommands the place uses. It knows tools volumes and their fill marker, the three one-shots, container ids, `rename`, `network connect`, a container name that is in use, a volume that is in use, a CLI that cannot reach the daemon and exits with 1, the server inside a space, which answers `/health` once its token is there, and a gatekeeper, whose control channel answers once its program has been written and whose tmpfs is empty again after a stop. Its `wait` moves its own clock, so no test sleeps. `docker.test.js` uses it for tools reuse, fill, fill failure, interrupted fill, a stranger's volume, two creates that share one fill, the readiness timeout, the token that never shows in an argument, a marker check that Docker could not run, a marker for other content, a tools volume that came back without labels, and the move to new tools with its rollbacks, including the old container that got its name back before the create landed.
|
|
743
1095
|
|
|
744
|
-
`
|
|
1096
|
+
`dispatcher.test.js` runs the dispatcher in front of a stand-in for the server inside: an Express app behind the host's own `createUiAuth`, so the login, the cookie name with the port in it and the refusal of a wrong token are the real ones, reached over a plain socket. It covers the prefix and the credentials coming off and the session going on, one login and one connection pool for many requests, a three-megabyte body streamed in, a form-encoded body kept whole by the parser predicate, an event stream read event by event with the request inside ended when the client leaves, a four-megabyte event stream whole, both guards in every spelling they take with their controls, the host-only and refused routes with their neighbours forwarded, an unknown id and one that appears, the cookie stripped and the page and raw-file content types, a redirect and `Clear-Site-Data` and `Refresh` never leaving the dispatcher for any method, an answer without a content type served as text, a stopped space answered with its code while a three-megabyte body is still on its way in, the renewed session and the repeated request after a 401, the body request that is not repeated, the renewal before expiry, the codes for a stopped space and an unreachable server, and the token absent from every answer and log line when the login fails. Each guard's test was run once with its guard removed and went red; so did the parser predicate's test, where the request hangs without it. `command-stream.test.js` runs `openCommandStream` and the bridge on the host's Node: bytes both ways, the error with the command's stderr, the kill on destroy, the idle timeout, the spawn failure, HTTP over a keep-alive agent with one process for many requests, the four-megabyte full-duplex exchange compared by sha256, `ECONNREFUSED` reported, and the command gone when the host lets go and when the server closes. `host.test.js` covers the owner file and the wiring over the memory place, with no process started, and since 4b, over a stand-in place whose spaces are Express apps behind the real UI auth, the merged list with its drops and the stale mark of a stopped space, the event connections into the real hub with the host's ids learned from it, and a socket forwarded through the mounted upgrade handler. `websocket.test.js` runs the forwarder against such a stand-in with an echo socket: the handshake built without the user's credentials and with the loopback origin, a header with a line break refused, the answer parsed with the bytes after it, the close frame's bytes, the terminal socket forwarded with the query token gone and the space session on, a 4 MiB frame both ways, every refusal before the upgrade with its code, the place's and the space's own refusals, the host's auth and origin rules per socket, the session renewed once after a 401, the close frame when the stream inside dies and the stream killed when the client leaves, and every socket ended at close. `space-sessions.test.js` is the table of what a space may claim: each directory spelling kept or dropped, a host id never taken, an id another space listed dropped, the field allowlist, each event rule, the merge order and marks, and the index's stale, partial, unknown and forgotten states. `space-events.test.js` runs the connection against a stand-in stream and the real hub: the announcement, the status seed, an event entered with the space's id and directory, the three drops with the directory absent from the log, the backoff after the stream ends with the gap announced, the connection dropped for a space that is gone, and a refused connection retried quietly. `places/sessions.docker.live.test.js` is the live file of 4b, see "Verified and not verified".
|
|
1097
|
+
|
|
1098
|
+
`space-server.test.js` feeds the channel every hostile answer listed under "The exec channel", and `gatekeeper-channel.test.js` does the same for the gatekeeper's channel, including a journal that is not JSON, one without records, records of another shape, one larger than the host reads, since 5a the `since` field taken as text only, and since 5b the bind file written before the program with every address that is not one refused on the host, an opened domain sent with no header and no secret, and the policy read as data with its lists capped and a broken one refused. `space-opencode.test.js` covers the configuration built from the grants and the fixed script that writes it.
|
|
1099
|
+
|
|
1100
|
+
`journey.test.js` runs the journey over the memory place with recording stand-ins for the gatekeeper channel, code in and code out: the order of the steps and the events of a create, what the record holds after it, a history that fails alone, a failure at the network or at code in that removes the space and lists the failure until it is dismissed, an unavailable place, an allowlist refused without host isolation, each bad field of the request refused before anything runs, stop and start with the network said again, a start without a record, the history sent again at start, remove with the refs and the record and with refs that would not go, every action refused while a creation is held at code in, stop all with one space that would not stop, no creation after the turn-off began until it is undone, a failed creation whose clean-up failed removed at its dismissal, the journal of a running and of a stopped space, the apply preview, the apply as a branch with the removal after it, as changes with the space kept, no removal when there was nothing to apply or the apply refused, and a remove, a stop and a journal read refused as busy while an apply holds the space. Since 5b also the grants: a typed key and an environment key delivered to the stand-in gatekeeper, remembered without their values and OpenCode inside told; the same provider replaced and a domain opened with no header; every bad request, a stopped space, a missing environment variable and an unreadable record refused; a refused grant not remembered; the grants said again after a start with the typed one and a variable no longer set in `needsAccess`, and a gatekeeper that refuses at the start leaving the space running; and the list asking only where there is something to ask and answering unknown on a failed read. `routes.test.js` runs the routes over a stand-in journey: every route 404 while the feature is off with the switch still answering, the switch's preview and its turn-off, each route translated into its journey call, an id that is not a space id and a body that is not an object refused before the journey is asked, and the status of every code, the grant route among them since 5b; and the switch controller: the unknown list when the place cannot be asked, one host built for two turn-ons at once and closed once for two turn-offs, the order of stop, write and close, and a setting that cannot be written leaving the feature on and open. `space-records.test.js` covers the file, its mode, the update, an unreadable file never taken for a fresh record, the id check, the domain rule, and since 5b a grant remembered without its value, a value under any name refused, and a record from before grants read back with none.
|
|
745
1101
|
|
|
746
|
-
`gatekeeper-program.test.js` covers the program two ways. It requires the file and calls the rules directly: every blocked range with its edges, every IPv6 embedded form, the public addresses that must stay allowed, `refuseTarget` for both modes and both ports, thirty target forms that must be refused as addresses or non-names, including the integer, hex, short and octal spellings, ten ordinary names that must still pass, the address-family choice, and what `splitTarget` refuses. The program runs its listeners only as the container's main module, so requiring it binds nothing and there is no switch that changes a decision. Then it spawns the program the way `tools-filler.test.js` runs the filler and reads decisions back from its own journal: a name that does not resolve, `localhost` as the name that proves the check is on the resolved address, exact-match domains, the port rules of both modes, `auth.openai.com` by name and in fifteen address and non-name forms, the three Unicode full stops, which Node's own parser refuses before any rule of ours sees them and which are therefore checked against the rules directly, a plain proxy request, malformed CONNECT lines, a reset in the middle of the handshake, a 200,000 character header block, a flood of 600 sockets on each listener, the window's header rules against a local upstream, a grant that points back at the gatekeeper, journal redaction, the ring buffer's overflow,
|
|
1102
|
+
`gatekeeper-program.test.js` covers the program two ways. It requires the file and calls the rules directly: every blocked range with its edges, every IPv6 embedded form, the public addresses that must stay allowed, `refuseTarget` for both modes and both ports, thirty target forms that must be refused as addresses or non-names, including the integer, hex, short and octal spellings, ten ordinary names that must still pass, the address-family choice, and what `splitTarget` refuses. The program runs its listeners only as the container's main module, so requiring it binds nothing and there is no switch that changes a decision. Then it spawns the program the way `tools-filler.test.js` runs the filler and reads decisions back from its own journal: a name that does not resolve, `localhost` as the name that proves the check is on the resolved address, exact-match domains, the port rules of both modes, `auth.openai.com` by name and in fifteen address and non-name forms, the three Unicode full stops, which Node's own parser refuses before any rule of ours sees them and which are therefore checked against the rules directly, a plain proxy request, malformed CONNECT lines, a reset in the middle of the handshake, a 200,000 character header block, a flood of 600 sockets on each listener, the window's header rules against a local upstream, a grant that points back at the gatekeeper, journal redaction, the ring buffer's overflow, what the control channel refuses, and since 5b a grant without a secret that adds no header and still takes the space's credentials away, the grant's own header name taken away as well, the policy read back without a secret, and the bind file: the program listens on the address it holds and does not start when it is missing, empty, every interface or not an address. A tunnel that carries bytes needs a name that resolves to a public address, so the tunnel cap and every tunnel that carries something live in the escape suite, inside real containers.
|
|
747
1103
|
|
|
748
1104
|
`tools-filler.test.js` runs the real filler program with Node against temporary directories, with a stand-in `npm` on PATH.
|
|
749
1105
|
|
|
750
|
-
`places/
|
|
1106
|
+
`places/dispatcher.docker.live.test.js` mounts the dispatcher over a real space, the way `server/index.js` mounts it, and covers a prefixed request answered by the server inside, a session created inside through a streamed body and listed back, the event stream of the server inside, the guard against a directory outside the space and a host-only route, the escape check of TESTING.md, a request without the prefix but with the space's directory refused before a host route that counts its runs, the bridge processes inside counted from `/proc` and gone after the host closes its connections, and a stopped space answered with 503 and served again after a start. Its second part starts the real server headless, `index.js --api-only`, with `DOCKER_HOST` pointed at a stand-in daemon on a Unix socket that counts its connections: with the switch off the prefixed request falls through to the host, the unprefixed one runs, the daemon sees no connection and no owner file is written; with the switch on the prefixed request is the feature's 502, the guard refuses, the host-only route is refused, and the daemon was asked.
|
|
1107
|
+
|
|
1108
|
+
`places/server.docker.live.test.js` covers `/health`, the token file, the session directory question of stage 0 through OpenCode 2's `location` body, a symlinked directory, a project plugin that imports `@opencode/plugin` and must load with no download, the terminal WebSocket, stop and start, the move to a second tools key at the next start, and a failed fill that leaves nothing. The terminal probe is a small Node script that runs inside the space and takes `ws` out of the tools volume.
|
|
751
1109
|
|
|
752
1110
|
`code-in.test.js` runs real host git in temporary directories, with no Docker, against the bait repository of `code-in-bait.js`: a committed file with the executable bit, an ignored `.env` with a fake secret, `node_modules/`, a file that only a global ignore file names, set through `GIT_CONFIG_GLOBAL`, a staged new file, a file with staged and unstaged edits, a deleted tracked file, an untracked file with a space in its name, a Unicode name, and a symlink except on Windows. The bait is built once per object format and copied into each test, because building it was most of the cost. The tests cover the comparison of host states itself, the two snapshot commits, the host ref and its removal, the clean mode, a user who signs every commit with a broken `gpg.program`, a user with no identity, every refusal with the host state compared, an ignored and an empty project subfolder, untracked repositories with odd names and without a commit, SHA-256, a linked worktree, a split index, intent-to-add, a detached HEAD, a project in a subfolder, the list of travelling files against what the snapshot adds, the limits, the URL builder, a shallow host, the git version floor with build suffixes, a temporary folder that cannot be made or removed, every failure of the history keeping its code, step and cause, a failure of the place or of the runner during `bringCodeIn` becoming `code_transfer_failed` with its code as the cause, a folder that does not exist, and a git that cannot be started. The transfer tests run against a stand-in for a space in a local folder: its `execArgv` points at a small Node receiver that checks the fixed command and runs the host's `git receive-pack`, and its `exec` runs the fixed scripts with the host's `sh`, so they are switched off on Windows. They cover the whole transfer and the history, the identity a user has in full, in part or not at all, the start agreeing with the working tree inside when untracked repositories stay behind, thirteen global hooks of the user's that must not run, SHA-256, a linked worktree, a detached HEAD, a space that refuses, one that prints without end, one that never answers, where a grandchild of the receiver must be dead afterwards, a project path that is taken, a history whose unshallow fails inside on a held `shallow.lock`, a user's fsmonitor program and daemon, a user who asks for automatic gc and maintenance at every chance, two concurrent history calls sharing one push, a repeated call answering `already_complete` without a push, and no stray `master` in the space's reflog. The refusing and flooding tests also check that the receiver really ran the command the host wrote out, so a receiver that never started cannot pass for one that refused.
|
|
753
1111
|
|
|
754
1112
|
`run-command.test.js` shows the process-tree kill with a real grandchild, which survives without `killTree` and dies with it, the child gone by the time the rejection comes, a grandchild that escaped the group ended by the close wait's bound, and a wrapper process whose `killTree` child and grandchild die with it when it leaves by `process.exit` or from its own SIGINT handler, and checks the pid guard against fakes for pid `null`, `undefined`, 0, 1, negative, fractional and string pids on every platform.
|
|
755
1113
|
|
|
1114
|
+
The eighth round, the rule path by path and the read-back by bytes, added a block of its own, `applyAsChanges after round seven`: the ordinary reviewer's matrix done with git, a pull, a switch of branch, a stash popped, applied over or dropped, an amend, a hard reset over a commit that brings the whole work, a revert that counts as the user's, and a commit followed by a deletion or a move, each with what the next apply brings; the verifier's apply from a second linked worktree, whole there and refused as a part in the first; a recorded interrupted apply keeping the HEAD of its intent; an attempt that wrote nothing after the second of two applies was thrown away; an invisible change beside a visible one, kept; `describeApplyState` reading nothing back on a closed route; a listing too long and a read-back time shared by the whole call, each `changes_undecided`; a clean filter that prints 200 KB a file on stderr; a kept last apply read by bytes while a slow clean filter never runs, counted through `files`; a path below a link read as gone; a name with a line break; a change of the exec bit alone where `core.filemode` is off; the time checked while one slow file is read; an LFS-like filter's second round refused with its own message; and three tests of the attributes read back with: a thrown-away `.gitattributes`, `.lfsconfig` and `.gitmodules` that name or configure a filter, a `.gitattributes` an interrupted apply never wrote, and one the user kept, which is honoured. Eleven of them are red on the seventh round's code.
|
|
1115
|
+
|
|
1116
|
+
The ninth round added `applyAsChanges after round eight`: a file of the space named like pathspec magic and a name with a line break, each undone by a commit of the user's and taken for the user's edit; a committed undo beside one without a commit, refused as a part, with no advice about clearing the way for a file committed as it is; a clean filter that fails while reading back, `read_back_failed`; the list of the user's commits read whole past four megabytes, and one past the cap, `changes_undecided`; the log asked once per call over every path and not at all when nothing is back as before, counted; a HEAD with no commit; a FIFO and a link swapped in between the host's `lstat` and its open, each `changes_undecided` at once; and a clean filter that never ends, required gone after a read-back, a dry run and an apply that each ran out of time. The LFS-like test gained a second file of the filter that fits, which the refusal must not name. Seven of them, and the two older tests of that refusal's message, are red on the eighth round's code; the others pin behaviour that was already right, and each is red under a mutation of the line it guards.
|
|
1117
|
+
|
|
1118
|
+
The seventh round, "thrown away" decided from authoritative state, added a block of its own: `git restore .` of the last apply with its added file left and a redo that touches only another file, refused and closed with both parts named; an edit on a file of the last apply, kept; every apply thrown away, the whole work from the start; the last apply committed and then put back by hand, kept, and committed with one file deleted, an edit: applied beside it, and refused by the dry run once the redo touches that file; a last apply of a gitlink only, kept; a CRLF-only change under the agent's `text eol=lf`, kept, alone and beside a change that can be seen; an attempt that wrote nothing followed by a throw-away of the apply before it, taken for not started; `describeApplyState` beside an interrupted apply read as a part, counting from the last apply; the verifier's closed route that stays closed after a throw-away, its read-only read beside an intent, its failed intent write and failed recovery record, and its file of the patch that is a link on the user's disk; the folder cap at its exact number; two linked worktrees taking turns; a dry run that fails on 8,000 long paths, for an apply and for a recovery; a read-back and a dry run that do not end in time; a slow clean filter; and an operation count that shows the check of all applies stopping at its first path. `firstRedirectedFolder` gained the exact spelling of a deleted link, and `run-command.test.js` the output window of `keepTail`.
|
|
1119
|
+
|
|
756
1120
|
`places/code-in.docker.live.test.js` runs each of its tests alone as well as in order: the shared code in happens in `beforeAll`, and each hanging test makes a space of its own. It brings the bait into a real space: `git status --porcelain=v1 --untracked-files=all` inside equals the host's line for line, `run.sh` is 755, the name and email are set, and `.env`, `node_modules` and the globally ignored file are absent. The history arrives: the commit count matches, the repository is not shallow, and the side repository is gone. The secret test collects the space's work directory and HOME as a tar, and every git object uncompressed, gzipped on the way out, and searches them on the host; its controls find a travelling file's text in the tar, and the text of a tracked file the bait deleted in the objects and not in the tar. Two tests plant a pre-receive hook that never ends through a global `core.hooksPath` in the space's HOME, one for the history and one for code in, each in a space of its own, with the host timeout and the inner margin sized from the slowest of three `docker exec` round trips measured at the start, and never below 6 s and 5 s, and check the timeout, the host ref and host state, the process tree on the host, and what runs inside before and after the inner limit.
|
|
757
1121
|
|
|
758
|
-
`
|
|
1122
|
+
`run-command.test.js` also covers the abort option of 3b: a signal that is already aborted starts nothing, where the control run of the same command does; an abort during a run kills the whole tree, the child gone by the time the rejection comes and the grandchild after it, with the rejection being the reason itself and the live set empty; without `killTree` the child alone goes; an abort after the run closed changes nothing; an abort after the timeout keeps the timeout as the error; and no listener stays on the signal after a close, a timeout, an abort or a spawn failure. A reason that is not a `SpaceError`, a string, null or nothing at all, rejects with `command_aborted`, before the start and during the run. On Windows the tree test runs the real `taskkill /T`.
|
|
1123
|
+
|
|
1124
|
+
`code-out.test.js` runs real host git in temporary directories, with no Docker. The apply tests run on every platform against a result made on the host, fetched into the result ref of a space whose start the snapshot of code in wrote: a patch that writes exactly the result tree, with a changed, an added, a deleted, a binary and an executable file and a link to outside, while the index, HEAD and `.git` stay as they were; nothing to apply; three unclean cases that touch nothing; the three `apply.*` settings and `diff.noprefix` and `diff.relative`, each first breaking the obvious command; a link of the user's where the space writes a folder; SHA-256; the branch with its exact changes, an existing branch, twelve bad names, a missing result, and a linked worktree. The filter test is POSIX only. The transfer tests run against the stand-in for a space of `code-in-bait.js`, extended with an `upload-pack` side that can refuse, flood, hang with a grandchild, or send slowly: the whole path with commits, uncommitted and untracked changes in the space and both applies; no extra commit when nothing is uncommitted; a shallow space; a forged `shallow` file; ten hostile tree names and the control with an ordinary name built the same way; tags and refs outside the refspec, with an ordinary fetch as the control; a refusal and a flood; a space that never answers, whose grandchild must be dead afterwards; a transfer stopped by the size cap in the middle, which the stand-in's `upload-pack` shows by never reporting its end, with the same space coming out with room as the control; a file of zeros, a large file committed and deleted, many copies of a user's file, a megabyte of commit message and a tree with forty thousand entries over the changed-bytes cap, each with the control of a larger cap; a transfer over the cap that arrived in one piece, which the cap after the fetch is what refuses; a repository the agent made inside its project and the paths of a conflicted merge, both reported and both applied as they are; the name check against a 200,000-file tree with one new file, the length rules on every platform and the Windows path rule with and without `core.longpaths`, a new file in a folder the user already has in two spellings, a rename that changes only the case of a file and of a folder refused with the route open and applied after a new name, a folder stored decomposed on macOS, tracked and untracked, applied rather than taken for a link, an ignored file in the way named as such, an interrupted apply that wrote nothing, one that stopped halfway and one that finished unrecorded, `describeApplyState` before and after an apply and with an intent and a closure, and a hook that writes to descriptor 3 with the planted commit refused; every Windows name rule through the check with the platform given, each also let through on macOS and Linux, ordinary names with spaces, Unicode, leading dots and brackets let through everywhere, names that differ only in case and the two spellings of an accent, a case collision and a name with a line break refused in a result made with git's plumbing, the route left open and the renamed file applied after it, and the same through a real bring-out; a link of the user's on the way to a path the patch adds, deletes, or reaches several folders down, refused before any patch and closing the route, a link that points inside the work tree, a link beside the patch's paths that is none of its business, and each of the two ways the check sees a redirection proven alone against fakes; a refused round closing the route, the next call refusing at once with no patch built and nothing touched, the branch working afterwards and holding the same commit, the closure surviving a new bring-out, and a space that was never refused keeping its route; a second apply that brings only the new work while the user's own file beside it stays, the same result applied twice, a second apply that collides, and the applied ref written only on success, moved by no branch and removed with the other two; a result the space swaps for another between the snapshot and the fetch, with the unswapped run as the control; one object that is not a file over its own cap; what a deletion costs and the apply refusing it; many copies of a file the user already has, refused by the apply and not by the bring-out; a hook that prints while the snapshot runs, with a control that it really ran; a repository and a conflicted path the space names with a line break, counted and shown redacted; what an apply reports about nested repositories and conflict markers; an apply that stops in the middle in a folder that cannot be written, with the writable run as the control; and a host git that fails where code out has to answer for it, for the patch it could not build and for the ref it could not write; too many paths and too many objects; bad requests, which never reach the space; an unreachable space and a failed snapshot inside; a temporary folder that cannot be removed; SHA-256; a project in a subfolder of a linked worktree; the user's hooks and fsmonitor; and gc, maintenance and commit graphs.
|
|
1125
|
+
|
|
1126
|
+
The fifth round of review added, in the same file: a result through more folders than the cap, and one whose folders lie too deep in all, each refused before anything is built with the branch holding it and each applying with room for it; the measured shape that ran the host out of memory, refused at the default caps in seconds; a bring-out that refuses neither; each APFS pair of the fold, as a name clash and as a case-only rename, and the same pairs on this Mac's own disk with the control that the disk ignores case; the link check across several paths, past a file at the top, with a link the patch deletes and the controls where it does not; a tracked link the space turned into a folder, applied; two applies and a read at once, one applied and one with nothing left; an interrupted apply with an edit of the user's since, taken for never started and for finished, a file of repeated lines it wrote that the dry run alone would take again, the closure at the interrupted result and not a newer one, and the intent gone after an apply that stopped; an intent beside a closed route; the list of changes longer than the host reads, for the apply's message and for `describeApplyState`; the path length with the project's folder in front, Windows' 259 and 247 at their boundaries, and `core.longpaths` read by the apply; and a branch name that a branch folder is in the way of.
|
|
1127
|
+
|
|
1128
|
+
The sixth round, the maintainer's decision on a thrown-away apply, added a block of its own: the whole last apply thrown away and the work applied again whole, with the route open and `describeApplyState` counting the same before and after; part of it thrown away, where the whole work would fit and leave the user's kept file behind, refused and closed; a throw-away beside an edit on another file, applied; one beside an edit on a path of the last apply, where the whole work would fit, refused; two applies with only the second thrown away, which brings the second round again from the first; no throw-away, the ordinary way, with each apply's base recorded; the whole work after a throw-away even where the patch from the last apply would fit; the same result applied again after a throw-away; a repository with no recorded base, as before; the caps held against the whole work, refused with the route open; and a host that dies before or right after `git apply` writes the whole work, read against the recorded base: finished and recorded, never started and applied again, or halfway and closed with both halves of the intent gone.
|
|
1129
|
+
|
|
1130
|
+
`places/code-out.docker.live.test.js` gives every test a space of its own and a bait repository of its own, and removes the space at the end of the test. It brings the agent's work out of a real space and applies it both ways, with the status inside unchanged, the branch holding mode 100755, and the host's `git status` showing what came out as uncommitted changes; brings work out of a space whose history has not arrived; plants a hook, a filter, a hooks folder and config in the space, plus tags and a branch, and sees the hook run inside and nothing on the host, and no ref but the result; refuses seven hostile tree names built inside past git's path checks, with the ordinary name as the control; refuses a link of the user's where the space writes a folder, with the result holding the file and a link to `/etc/passwd` as the control; ends a fetch that a planted `uploadpack.packObjectsHook` holds forever, sized from measured `docker exec` round trips like code in, and checks the host processes and what runs inside before and after the inner limit; refuses a moved result when a planted hook writes a commit id of its own to its parent git's stdout through `/proc`, with the hook's own marker as the control; stops a space whose planted hook sends a pack without end at a 4 MiB cap in seconds, against a 90 s deadline, which only the polling cap can do; and stops a 16 MiB transfer at a 4 MiB cap, with the default cap as the control.
|
|
759
1131
|
|
|
760
1132
|
Escape tests run inside a real space and pass only when the attempt fails. A positive control backs the attempts that could pass for the wrong reason:
|
|
761
1133
|
|
|
@@ -769,7 +1141,7 @@ Escape tests run inside a real space and pass only when the attempt fails. A pos
|
|
|
769
1141
|
- The listener test first finds the server's own loopback listener in `/proc/net/tcp`.
|
|
770
1142
|
- The gatekeeper tests open with a positive control that the corridor carries a real CONNECT to an allowed domain, so every refusal that follows is a refusal and not a broken corridor. A 502 there ends that test as inconclusive, naming the host and the answer, because it means the corridor allowed the target and the Docker machine could not reach it. Measured on a Debian machine over `DOCKER_HOST=ssh://`: exactly that happened once, for the product service that control used to carry to, and the run reported a failed restriction when the restriction had worked. Both names it carries to now are IANA's reserved documentation domains, so the live suite depends on one operator rather than two.
|
|
771
1143
|
- The refused name `auth.openai.com` is tried by its real address as well, resolved on the host at test time, in four forms and in both modes, with the gatekeeper's own reach of that address as the control. The test fails as inconclusive if the name does not resolve here. This is the escape a reviewer found in the first draft: the name was refused and the address was not. The allowlist-mode test says the name is unreachable; the open-mode test says only that these two forms are refused, because in that mode nothing can say more.
|
|
772
|
-
- The long-lived credential test searches the space's HOME, `/tmp`, its work directory and every process environment for the shape of an oauth refresh record, a private key, a GitHub token or a provider key, and for OpenCode's auth
|
|
1144
|
+
- The long-lived credential test searches the space's HOME, `/tmp`, its work directory and every process environment for the shape of an oauth refresh record, a private key, a GitHub token or a provider key, and for OpenCode 1's `auth.json` by path, which OpenCode 2 imports once if it finds one. The file search reads binary files too, because OpenCode 2 keeps logins in the `credential` table of its SQLite database `~/.local/share/opencode/opencode.db`, and `grep -I` skipped that file. It also asks OpenCode for the connections of every integration, which covers a stored login and a provider key OpenCode found in its own environment, and reads every row of the `credential` table, integration and kind only, with Node's built-in SQLite, because OpenCode lists only integrations its catalog knows. It requires none of either. The clean search runs first. Then the control writes two rows into the database OpenCode is using, with a five-second busy timeout, because no OpenCode 2 route stores an oauth record without a real login: an OpenAI oauth row with a decoy refresh token, and a key for the unknown provider `acme-llm`. OpenCode must then list the first as a connection, which shows it is the store OpenCode reads, the table read must return both, and the search must find the refresh shape in `opencode.db` or its write-ahead log, next to a planted private key and a decoy key in a process environment. The order matters: SQLite leaves a deleted row's text readable in the write-ahead log, so a clean search after the control would find the decoy. That is also why the search reads those files at all, since a login a user removed can still be there. Stage 2 puts no such credential inside, so the test guards the one guarantee that holds in both modes. The clean-up must report both rows removed. Measured: the old text-only search misses the planted row, a provider key added to the space environment turns the connection check red, and without the table read the custom provider's key goes unseen.
|
|
773
1145
|
- The tunnel cap is tested by opening 80 tunnels at once to an allowed domain from inside the space. It passes only when some were established, none beyond the cap, the journal holds `deny:too-many-tunnels`, and the corridor carries a tunnel again once the space lets go.
|
|
774
1146
|
- The cross-space tests make a second space of the same owner, about three seconds here because both share this owner's tools volume, and look from the first at everything the second has: its space address, both of its gatekeeper's addresses, on the server, corridor, window and control ports, directly and through the first space's own corridor. The controls are that the other space's server answers, that each space reaches the corridor of its own gatekeeper at that same port, and that the shared name `gatekeeper` resolves to the space's own gatekeeper and never the other's.
|
|
775
1147
|
- The tunnel's teardown is covered by `joinSockets` in the unit tests, with socket pairs built the way the program builds them: the space-facing one half-open as an `http.Server` accepts it, the one out half-open as the program now asks `net.connect` for. Five cases: four megabytes carried whole while the space is still uploading, which is the only shape that shows the truncation; a half-close passed on in each direction with what follows still arriving; the place given back when one side dies under load; and the place kept while a half-closed tunnel is still carrying. It is tested there because no address this machine has is one the corridor would tunnel to, so a tunnel cannot be established any other way. Put the old teardown back and four of them fail; take away the part that tells a peer to go and one does.
|