@vantran-se/openchamber-web 2.0.4 → 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.
Files changed (243) hide show
  1. package/dist/assets/{CodeMirrorCommentWidgets-DZF8ntaq.js → CodeMirrorCommentWidgets-DCAQTLMM.js} +1 -1
  2. package/dist/assets/DiffView-uzNpHBlV.js +4 -0
  3. package/dist/assets/FilesView-Bkq0sjNx.js +17 -0
  4. package/dist/assets/{GitView-DwQwqDvu.js → GitView-BmibZC7n.js} +4 -4
  5. package/dist/assets/{GuestAttachDialog-CoXM0gh_.js → GuestAttachDialog-D7TJG1rt.js} +1 -1
  6. package/dist/assets/GuestSurfacePane-PAcYaFzP.js +1 -0
  7. package/dist/assets/{JsonTreeView-Cq_27v0y.js → JsonTreeView-BNT2yUVu.js} +1 -1
  8. package/dist/assets/{LinearIssuesView-CsSSRMhn.js → LinearIssuesView-D3Azksju.js} +1 -1
  9. package/dist/assets/MarkdownImageGallery-BGY6tuiK.js +1 -0
  10. package/dist/assets/MarkdownRendererImpl-DJmhQ37f.js +12 -0
  11. package/dist/assets/{OnboardingScreen-ClatMQAX.js → OnboardingScreen-D_41H2qC.js} +1 -1
  12. package/dist/assets/{PatchDiff-DOwlrIK9.js → PatchDiff-C0RSqa9W.js} +1 -1
  13. package/dist/assets/{PierreDiffViewer-CrcGqqD_.js → PierreDiffViewer-BBE7tTY2.js} +3 -3
  14. package/dist/assets/{PlanView-BGLsMgQk.js → PlanView-Bqc-xuDe.js} +4 -4
  15. package/dist/assets/PluginPane-DQ0k41Q-.js +3 -0
  16. package/dist/assets/SettingsView-BAiJU5hJ.js +30 -0
  17. package/dist/assets/{SettingsWindow-B0BAy-f9.js → SettingsWindow-DuC4ecLw.js} +1 -1
  18. package/dist/assets/{SubmoduleDiffSummary-fcdDzCSW.js → SubmoduleDiffSummary-0HgmcMXz.js} +1 -1
  19. package/dist/assets/{ToolOutputDialog-B7xaUz2H.js → ToolOutputDialog-DdHkQJEE.js} +6 -6
  20. package/dist/assets/{ToolPartDiffPreview-CkWCaUmZ.js → ToolPartDiffPreview-DWdN0r5K.js} +2 -2
  21. package/dist/assets/{VirtualizedFileDiff-DONC4eI2.js → VirtualizedFileDiff-ClDezf2d.js} +1 -1
  22. package/dist/assets/{WalkthroughView-CXDImOXW.js → WalkthroughView-BJicp0RC.js} +2 -2
  23. package/dist/assets/{appThemeRegistry-Br2TLSlb.js → appThemeRegistry-CiuyzfSW.js} +1 -1
  24. package/dist/assets/changesTree-CePHbFcj.js +1 -0
  25. package/dist/assets/commitAndPush-DTKb6IwT.js +1 -0
  26. package/dist/assets/{computeVirtualFileMetrics-_-20umCs.js → computeVirtualFileMetrics-kM_zJNMt.js} +2 -2
  27. package/dist/assets/de-BMnfXm-G.js +10 -0
  28. package/dist/assets/es-DKEvCCL6.js +16 -0
  29. package/dist/assets/fr-D9fgE2xE.js +14 -0
  30. package/dist/assets/index-DUke1nme.css +1 -0
  31. package/dist/assets/{index-OKDYjcMF.js → index-DWB8bR_g.js} +1 -1
  32. package/dist/assets/index-Di6zUe5h.js +41 -0
  33. package/dist/assets/{isDiffPlainText-D77M6q1f.js → isDiffPlainText-DOoIsGqs.js} +1 -1
  34. package/dist/assets/{isFilePlainText-C54B4Mns.js → isFilePlainText-bt5VHvcd.js} +1 -1
  35. package/dist/assets/ja-BEXad-KW.js +16 -0
  36. package/dist/assets/ko-DGrQ0lEe.js +16 -0
  37. package/dist/assets/{main-CGEvLMd2.js → main-BpLQbZC2.js} +2 -2
  38. package/dist/assets/main-CI9DtHJv.js +896 -0
  39. package/dist/assets/{markdown-shiki.worker-CahLvHIq.js → markdown-shiki.worker-Bg8SoAJJ.js} +8 -5
  40. package/dist/assets/markdownCore-DK2KK5RW.js +7 -0
  41. package/dist/assets/{miniChat-BSxkRQLj.js → miniChat-CIWaR8wG.js} +2 -2
  42. package/dist/assets/{mobile-C5KRTNOB.js → mobile-CnWKEYIP.js} +2 -2
  43. package/dist/assets/patchFileDiff-Cqn0C0az.js +13 -0
  44. package/dist/assets/pl-BlhkmR5b.js +16 -0
  45. package/dist/assets/pt-BR-Bs96Ew9I.js +16 -0
  46. package/dist/assets/{renderElectronMiniChatApp-CnkAQV6o.js → renderElectronMiniChatApp-D9t0-9nV.js} +2 -2
  47. package/dist/assets/renderMobileApp-CW7fVs_s.js +25 -0
  48. package/dist/assets/runtimeEndpointReset-D0PcCb3K.js +27 -0
  49. package/dist/assets/scheduledTasksApi-Dahv3ws0.js +1 -0
  50. package/dist/assets/{shikiHighlight-4O0ZM14D.js → shikiHighlight-l-MbaNS4.js} +1 -1
  51. package/dist/assets/{textMateThemeFromAppTheme-yNq7b0DZ.js → textMateThemeFromAppTheme-C-kGcOpD.js} +1 -1
  52. package/dist/assets/tr-CehUK1M9.js +7 -0
  53. package/dist/assets/uk-Bno5WGNz.js +16 -0
  54. package/dist/assets/{useAppFontEffects-Bzyszv2N.js → useAppFontEffects-DDjuwVqi.js} +160 -156
  55. package/dist/assets/{useInlineCommentController-zOf6ORAq.js → useInlineCommentController-Clg8EO3A.js} +2 -2
  56. package/dist/assets/{useRootScrollLock-DCaZ4BWi.js → useRootScrollLock-nDcRrgJ1.js} +2 -2
  57. package/dist/assets/{vendor-codemirror-language-data-6cEFPNUR.js → vendor-codemirror-language-data-oKF0MAwd.js} +2 -2
  58. package/dist/assets/vendor-opencode-client-DSZl7jSi.js +12 -0
  59. package/dist/assets/vendor-zod-BSoePkpP.js +39 -0
  60. package/dist/assets/{web-components-BEo4-ISu.js → web-components-Bc1WPd4i.js} +1 -1
  61. package/dist/assets/zh-CN-DnAweIWo.js +16 -0
  62. package/dist/assets/zh-TW-BbBBK25p.js +16 -0
  63. package/dist/index.html +5 -5
  64. package/dist/mini-chat.html +5 -5
  65. package/dist/mobile.html +5 -5
  66. package/package.json +3 -3
  67. package/server/index.js +82 -0
  68. package/server/lib/agent-tool/DOCUMENTATION.md +132 -47
  69. package/server/lib/agent-tool/runtime.js +34 -15
  70. package/server/lib/agent-tool/runtime.test.js +15 -0
  71. package/server/lib/browser-control/DOCUMENTATION.md +14 -2
  72. package/server/lib/event-stream/global-hub.js +23 -1
  73. package/server/lib/event-stream/global-hub.test.js +35 -0
  74. package/server/lib/event-stream/global-ws-bridge.js +3 -1
  75. package/server/lib/event-stream/upstream-reader.js +3 -2
  76. package/server/lib/git/DOCUMENTATION.md +1 -1
  77. package/server/lib/git/service.js +22 -0
  78. package/server/lib/git/service.test.js +8 -0
  79. package/server/lib/guests/DOCUMENTATION.md +4 -3
  80. package/server/lib/guests/catalog.js +19 -1
  81. package/server/lib/guests/catalog.test.js +22 -0
  82. package/server/lib/guests/html-styles.js +5 -3
  83. package/server/lib/guests/html-styles.test.js +3 -1
  84. package/server/lib/guests/routes.js +11 -3
  85. package/server/lib/guests/service.js +5 -0
  86. package/server/lib/guests/surface.js +55 -9
  87. package/server/lib/guests/surface.test.js +56 -6
  88. package/server/lib/notifications/DOCUMENTATION.md +10 -0
  89. package/server/lib/notifications/emit-route.js +118 -0
  90. package/server/lib/notifications/emit-route.test.js +112 -0
  91. package/server/lib/openchamber-control/actions.js +16 -2
  92. package/server/lib/openchamber-control/service.js +23 -0
  93. package/server/lib/openchamber-control/service.test.js +31 -0
  94. package/server/lib/openchamber-sessions/fork-inheritance.js +103 -0
  95. package/server/lib/openchamber-sessions/fork-inheritance.test.js +69 -0
  96. package/server/lib/openchamber-sessions/routes.js +11 -0
  97. package/server/lib/openchamber-sessions/routes.test.js +18 -0
  98. package/server/lib/opencode/bootstrap-runtime.js +14 -1
  99. package/server/lib/opencode/compatibility.js +6 -3
  100. package/server/lib/opencode/compatibility.test.js +8 -0
  101. package/server/lib/opencode/config-entity-v2.test.js +27 -0
  102. package/server/lib/opencode/config-paths.test.js +12 -0
  103. package/server/lib/opencode/config-v2.d.ts +2 -0
  104. package/server/lib/opencode/config-v2.js +42 -0
  105. package/server/lib/opencode/config-v2.test.js +17 -0
  106. package/server/lib/opencode/core-routes.js +20 -4
  107. package/server/lib/opencode/core-routes.test.js +41 -0
  108. package/server/lib/opencode/env-runtime.js +1 -8
  109. package/server/lib/opencode/feature-routes-runtime.js +2 -1
  110. package/server/lib/opencode/lifecycle.js +30 -2
  111. package/server/lib/opencode/lifecycle.test.js +27 -0
  112. package/server/lib/opencode/managed-config-file.js +18 -6
  113. package/server/lib/opencode/managed-config-file.test.js +23 -7
  114. package/server/lib/opencode/mcp.js +7 -2
  115. package/server/lib/opencode/npm-registry-config.d.ts +1 -0
  116. package/server/lib/opencode/npm-registry-config.js +123 -0
  117. package/server/lib/opencode/npm-registry-config.test.js +167 -0
  118. package/server/lib/opencode/npm-registry.js +5 -6
  119. package/server/lib/opencode/npm-registry.test.js +30 -0
  120. package/server/lib/opencode/openchamber-routes.js +29 -11
  121. package/server/lib/opencode/openchamber-routes.test.js +18 -0
  122. package/server/lib/opencode/project-directory-runtime.js +8 -0
  123. package/server/lib/opencode/project-directory-runtime.test.js +13 -0
  124. package/server/lib/opencode/provider-source-route.test.js +59 -0
  125. package/server/lib/opencode/providers.js +3 -0
  126. package/server/lib/opencode/providers.test.js +62 -0
  127. package/server/lib/opencode/proxy.js +38 -2
  128. package/server/lib/opencode/routes-upgrade.test.js +1 -1
  129. package/server/lib/opencode/routes.js +36 -1
  130. package/server/lib/opencode/server-utils-runtime.js +5 -0
  131. package/server/lib/opencode/settings-helpers.js +10 -0
  132. package/server/lib/opencode/settings-helpers.test.js +2 -2
  133. package/server/lib/opencode/settings-registry.json +17 -0
  134. package/server/lib/opencode/settings-runtime.js +1 -0
  135. package/server/lib/opencode/shared.js +49 -4
  136. package/server/lib/opencode/shared.test.js +78 -1
  137. package/server/lib/opencode/shutdown-runtime.js +3 -0
  138. package/server/lib/opencode/skill-routes.js +28 -12
  139. package/server/lib/opencode/skill-routes.test.js +121 -1
  140. package/server/lib/opencode/upgrade-capability.js +0 -14
  141. package/server/lib/opencode/v1-migration-topup.js +21 -15
  142. package/server/lib/opencode/v1-migration-topup.test.js +14 -6
  143. package/server/lib/opencode/v2-install.d.ts +1 -0
  144. package/server/lib/opencode/v2-install.js +8 -7
  145. package/server/lib/opencode/v2-install.test.js +13 -4
  146. package/server/lib/opencode/watcher.js +3 -1
  147. package/server/lib/opencode/websearch-config.js +13 -1
  148. package/server/lib/quota/providers/kimi.js +1 -1
  149. package/server/lib/realtime-proxy.js +5 -1
  150. package/server/lib/realtime-proxy.test.js +29 -0
  151. package/server/lib/relay/tunnel-host.js +4 -1
  152. package/server/lib/relay/tunnel-host.test.js +11 -0
  153. package/server/lib/session-assist/DOCUMENTATION.md +17 -5
  154. package/server/lib/session-assist/runtime.js +19 -2
  155. package/server/lib/session-assist/runtime.test.js +8 -0
  156. package/server/lib/session-goal/DOCUMENTATION.md +11 -0
  157. package/server/lib/small-model/DOCUMENTATION.md +11 -10
  158. package/server/lib/small-model/index.js +27 -32
  159. package/server/lib/small-model/index.test.js +28 -16
  160. package/server/lib/spaces/DOCUMENTATION.md +404 -32
  161. package/server/lib/spaces/code-in-bait.js +145 -6
  162. package/server/lib/spaces/code-in.js +10 -150
  163. package/server/lib/spaces/code-in.test.js +7 -83
  164. package/server/lib/spaces/code-out.js +1819 -0
  165. package/server/lib/spaces/code-out.test.js +3945 -0
  166. package/server/lib/spaces/code-transfer.js +173 -0
  167. package/server/lib/spaces/command-stream.test.js +181 -0
  168. package/server/lib/spaces/dispatcher.js +670 -0
  169. package/server/lib/spaces/dispatcher.test.js +582 -0
  170. package/server/lib/spaces/gatekeeper-channel.js +61 -7
  171. package/server/lib/spaces/gatekeeper-channel.test.js +43 -7
  172. package/server/lib/spaces/gatekeeper-program.cjs +68 -14
  173. package/server/lib/spaces/gatekeeper-program.test.js +118 -3
  174. package/server/lib/spaces/hardening.js +20 -4
  175. package/server/lib/spaces/hardening.test.js +18 -3
  176. package/server/lib/spaces/host-git.js +7 -4
  177. package/server/lib/spaces/host-git.test.js +6 -2
  178. package/server/lib/spaces/host.js +310 -0
  179. package/server/lib/spaces/host.test.js +273 -0
  180. package/server/lib/spaces/journey.js +552 -0
  181. package/server/lib/spaces/journey.test.js +589 -0
  182. package/server/lib/spaces/layout.js +42 -8
  183. package/server/lib/spaces/manager.js +4 -3
  184. package/server/lib/spaces/manager.test.js +6 -0
  185. package/server/lib/spaces/places/code-out.docker.live.test.js +412 -0
  186. package/server/lib/spaces/places/contract-suite.js +36 -0
  187. package/server/lib/spaces/places/dispatcher.docker.live.test.js +296 -0
  188. package/server/lib/spaces/places/docker-live-support.js +27 -4
  189. package/server/lib/spaces/places/docker.js +33 -5
  190. package/server/lib/spaces/places/docker.test.js +42 -3
  191. package/server/lib/spaces/places/escape-suite.js +118 -14
  192. package/server/lib/spaces/places/fake-docker.js +3 -1
  193. package/server/lib/spaces/places/grants.docker.live.test.js +197 -0
  194. package/server/lib/spaces/places/memory-place.js +24 -1
  195. package/server/lib/spaces/places/registry.js +1 -1
  196. package/server/lib/spaces/places/server.docker.live.test.js +69 -33
  197. package/server/lib/spaces/places/sessions.docker.live.test.js +222 -0
  198. package/server/lib/spaces/routes.js +216 -0
  199. package/server/lib/spaces/routes.test.js +213 -0
  200. package/server/lib/spaces/run-command.js +261 -8
  201. package/server/lib/spaces/run-command.test.js +193 -3
  202. package/server/lib/spaces/space-events.js +153 -0
  203. package/server/lib/spaces/space-events.test.js +160 -0
  204. package/server/lib/spaces/space-opencode.js +52 -0
  205. package/server/lib/spaces/space-opencode.test.js +41 -0
  206. package/server/lib/spaces/space-records.js +150 -0
  207. package/server/lib/spaces/space-records.test.js +113 -0
  208. package/server/lib/spaces/space-sessions.js +210 -0
  209. package/server/lib/spaces/space-sessions.test.js +161 -0
  210. package/server/lib/spaces/websocket.js +329 -0
  211. package/server/lib/spaces/websocket.test.js +345 -0
  212. package/server/lib/ui-auth/ui-auth.js +8 -2
  213. package/server/lib/ui-auth/ui-auth.test.js +29 -0
  214. package/server/opencode-proxy.test.js +113 -0
  215. package/dist/assets/DiffView-D2p4Ar0H.js +0 -4
  216. package/dist/assets/FilesView-BeTtbsip.js +0 -17
  217. package/dist/assets/GuestSurfacePane-DIJvkkM-.js +0 -1
  218. package/dist/assets/MarkdownImageGallery-CY0dWTaE.js +0 -1
  219. package/dist/assets/MarkdownRendererImpl-ILdXzLrt.js +0 -12
  220. package/dist/assets/PluginPane-B39JEUL8.js +0 -3
  221. package/dist/assets/SettingsView-13B2_G-A.js +0 -29
  222. package/dist/assets/commitAndPush-CcI4ol7c.js +0 -1
  223. package/dist/assets/de-BtYnUgII.js +0 -10
  224. package/dist/assets/es-crP5qPye.js +0 -16
  225. package/dist/assets/fr-CLSRuALk.js +0 -14
  226. package/dist/assets/index-CLcM8HKx.css +0 -1
  227. package/dist/assets/index-CvLgnuNI.js +0 -41
  228. package/dist/assets/ja-SZ1-ucLV.js +0 -16
  229. package/dist/assets/ko-HthbT_Ve.js +0 -16
  230. package/dist/assets/main-BJklC7Cl.js +0 -896
  231. package/dist/assets/markdownCore-BzQuETm0.js +0 -5
  232. package/dist/assets/patchFileDiff-DGw2KryM.js +0 -13
  233. package/dist/assets/pl-C8eYfjL1.js +0 -16
  234. package/dist/assets/pt-BR-cYOrZdxS.js +0 -16
  235. package/dist/assets/renderMobileApp-CMxjwisj.js +0 -25
  236. package/dist/assets/runtimeEndpointReset-C45jrFq4.js +0 -27
  237. package/dist/assets/scheduledTasksApi-CkK1F9s2.js +0 -1
  238. package/dist/assets/tr-B4IQ5t-T.js +0 -7
  239. package/dist/assets/uk-DCoXG6ve.js +0 -16
  240. package/dist/assets/vendor-opencode-client-_BAHQo4R.js +0 -12
  241. package/dist/assets/vendor-zod-Gea9uBlY.js +0 -39
  242. package/dist/assets/zh-CN-BXBoxsFk.js +0 -16
  243. package/dist/assets/zh-TW-C6oelpnL.js +0 -16
@@ -0,0 +1,150 @@
1
+ // What the host remembers about a space beside the runtime's labels: the network choice the user
2
+ // made, so the host can say it again to a gatekeeper that starts with an empty memory, and where
3
+ // the code went in, so the history and the result find the same repository later.
4
+ //
5
+ // The labels stay the source of truth about which spaces exist: a record without a space is
6
+ // forgotten, and a space without a record is listed with nothing known about it. No secret is
7
+ // ever in a record; the design keeps secret values out of the host's disk.
8
+
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+
12
+ import { z } from 'zod';
13
+
14
+ import { requireSpaceId } from './labels.js';
15
+
16
+ const RECORDS_DIRECTORY = path.join('spaces', 'records');
17
+
18
+ const NETWORK_MODES = Object.freeze(['allowlist', 'open']);
19
+ // The allowlist's own rule for a name: labels of letters, digits and hyphens, with a real last label.
20
+ const DOMAIN_PATTERN = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{0,61}[a-z0-9]$/;
21
+ const MAX_DOMAINS = 200;
22
+
23
+ export const networkSchema = z.object({
24
+ mode: z.enum(NETWORK_MODES),
25
+ domains: z.array(z.string().regex(DOMAIN_PATTERN)).max(MAX_DOMAINS).default([]),
26
+ });
27
+
28
+ const historySchema = z.enum(['pending', 'sent', 'already_complete', 'host_shallow', 'failed']);
29
+
30
+ const GRANT_ID_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
31
+ // zod runs the refinement even when `.url()` failed, so the parse of the URL must not throw here.
32
+ const isHttpUrl = (value) => {
33
+ try {
34
+ return /^https?:$/.test(new URL(value).protocol);
35
+ } catch {
36
+ return false;
37
+ }
38
+ };
39
+ const upstreamSchema = z.string().url().refine(isHttpUrl, 'http or https');
40
+ /** Where the host finds a secret again, never the secret: an environment variable of the host's by name, or the user typed it once. */
41
+ export const secretSourceSchema = z.discriminatedUnion('kind', [
42
+ z.object({ kind: z.literal('env'), name: z.string().regex(/^[A-Za-z_][A-Za-z0-9_]{0,255}$/) }).strict(),
43
+ z.object({ kind: z.literal('typed') }).strict(),
44
+ ]);
45
+ /**
46
+ * A grant as the host remembers it. Strict, so a value under any name is refused rather than
47
+ * written: a record holds where a secret comes from and never what it is (decision 5).
48
+ */
49
+ export const grantSchema = z.discriminatedUnion('kind', [
50
+ z.object({
51
+ kind: z.literal('model'),
52
+ id: z.string().regex(GRANT_ID_PATTERN),
53
+ provider: z.string().regex(GRANT_ID_PATTERN),
54
+ upstream: upstreamSchema,
55
+ header: z.string().regex(/^[a-z0-9-]{1,64}$/),
56
+ source: secretSourceSchema,
57
+ }).strict(),
58
+ z.object({
59
+ kind: z.literal('domain'),
60
+ id: z.string().regex(GRANT_ID_PATTERN),
61
+ upstream: upstreamSchema,
62
+ }).strict(),
63
+ ]);
64
+ const MAX_GRANTS = 100;
65
+
66
+ const recordSchema = z.object({
67
+ version: z.literal(1),
68
+ network: networkSchema,
69
+ /** The host repository the code came from, as the user's project names it. */
70
+ repository: z.string().min(1).nullable().default(null),
71
+ /** The project path inside the space that code in returned. */
72
+ spacePath: z.string().min(1).nullable().default(null),
73
+ /** The commit the snapshot was taken from, which the history is sent behind. */
74
+ base: z.string().regex(/^[0-9a-f]{40}$|^[0-9a-f]{64}$/).nullable().default(null),
75
+ history: historySchema.default('pending'),
76
+ /** The grants the user gave, said again to the gatekeeper after every start. */
77
+ grants: z.array(grantSchema).max(MAX_GRANTS).default([]),
78
+ });
79
+
80
+ /**
81
+ * Per-space records under the host's data directory, one JSON file each, readable by the user only.
82
+ * A record that cannot be read as one is reported as `null` with a warning, never as a fresh one:
83
+ * a bad file must not make the host forget a network choice and leave a space more open than asked.
84
+ */
85
+ export function createSpaceRecords({ dataDir, logger = console }) {
86
+ const directory = path.join(dataDir, RECORDS_DIRECTORY);
87
+ const fileOf = (spaceId) => path.join(directory, `${requireSpaceId(spaceId)}.json`);
88
+
89
+ /**
90
+ * One grant the host cannot read, from a later version that knows another kind among the
91
+ * reasons, is dropped with a warning and never takes the network, the repository and the
92
+ * history of the record with it: the space then shows that grant as missing, not as unknown.
93
+ */
94
+ const withReadableGrants = (spaceId, raw) => {
95
+ if (!(raw instanceof Object) || !Array.isArray(raw.grants)) return raw;
96
+ const grants = raw.grants.filter((grant) => grantSchema.safeParse(grant).success);
97
+ if (grants.length < raw.grants.length) {
98
+ logger.warn?.(`[spaces] the record of space ${spaceId} holds ${raw.grants.length - grants.length} grant(s) this host cannot read; they are left out`);
99
+ }
100
+ return { ...raw, grants };
101
+ };
102
+
103
+ const read = (spaceId) => {
104
+ const file = fileOf(spaceId);
105
+ let text;
106
+ try {
107
+ text = fs.readFileSync(file, 'utf8');
108
+ } catch (error) {
109
+ if (error?.code === 'ENOENT') return { status: 'missing', record: null };
110
+ logger.warn?.(`[spaces] could not read the record of space ${spaceId}: ${error?.code ?? error?.message ?? error}`);
111
+ return { status: 'unreadable', record: null };
112
+ }
113
+ try {
114
+ const parsed = recordSchema.safeParse(withReadableGrants(spaceId, JSON.parse(text)));
115
+ if (parsed.success) return { status: 'ok', record: parsed.data };
116
+ } catch {
117
+ // Reported below.
118
+ }
119
+ logger.warn?.(`[spaces] the record of space ${spaceId} is not one this host wrote`);
120
+ return { status: 'unreadable', record: null };
121
+ };
122
+
123
+ const write = (spaceId, record) => {
124
+ const checked = recordSchema.parse({ version: 1, ...record });
125
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
126
+ const file = fileOf(spaceId);
127
+ // Through a temporary name, so a crash in the middle never leaves half a record.
128
+ const temporary = `${file}.${process.pid}.tmp`;
129
+ fs.writeFileSync(temporary, `${JSON.stringify(checked, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
130
+ fs.renameSync(temporary, file);
131
+ return checked;
132
+ };
133
+
134
+ /** Changes some fields of a record that exists. A record that is missing or unreadable is not written. */
135
+ const update = (spaceId, changes) => {
136
+ const current = read(spaceId);
137
+ if (current.status !== 'ok') return current;
138
+ return { status: 'ok', record: write(spaceId, { ...current.record, ...changes }) };
139
+ };
140
+
141
+ const remove = (spaceId) => {
142
+ try {
143
+ fs.rmSync(fileOf(spaceId), { force: true });
144
+ } catch (error) {
145
+ logger.warn?.(`[spaces] could not remove the record of space ${spaceId}: ${error?.code ?? error?.message ?? error}`);
146
+ }
147
+ };
148
+
149
+ return { read, write, update, remove };
150
+ }
@@ -0,0 +1,113 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ import { afterEach, describe, expect, it } from 'vitest';
6
+
7
+ import { createSpaceRecords, networkSchema } from './space-records.js';
8
+
9
+ const ID = 'a1b2c3d4e5f6';
10
+ const folders = [];
11
+ const temporary = () => {
12
+ const folder = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-space-records-'));
13
+ folders.push(folder);
14
+ return folder;
15
+ };
16
+ const quiet = { warn: () => {} };
17
+
18
+ afterEach(() => {
19
+ for (const folder of folders.splice(0)) fs.rmSync(folder, { recursive: true, force: true });
20
+ });
21
+
22
+ describe('space records', () => {
23
+ it('writes a record readable by the user only, reads it back, changes a field and removes it', () => {
24
+ const dataDir = temporary();
25
+ const records = createSpaceRecords({ dataDir, logger: quiet });
26
+ expect(records.read(ID)).toEqual({ status: 'missing', record: null });
27
+
28
+ const written = records.write(ID, { network: { mode: 'allowlist', domains: ['api.anthropic.com'] }, repository: '/home/me/project' });
29
+ expect(written).toEqual({ version: 1, network: { mode: 'allowlist', domains: ['api.anthropic.com'] }, repository: '/home/me/project', spacePath: null, base: null, history: 'pending', grants: [] });
30
+ const file = path.join(dataDir, 'spaces', 'records', `${ID}.json`);
31
+ if (process.platform !== 'win32') expect(fs.statSync(file).mode & 0o777).toBe(0o600);
32
+ expect(fs.readdirSync(path.dirname(file))).toEqual([`${ID}.json`]);
33
+
34
+ expect(records.update(ID, { spacePath: `/spaces/${ID}/project`, base: 'a'.repeat(40) }).record).toMatchObject({ spacePath: `/spaces/${ID}/project`, base: 'a'.repeat(40), history: 'pending' });
35
+ expect(records.update(ID, { history: 'sent' }).record.history).toBe('sent');
36
+ expect(records.read(ID)).toEqual({ status: 'ok', record: expect.objectContaining({ history: 'sent', network: { mode: 'allowlist', domains: ['api.anthropic.com'] } }) });
37
+
38
+ records.remove(ID);
39
+ expect(records.read(ID)).toEqual({ status: 'missing', record: null });
40
+ records.remove(ID);
41
+ });
42
+
43
+ it('reports a file it cannot read as unreadable, never as a fresh record, and does not write over it', () => {
44
+ const dataDir = temporary();
45
+ const records = createSpaceRecords({ dataDir, logger: quiet });
46
+ const directory = path.join(dataDir, 'spaces', 'records');
47
+ fs.mkdirSync(directory, { recursive: true });
48
+ fs.writeFileSync(path.join(directory, `${ID}.json`), '{ "version": 1, "network": { "mode": "everything" } }');
49
+ expect(records.read(ID)).toEqual({ status: 'unreadable', record: null });
50
+ fs.writeFileSync(path.join(directory, `${ID}.json`), 'not json');
51
+ expect(records.read(ID)).toEqual({ status: 'unreadable', record: null });
52
+ expect(records.update(ID, { history: 'sent' })).toEqual({ status: 'unreadable', record: null });
53
+ expect(fs.readFileSync(path.join(directory, `${ID}.json`), 'utf8')).toBe('not json');
54
+ });
55
+
56
+ it('remembers a grant without its value, and refuses one that carries a value under any name', () => {
57
+ const records = createSpaceRecords({ dataDir: temporary(), logger: quiet });
58
+ const model = { kind: 'model', id: 'anthropic', provider: 'anthropic', upstream: 'https://api.anthropic.com/v1', header: 'x-api-key', source: { kind: 'env', name: 'ANTHROPIC_API_KEY' } };
59
+ const typed = { ...model, id: 'openai', provider: 'openai', upstream: 'https://api.openai.com/v1', header: 'authorization', source: { kind: 'typed' } };
60
+ const domain = { kind: 'domain', id: 'open-0a1b2c3d4e5f', upstream: 'https://registry.example.com/npm/' };
61
+ const written = records.write(ID, { network: { mode: 'open' }, grants: [model, typed, domain] });
62
+ expect(written.grants).toEqual([model, typed, domain]);
63
+ expect(records.read(ID).record.grants).toEqual([model, typed, domain]);
64
+
65
+ for (const bad of [
66
+ { ...model, secret: 'sk-live-1' },
67
+ { ...model, value: 'sk-live-1' },
68
+ { ...model, source: { kind: 'typed', value: 'sk-live-1' } },
69
+ { ...model, source: { kind: 'env', name: 'ANTHROPIC_API_KEY', value: 'sk-live-1' } },
70
+ { ...domain, secret: 'sk-live-1' },
71
+ { ...domain, header: 'authorization' },
72
+ { ...model, upstream: 'file:///etc/passwd' },
73
+ { ...model, header: 'X-Api-Key' },
74
+ { ...model, id: '../x' },
75
+ { ...model, source: { kind: 'file', name: '/tmp/key' } },
76
+ ]) {
77
+ expect(() => records.write(ID, { network: { mode: 'open' }, grants: [bad] }), JSON.stringify(bad)).toThrow();
78
+ }
79
+ // A grant this host cannot read, from a later version, is left out; the rest of the record stays.
80
+ const later = temporary();
81
+ const withUnknown = createSpaceRecords({ dataDir: later, logger: quiet });
82
+ fs.mkdirSync(path.join(later, 'spaces', 'records'), { recursive: true });
83
+ fs.writeFileSync(path.join(later, 'spaces', 'records', `${ID}.json`), JSON.stringify({ version: 1, network: { mode: 'allowlist', domains: ['a.example.com'] }, repository: '/home/me/project', history: 'sent', grants: [{ kind: 'git', id: 'github' }, domain] }));
84
+ expect(withUnknown.read(ID)).toEqual({ status: 'ok', record: expect.objectContaining({ network: { mode: 'allowlist', domains: ['a.example.com'] }, repository: '/home/me/project', history: 'sent', grants: [domain] }) });
85
+ // A malformed upstream is refused, not thrown.
86
+ expect(() => records.write(ID, { network: { mode: 'open' }, grants: [{ ...domain, upstream: 'not a url' }] })).toThrow();
87
+
88
+ // A record written before grants existed reads back with none.
89
+ const dataDir = temporary();
90
+ const older = createSpaceRecords({ dataDir, logger: quiet });
91
+ fs.mkdirSync(path.join(dataDir, 'spaces', 'records'), { recursive: true });
92
+ fs.writeFileSync(path.join(dataDir, 'spaces', 'records', `${ID}.json`), JSON.stringify({ version: 1, network: { mode: 'open', domains: [] } }));
93
+ expect(older.read(ID)).toEqual({ status: 'ok', record: expect.objectContaining({ grants: [] }) });
94
+ });
95
+
96
+ it('refuses a record that is not one, and an id that is not a space id', () => {
97
+ const records = createSpaceRecords({ dataDir: temporary(), logger: quiet });
98
+ expect(() => records.write(ID, { network: { mode: 'open', domains: ['not a name'] } })).toThrow();
99
+ expect(() => records.write('../etc', { network: { mode: 'open' } })).toThrow(/space id/i);
100
+ expect(() => records.read('../etc')).toThrow(/space id/i);
101
+ });
102
+
103
+ it('takes domain names the allowlist takes, and nothing else', () => {
104
+ const accepted = (domains) => networkSchema.safeParse({ mode: 'allowlist', domains }).success;
105
+ expect(accepted(['api.anthropic.com', 'registry.npmjs.org', 'a-b.example'])).toBe(true);
106
+ expect(accepted([])).toBe(true);
107
+ for (const bad of [['localhost'], ['10.0.0.1'], ['Api.Example.com'], ['a_b.example'], ['-a.example'], ['a.example.'], ['a..example'], ['https://a.example'], ['a.example/path']]) {
108
+ expect(accepted(bad), bad[0]).toBe(false);
109
+ }
110
+ expect(networkSchema.safeParse({ mode: 'open' }).data).toEqual({ mode: 'open', domains: [] });
111
+ expect(networkSchema.safeParse({ mode: 'all' }).success).toBe(false);
112
+ });
113
+ });
@@ -0,0 +1,210 @@
1
+ // The merged session list and the rules for what a space may claim in it or in its events.
2
+ // Pure: no Express, no transport. `host.js` feeds it what the spaces answered and the proxy
3
+ // asks it for the merged answer.
4
+ //
5
+ // A space's list and events are untrusted. A record or an event may name a directory under
6
+ // that space's own root only, and never a session id the host has: the host's id wins, the
7
+ // space's record is dropped and logged as a code, without its body. A space that does not
8
+ // answer keeps its last known sessions, marked stale; a missing answer never means deletion.
9
+
10
+ import { z } from 'zod';
11
+
12
+ import { sanitizeSessionListItem } from '../opencode/proxy.js';
13
+ import { isDirectoryOfSpace } from './dispatcher.js';
14
+
15
+ // How many host session ids are remembered for the collision rule. Older ones are forgotten
16
+ // first; a session the host listed or created recently is always among them.
17
+ const HOST_ID_MEMORY = 50_000;
18
+
19
+ /** Why a record or an event of a space was dropped. Logged as a code, never with the body. */
20
+ export const SPACE_DROP_CODES = Object.freeze({
21
+ malformed: 'space_session_malformed',
22
+ outsideRoot: 'space_session_outside_root',
23
+ hostId: 'space_session_host_id',
24
+ claimed: 'space_session_claimed',
25
+ eventHostSession: 'space_event_host_session',
26
+ eventOutsideRoot: 'space_event_outside_root',
27
+ });
28
+
29
+ // What the host reads of a record or an event before it decides; every other field is carried, not read.
30
+ const directorySchema = z.string().min(1);
31
+ const idSchema = z.string().min(1);
32
+ const recordSchema = z.object({ id: idSchema, location: z.object({ directory: directorySchema }).passthrough() }).passthrough();
33
+ const eventSchema = z.object({
34
+ type: z.string(),
35
+ data: z.object({ sessionID: idSchema.optional(), location: z.object({ directory: z.unknown() }).passthrough().optional() }).passthrough().optional(),
36
+ location: z.object({ directory: z.unknown() }).passthrough().optional(),
37
+ }).passthrough();
38
+ const isRecord = (value) => z.object({}).passthrough().safeParse(value).success;
39
+
40
+ /**
41
+ * The records of one space's list, kept or dropped one by one. `isHostSessionId` answers the
42
+ * host's ids, `claimedBy` names the space that already listed an id. Duplicates within the list
43
+ * keep the first. Resolves `{ records, dropped }`, `dropped` counting each code.
44
+ */
45
+ export function filterSpaceSessionRecords({ spaceId, records, isHostSessionId, claimedBy }) {
46
+ const kept = [];
47
+ const dropped = {};
48
+ const seen = new Set();
49
+ const drop = (code) => { dropped[code] = (dropped[code] ?? 0) + 1; };
50
+ for (const record of records) {
51
+ const parsed = recordSchema.safeParse(record);
52
+ if (!parsed.success) {
53
+ // A record without an id is malformed; one with an id and no usable directory lies outside every root.
54
+ drop(idSchema.safeParse(record?.id).success ? SPACE_DROP_CODES.outsideRoot : SPACE_DROP_CODES.malformed);
55
+ continue;
56
+ }
57
+ const { id, location } = parsed.data;
58
+ if (!isDirectoryOfSpace(location.directory, spaceId)) { drop(SPACE_DROP_CODES.outsideRoot); continue; }
59
+ if (isHostSessionId(id)) { drop(SPACE_DROP_CODES.hostId); continue; }
60
+ const owner = claimedBy(id);
61
+ if (owner !== null && owner !== spaceId) { drop(SPACE_DROP_CODES.claimed); continue; }
62
+ if (seen.has(id)) continue;
63
+ seen.add(id);
64
+ kept.push(sanitizeSessionListItem(record));
65
+ }
66
+ return { records: kept, dropped };
67
+ }
68
+
69
+ /**
70
+ * Whether one event of a space may pass: the session it names is not the host's, and every
71
+ * directory it carries, on the event or on the created session, lies under the space's root.
72
+ * Execution events carry no location and pass on their session id alone. Answers the drop
73
+ * code, or null when the event passes.
74
+ */
75
+ export function spaceEventDropCode({ spaceId, payload, isHostSessionId }) {
76
+ const parsed = eventSchema.safeParse(payload);
77
+ if (!parsed.success) return SPACE_DROP_CODES.malformed;
78
+ const { data, location } = parsed.data;
79
+ for (const directory of [location?.directory, data?.location?.directory]) {
80
+ if (directory === undefined || directory === null) continue;
81
+ if (!directorySchema.safeParse(directory).success || !isDirectoryOfSpace(directory, spaceId)) return SPACE_DROP_CODES.eventOutsideRoot;
82
+ }
83
+ if (data?.sessionID !== undefined && isHostSessionId(data.sessionID)) return SPACE_DROP_CODES.eventHostSession;
84
+ return null;
85
+ }
86
+
87
+ /**
88
+ * The host's list with every space's records after it. `spaces` marks each space's answer:
89
+ * `complete`, `partial` when the space had more pages than the host read, `stale` when the
90
+ * last known list stands in for an answer that did not come, and `unknown` when the space
91
+ * never answered. A host payload without spaces goes back as it came, untouched.
92
+ */
93
+ export function mergeSessionLists(hostPayload, spaceLists) {
94
+ if (spaceLists.length === 0) return hostPayload;
95
+ const records = spaceLists.flatMap((entry) => entry.records);
96
+ // The mark names the space for the client: its label name, the host project it was made for,
97
+ // as the host resolved it from the label, and the project's path inside, or null for both when
98
+ // the project is no longer registered on this host.
99
+ const spaces = spaceLists.map((entry) => ({
100
+ id: entry.spaceId,
101
+ name: entry.name ?? '',
102
+ state: entry.state,
103
+ sessions: entry.records.length,
104
+ projectDirectory: entry.projectDirectory ?? null,
105
+ directory: entry.directory ?? null,
106
+ }));
107
+ if (Array.isArray(hostPayload)) return [...hostPayload, ...records];
108
+ if (isRecord(hostPayload) && Array.isArray(hostPayload.data)) {
109
+ return { ...hostPayload, data: [...hostPayload.data, ...records], spaces };
110
+ }
111
+ return hostPayload;
112
+ }
113
+
114
+ /**
115
+ * What the host knows about sessions across the boundary: the host's own ids, and per space
116
+ * the last list it accepted and the state of that answer. One per host process.
117
+ */
118
+ export function createSpaceSessionIndex({ logger = console } = {}) {
119
+ const hostIds = new Map();
120
+ const spaces = new Map();
121
+ const claims = new Map();
122
+
123
+ const rememberHostId = (id) => {
124
+ if (!idSchema.safeParse(id).success) return;
125
+ hostIds.delete(id);
126
+ hostIds.set(id, true);
127
+ if (hostIds.size > HOST_ID_MEMORY) hostIds.delete(hostIds.keys().next().value);
128
+ };
129
+ const isHostSessionId = (id) => hostIds.has(id);
130
+ const claimedBy = (id) => claims.get(id) ?? null;
131
+
132
+ const logDropped = (spaceId, dropped) => {
133
+ for (const [code, count] of Object.entries(dropped)) {
134
+ logger.warn?.(`[spaces] dropped ${count} record(s) of space ${spaceId}: ${code}`);
135
+ }
136
+ };
137
+
138
+ const entryOf = (spaceId) => {
139
+ let entry = spaces.get(spaceId);
140
+ if (!entry) {
141
+ entry = { spaceId, records: [], state: 'unknown' };
142
+ spaces.set(spaceId, entry);
143
+ }
144
+ return entry;
145
+ };
146
+
147
+ const releaseClaims = (spaceId) => {
148
+ for (const [id, owner] of claims) if (owner === spaceId) claims.delete(id);
149
+ };
150
+
151
+ return {
152
+ isHostSessionId,
153
+ claimedBy,
154
+ /** The host's list passed through the proxy: its ids are the host's from now on. */
155
+ observeHostRecords(records) {
156
+ for (const record of records) if (isRecord(record)) rememberHostId(record.id);
157
+ },
158
+ /** A host event that names a session: a created one is the host's from now on. */
159
+ observeHostEvent(payload) {
160
+ const parsed = eventSchema.safeParse(payload);
161
+ if (!parsed.success || parsed.data.type !== 'session.created') return;
162
+ rememberHostId(parsed.data.data?.sessionID);
163
+ },
164
+ /** A space answered its list, whole or in part. The records that pass replace the last known ones. */
165
+ acceptSpaceList(spaceId, records, { complete }) {
166
+ const filtered = filterSpaceSessionRecords({ spaceId, records, isHostSessionId, claimedBy });
167
+ logDropped(spaceId, filtered.dropped);
168
+ releaseClaims(spaceId);
169
+ for (const record of filtered.records) claims.set(record.id, spaceId);
170
+ const entry = entryOf(spaceId);
171
+ entry.records = filtered.records;
172
+ entry.state = complete ? 'complete' : 'partial';
173
+ return entry;
174
+ },
175
+ /** A space did not answer: its last known list stands, marked stale. */
176
+ markUnreachable(spaceId) {
177
+ const entry = entryOf(spaceId);
178
+ if (entry.state !== 'unknown') entry.state = 'stale';
179
+ return entry;
180
+ },
181
+ /** A space is gone: nothing of it is listed or claimed any more. */
182
+ forget(spaceId) {
183
+ spaces.delete(spaceId);
184
+ releaseClaims(spaceId);
185
+ },
186
+ /** Whether one event of a space passes. A created session that passes is claimed for the space. */
187
+ acceptSpaceEvent(spaceId, payload) {
188
+ const code = spaceEventDropCode({ spaceId, payload, isHostSessionId });
189
+ if (code !== null) {
190
+ logger.warn?.(`[spaces] dropped an event of space ${spaceId}: ${code}`);
191
+ return false;
192
+ }
193
+ const sessionID = payload.data?.sessionID;
194
+ if (idSchema.safeParse(sessionID).success) {
195
+ // A session another space listed or made is that space's: no other space speaks for it.
196
+ const owner = claimedBy(sessionID);
197
+ if (owner !== null && owner !== spaceId) {
198
+ logger.warn?.(`[spaces] dropped an event of space ${spaceId}: ${SPACE_DROP_CODES.claimed}`);
199
+ return false;
200
+ }
201
+ if (payload.type === 'session.created') claims.set(sessionID, spaceId);
202
+ }
203
+ return true;
204
+ },
205
+ /** Every space's last accepted list with its state, for the merge. */
206
+ snapshot() {
207
+ return Array.from(spaces.values(), (entry) => ({ spaceId: entry.spaceId, state: entry.state, records: entry.records }));
208
+ },
209
+ };
210
+ }
@@ -0,0 +1,161 @@
1
+ // The rules of the merged session list: what a space may claim in its list and in its events.
2
+
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ import {
6
+ SPACE_DROP_CODES,
7
+ createSpaceSessionIndex,
8
+ filterSpaceSessionRecords,
9
+ mergeSessionLists,
10
+ spaceEventDropCode,
11
+ } from './space-sessions.js';
12
+
13
+ const ID = 'a1b2c3d4e5f6';
14
+ const OTHER = '0f0f0f0f0f0f';
15
+ const record = (id, directory, extra = {}) => ({ id, location: { directory }, title: id, ...extra });
16
+ const never = () => false;
17
+ const nobody = () => null;
18
+
19
+ describe('filterSpaceSessionRecords', () => {
20
+ it.each([
21
+ ['its own root', record('s1', `/spaces/${ID}/repo`), true],
22
+ ['its own root, deeper', record('s2', `/spaces/${ID}/repo/src`), true],
23
+ ['the root itself', record('s3', `/spaces/${ID}`), true],
24
+ ['a path outside every space', record('s4', '/home/me/project'), false],
25
+ ['another space', record('s5', `/spaces/${OTHER}/repo`), false],
26
+ ['a climb out of its root', record('s6', `/spaces/${ID}/../${OTHER}/repo`), false],
27
+ ['a sibling with the same prefix', record('s7', `/spaces/${ID}x/repo`), false],
28
+ ['no directory at all', { id: 's8', title: 'no location' }, false],
29
+ ['a directory that is not text', record('s9', 42), false],
30
+ ['no id', { location: { directory: `/spaces/${ID}/repo` } }, false],
31
+ ['not an object', 'text', false],
32
+ ])('keeps a record only when its directory lies under the space\'s root: %s', (_name, entry, kept) => {
33
+ const result = filterSpaceSessionRecords({ spaceId: ID, records: [entry], isHostSessionId: never, claimedBy: nobody });
34
+ expect(result.records.map((item) => item.id)).toEqual(kept ? [entry.id] : []);
35
+ if (!kept) expect(Object.values(result.dropped).reduce((sum, count) => sum + count, 0)).toBe(1);
36
+ });
37
+
38
+ it('never lets a space record overwrite a host session id, and logs it as a code', () => {
39
+ const result = filterSpaceSessionRecords({
40
+ spaceId: ID,
41
+ records: [record('host-1', `/spaces/${ID}/repo`, { title: 'I am the host session now' }), record('mine', `/spaces/${ID}/repo`)],
42
+ isHostSessionId: (id) => id === 'host-1',
43
+ claimedBy: nobody,
44
+ });
45
+ expect(result.records.map((item) => item.id)).toEqual(['mine']);
46
+ expect(result.dropped).toEqual({ [SPACE_DROP_CODES.hostId]: 1 });
47
+ });
48
+
49
+ it('drops a record whose id another space already listed, and a duplicate within the list', () => {
50
+ const result = filterSpaceSessionRecords({
51
+ spaceId: ID,
52
+ records: [record('theirs', `/spaces/${ID}/repo`), record('mine', `/spaces/${ID}/repo`), record('mine', `/spaces/${ID}/repo`)],
53
+ isHostSessionId: never,
54
+ claimedBy: (id) => (id === 'theirs' ? OTHER : id === 'mine' ? ID : null),
55
+ });
56
+ expect(result.records.map((item) => item.id)).toEqual(['mine']);
57
+ expect(result.dropped).toEqual({ [SPACE_DROP_CODES.claimed]: 1 });
58
+ });
59
+
60
+ it('keeps only the fields a session list may carry', () => {
61
+ const result = filterSpaceSessionRecords({
62
+ spaceId: ID,
63
+ records: [record('s1', `/spaces/${ID}/repo`, { permissions: { edit: 'allow' }, revert: { messageID: 'm', files: ['x'] } })],
64
+ isHostSessionId: never,
65
+ claimedBy: nobody,
66
+ });
67
+ expect(result.records[0]).toEqual({ id: 's1', location: { directory: `/spaces/${ID}/repo` }, title: 's1', revert: { messageID: 'm' } });
68
+ });
69
+ });
70
+
71
+ describe('spaceEventDropCode', () => {
72
+ const event = (type, data, location) => {
73
+ const payload = { type, data };
74
+ if (location) payload.location = { directory: location };
75
+ return payload;
76
+ };
77
+ it.each([
78
+ ['an event of its own directory', event('session.created', { sessionID: 's1', location: { directory: `/spaces/${ID}/repo` } }, `/spaces/${ID}/repo`), null],
79
+ ['an execution event with no location', event('session.execution.started', { sessionID: 's1' }), null],
80
+ ['an event of another space', event('session.created', { sessionID: 's1' }, `/spaces/${OTHER}/repo`), SPACE_DROP_CODES.eventOutsideRoot],
81
+ ['an event of a host directory', event('session.renamed', { sessionID: 's1' }, '/home/me/project'), SPACE_DROP_CODES.eventOutsideRoot],
82
+ ['a created session that lands outside', event('session.created', { sessionID: 's1', location: { directory: '/home/me' } }, `/spaces/${ID}/repo`), SPACE_DROP_CODES.eventOutsideRoot],
83
+ ['a moved session that lands outside', event('session.moved', { sessionID: 's1', location: { directory: `/spaces/${OTHER}` } }, `/spaces/${ID}/repo`), SPACE_DROP_CODES.eventOutsideRoot],
84
+ ['an event that names a host session', event('session.execution.started', { sessionID: 'host-1' }), SPACE_DROP_CODES.eventHostSession],
85
+ ['not an object', 'text', SPACE_DROP_CODES.malformed],
86
+ ])('%s', (_name, payload, code) => {
87
+ expect(spaceEventDropCode({ spaceId: ID, payload, isHostSessionId: (id) => id === 'host-1' })).toBe(code);
88
+ });
89
+ });
90
+
91
+ describe('mergeSessionLists', () => {
92
+ const host = { data: [record('h1', '/home/me')], cursor: { next: 'abc' } };
93
+ it('returns the host payload untouched, the same object, when there is no space', () => {
94
+ expect(mergeSessionLists(host, [])).toBe(host);
95
+ });
96
+
97
+ it('lists the host first, then each space, with a mark per space', () => {
98
+ const merged = mergeSessionLists(host, [
99
+ { spaceId: ID, state: 'complete', records: [record('s1', `/spaces/${ID}/repo`)], name: 'First', projectDirectory: '/home/me/repo', directory: `/spaces/${ID}/repo` },
100
+ { spaceId: OTHER, state: 'stale', records: [record('s2', `/spaces/${OTHER}/repo`), record('s3', `/spaces/${OTHER}/repo`)] },
101
+ ]);
102
+ expect(merged.data.map((item) => item.id)).toEqual(['h1', 's1', 's2', 's3']);
103
+ expect(merged.cursor).toEqual({ next: 'abc' });
104
+ // A space whose project is not registered on this host is marked with no project and no directory.
105
+ expect(merged.spaces).toEqual([
106
+ { id: ID, name: 'First', state: 'complete', sessions: 1, projectDirectory: '/home/me/repo', directory: `/spaces/${ID}/repo` },
107
+ { id: OTHER, name: '', state: 'stale', sessions: 2, projectDirectory: null, directory: null },
108
+ ]);
109
+ });
110
+ });
111
+
112
+ describe('createSpaceSessionIndex', () => {
113
+ const quiet = { warn: () => {} };
114
+
115
+ it('answers empty for a space that answered empty, and keeps the last list for one that did not answer', () => {
116
+ const index = createSpaceSessionIndex({ logger: quiet });
117
+ index.acceptSpaceList(ID, [record('s1', `/spaces/${ID}/repo`)], { complete: true });
118
+ expect(index.snapshot()).toEqual([{ spaceId: ID, state: 'complete', records: [expect.objectContaining({ id: 's1' })] }]);
119
+ index.markUnreachable(ID);
120
+ expect(index.snapshot()).toEqual([{ spaceId: ID, state: 'stale', records: [expect.objectContaining({ id: 's1' })] }]);
121
+ index.acceptSpaceList(ID, [], { complete: true });
122
+ expect(index.snapshot()).toEqual([{ spaceId: ID, state: 'complete', records: [] }]);
123
+ // A space that never answered is unknown, with nothing to show, and is not an empty answer.
124
+ index.markUnreachable(OTHER);
125
+ expect(index.snapshot()[1]).toEqual({ spaceId: OTHER, state: 'unknown', records: [] });
126
+ });
127
+
128
+ it('marks a list that had more pages than were read as partial', () => {
129
+ const index = createSpaceSessionIndex({ logger: quiet });
130
+ index.acceptSpaceList(ID, [record('s1', `/spaces/${ID}/repo`)], { complete: false });
131
+ expect(index.snapshot()[0].state).toBe('partial');
132
+ });
133
+
134
+ it('learns the host\'s ids from its list and its events, and lets no space take them', () => {
135
+ const logs = [];
136
+ const index = createSpaceSessionIndex({ logger: { warn: (line) => logs.push(line) } });
137
+ index.observeHostRecords([record('h1', '/home/me')]);
138
+ index.observeHostEvent({ type: 'session.created', data: { sessionID: 'h2' } });
139
+ const entry = index.acceptSpaceList(ID, [record('h1', `/spaces/${ID}/repo`, { title: 'stolen' }), record('h2', `/spaces/${ID}/repo`), record('s1', `/spaces/${ID}/repo`)], { complete: true });
140
+ expect(entry.records.map((item) => item.id)).toEqual(['s1']);
141
+ expect(index.acceptSpaceEvent(ID, { type: 'session.execution.started', data: { sessionID: 'h1' } })).toBe(false);
142
+ expect(index.acceptSpaceEvent(ID, { type: 'session.execution.started', data: { sessionID: 's1' } })).toBe(true);
143
+ expect(logs.join('\n')).toContain(SPACE_DROP_CODES.hostId);
144
+ expect(logs.join('\n')).toContain(SPACE_DROP_CODES.eventHostSession);
145
+ expect(logs.join('\n')).not.toContain('stolen');
146
+ });
147
+
148
+ it('claims a created session for the space that made it, and releases claims when the space is forgotten', () => {
149
+ const index = createSpaceSessionIndex({ logger: quiet });
150
+ expect(index.acceptSpaceEvent(ID, { type: 'session.created', data: { sessionID: 's1', location: { directory: `/spaces/${ID}/repo` } } })).toBe(true);
151
+ expect(index.claimedBy('s1')).toBe(ID);
152
+ expect(index.acceptSpaceEvent(OTHER, { type: 'session.created', data: { sessionID: 's1', location: { directory: `/spaces/${OTHER}/repo` } } })).toBe(false);
153
+ // Nor may another space speak for it in any other event.
154
+ expect(index.acceptSpaceEvent(OTHER, { type: 'session.execution.started', data: { sessionID: 's1' } })).toBe(false);
155
+ expect(index.acceptSpaceEvent(ID, { type: 'session.execution.started', data: { sessionID: 's1' } })).toBe(true);
156
+ expect(index.acceptSpaceList(OTHER, [record('s1', `/spaces/${OTHER}/repo`)], { complete: true }).records).toEqual([]);
157
+ index.forget(ID);
158
+ expect(index.claimedBy('s1')).toBeNull();
159
+ expect(index.snapshot().map((entry) => entry.spaceId)).toEqual([OTHER]);
160
+ });
161
+ });