@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.
Files changed (265) hide show
  1. package/README.md +20 -28
  2. package/bin/cli.js +3 -1
  3. package/bin/cli.test.js +13 -4
  4. package/bin/lib/DOCUMENTATION.md +4 -1
  5. package/bin/lib/commands-serve.js +9 -0
  6. package/dist/assets/{CodeMirrorCommentWidgets-CWhdh0wf.js → CodeMirrorCommentWidgets-DCAQTLMM.js} +1 -1
  7. package/dist/assets/DiffView-uzNpHBlV.js +4 -0
  8. package/dist/assets/FilesView-Bkq0sjNx.js +17 -0
  9. package/dist/assets/{GitView-DKIjpnu4.js → GitView-BmibZC7n.js} +4 -4
  10. package/dist/assets/{GuestAttachDialog-BIa9jCfL.js → GuestAttachDialog-D7TJG1rt.js} +1 -1
  11. package/dist/assets/GuestSurfacePane-PAcYaFzP.js +1 -0
  12. package/dist/assets/{JsonTreeView-CtI1Mqwo.js → JsonTreeView-BNT2yUVu.js} +1 -1
  13. package/dist/assets/{LinearIssuesView-BD2zLu-r.js → LinearIssuesView-D3Azksju.js} +1 -1
  14. package/dist/assets/MarkdownImageGallery-BGY6tuiK.js +1 -0
  15. package/dist/assets/MarkdownRendererImpl-DJmhQ37f.js +12 -0
  16. package/dist/assets/{OnboardingScreen-B8TxloXZ.js → OnboardingScreen-D_41H2qC.js} +1 -1
  17. package/dist/assets/{PatchDiff--Civs1xY.js → PatchDiff-C0RSqa9W.js} +1 -1
  18. package/dist/assets/{PierreDiffViewer-BlDmw3-A.js → PierreDiffViewer-BBE7tTY2.js} +3 -3
  19. package/dist/assets/{PlanView-DyH2SyCP.js → PlanView-Bqc-xuDe.js} +4 -4
  20. package/dist/assets/PluginPane-DQ0k41Q-.js +3 -0
  21. package/dist/assets/SettingsView-BAiJU5hJ.js +30 -0
  22. package/dist/assets/{SettingsWindow-y9WpZqK6.js → SettingsWindow-DuC4ecLw.js} +1 -1
  23. package/dist/assets/{SubmoduleDiffSummary-DeFmhPFZ.js → SubmoduleDiffSummary-0HgmcMXz.js} +1 -1
  24. package/dist/assets/{ToolOutputDialog-8DmF7_PZ.js → ToolOutputDialog-DdHkQJEE.js} +6 -6
  25. package/dist/assets/{ToolPartDiffPreview-Ca15DKE1.js → ToolPartDiffPreview-DWdN0r5K.js} +2 -2
  26. package/dist/assets/{VirtualizedFileDiff-BfEBad09.js → VirtualizedFileDiff-ClDezf2d.js} +1 -1
  27. package/dist/assets/{WalkthroughView-Cj3K8Xz6.js → WalkthroughView-BJicp0RC.js} +2 -2
  28. package/dist/assets/{appThemeRegistry-DAdawJZn.js → appThemeRegistry-CiuyzfSW.js} +1 -1
  29. package/dist/assets/changesTree-CePHbFcj.js +1 -0
  30. package/dist/assets/commitAndPush-DTKb6IwT.js +1 -0
  31. package/dist/assets/{computeVirtualFileMetrics-RF-KjfUH.js → computeVirtualFileMetrics-kM_zJNMt.js} +2 -2
  32. package/dist/assets/de-BMnfXm-G.js +10 -0
  33. package/dist/assets/es-DKEvCCL6.js +16 -0
  34. package/dist/assets/fr-D9fgE2xE.js +14 -0
  35. package/dist/assets/index-DUke1nme.css +1 -0
  36. package/dist/assets/{index-CL7ZQ94-.js → index-DWB8bR_g.js} +1 -1
  37. package/dist/assets/index-Di6zUe5h.js +41 -0
  38. package/dist/assets/{isDiffPlainText-D2yJ9fO6.js → isDiffPlainText-DOoIsGqs.js} +1 -1
  39. package/dist/assets/{isFilePlainText-BwMEq1Ey.js → isFilePlainText-bt5VHvcd.js} +1 -1
  40. package/dist/assets/ja-BEXad-KW.js +16 -0
  41. package/dist/assets/ko-DGrQ0lEe.js +16 -0
  42. package/dist/assets/{main-quLSA7jc.js → main-BpLQbZC2.js} +2 -2
  43. package/dist/assets/main-CI9DtHJv.js +896 -0
  44. package/dist/assets/{markdown-shiki.worker-CahLvHIq.js → markdown-shiki.worker-Bg8SoAJJ.js} +8 -5
  45. package/dist/assets/markdownCore-DK2KK5RW.js +7 -0
  46. package/dist/assets/{miniChat-D-lJJibr.js → miniChat-CIWaR8wG.js} +2 -2
  47. package/dist/assets/{mobile-VTtphbW6.js → mobile-CnWKEYIP.js} +2 -2
  48. package/dist/assets/patchFileDiff-Cqn0C0az.js +13 -0
  49. package/dist/assets/pl-BlhkmR5b.js +16 -0
  50. package/dist/assets/pt-BR-Bs96Ew9I.js +16 -0
  51. package/dist/assets/{renderElectronMiniChatApp-DF7ch5eM.js → renderElectronMiniChatApp-D9t0-9nV.js} +2 -2
  52. package/dist/assets/renderMobileApp-CW7fVs_s.js +25 -0
  53. package/dist/assets/runtimeEndpointReset-D0PcCb3K.js +27 -0
  54. package/dist/assets/scheduledTasksApi-Dahv3ws0.js +1 -0
  55. package/dist/assets/{shikiHighlight-_HeCCHx1.js → shikiHighlight-l-MbaNS4.js} +1 -1
  56. package/dist/assets/{textMateThemeFromAppTheme-C95XdFGj.js → textMateThemeFromAppTheme-C-kGcOpD.js} +1 -1
  57. package/dist/assets/tr-CehUK1M9.js +7 -0
  58. package/dist/assets/uk-Bno5WGNz.js +16 -0
  59. package/dist/assets/{useAppFontEffects-ZM9ij19-.js → useAppFontEffects-DDjuwVqi.js} +160 -156
  60. package/dist/assets/{useInlineCommentController-CkfxMJPy.js → useInlineCommentController-Clg8EO3A.js} +2 -2
  61. package/dist/assets/{useRootScrollLock-BtiAC1fj.js → useRootScrollLock-nDcRrgJ1.js} +2 -2
  62. package/dist/assets/{vendor-codemirror-language-data-U-lXzXNK.js → vendor-codemirror-language-data-oKF0MAwd.js} +2 -2
  63. package/dist/assets/vendor-opencode-client-DSZl7jSi.js +12 -0
  64. package/dist/assets/vendor-zod-BSoePkpP.js +39 -0
  65. package/dist/assets/{web-components-C583Pmfm.js → web-components-Bc1WPd4i.js} +1 -1
  66. package/dist/assets/zh-CN-DnAweIWo.js +16 -0
  67. package/dist/assets/zh-TW-BbBBK25p.js +16 -0
  68. package/dist/index.html +5 -5
  69. package/dist/mini-chat.html +5 -5
  70. package/dist/mobile.html +5 -5
  71. package/package.json +3 -3
  72. package/server/index.d.ts +2 -0
  73. package/server/index.js +196 -52
  74. package/server/lib/agent-tool/DOCUMENTATION.md +5 -0
  75. package/server/lib/agent-tool/runtime.js +89 -51
  76. package/server/lib/agent-tool/runtime.test.js +17 -0
  77. package/server/lib/browser-control/DOCUMENTATION.md +14 -2
  78. package/server/lib/event-stream/DOCUMENTATION.md +2 -2
  79. package/server/lib/event-stream/global-hub.js +45 -7
  80. package/server/lib/event-stream/global-hub.test.js +128 -2
  81. package/server/lib/event-stream/global-ws-bridge.js +3 -1
  82. package/server/lib/event-stream/rebind.test.js +1 -0
  83. package/server/lib/event-stream/runtime.js +1 -2
  84. package/server/lib/event-stream/upstream-reader.js +3 -2
  85. package/server/lib/git/DOCUMENTATION.md +1 -1
  86. package/server/lib/git/service.js +22 -0
  87. package/server/lib/git/service.test.js +8 -0
  88. package/server/lib/guests/DOCUMENTATION.md +4 -3
  89. package/server/lib/guests/catalog.js +19 -1
  90. package/server/lib/guests/catalog.test.js +22 -0
  91. package/server/lib/guests/html-styles.js +5 -3
  92. package/server/lib/guests/html-styles.test.js +3 -1
  93. package/server/lib/guests/routes.js +11 -3
  94. package/server/lib/guests/service.js +5 -0
  95. package/server/lib/guests/surface.js +55 -9
  96. package/server/lib/guests/surface.test.js +56 -6
  97. package/server/lib/notifications/DOCUMENTATION.md +10 -0
  98. package/server/lib/notifications/emit-route.js +118 -0
  99. package/server/lib/notifications/emit-route.test.js +112 -0
  100. package/server/lib/openchamber-control/actions.js +16 -2
  101. package/server/lib/openchamber-control/service.js +23 -0
  102. package/server/lib/openchamber-control/service.test.js +31 -0
  103. package/server/lib/openchamber-sessions/fork-inheritance.js +103 -0
  104. package/server/lib/openchamber-sessions/fork-inheritance.test.js +69 -0
  105. package/server/lib/openchamber-sessions/routes.js +11 -0
  106. package/server/lib/openchamber-sessions/routes.test.js +18 -0
  107. package/server/lib/opencode/DOCUMENTATION.md +23 -23
  108. package/server/lib/opencode/bootstrap-runtime.js +14 -1
  109. package/server/lib/opencode/compatibility.js +6 -3
  110. package/server/lib/opencode/compatibility.test.js +8 -0
  111. package/server/lib/opencode/config-entity-v2.test.js +27 -0
  112. package/server/lib/opencode/config-paths.test.js +12 -0
  113. package/server/lib/opencode/config-v2.d.ts +2 -0
  114. package/server/lib/opencode/config-v2.js +42 -0
  115. package/server/lib/opencode/config-v2.test.js +17 -0
  116. package/server/lib/opencode/core-routes.js +20 -4
  117. package/server/lib/opencode/core-routes.test.js +41 -0
  118. package/server/lib/opencode/env-runtime.js +1 -8
  119. package/server/lib/opencode/feature-routes-runtime.js +2 -1
  120. package/server/lib/opencode/lifecycle.js +156 -2
  121. package/server/lib/opencode/lifecycle.test.js +255 -1
  122. package/server/lib/opencode/managed-config-file.js +18 -6
  123. package/server/lib/opencode/managed-config-file.test.js +23 -7
  124. package/server/lib/opencode/mcp.js +7 -2
  125. package/server/lib/opencode/network-runtime.js +16 -7
  126. package/server/lib/opencode/network-runtime.test.js +29 -1
  127. package/server/lib/opencode/npm-registry-config.d.ts +1 -0
  128. package/server/lib/opencode/npm-registry-config.js +123 -0
  129. package/server/lib/opencode/npm-registry-config.test.js +167 -0
  130. package/server/lib/opencode/npm-registry.js +5 -6
  131. package/server/lib/opencode/npm-registry.test.js +30 -0
  132. package/server/lib/opencode/openchamber-routes.js +29 -11
  133. package/server/lib/opencode/openchamber-routes.test.js +18 -0
  134. package/server/lib/opencode/project-directory-runtime.js +8 -0
  135. package/server/lib/opencode/project-directory-runtime.test.js +13 -0
  136. package/server/lib/opencode/provider-source-route.test.js +59 -0
  137. package/server/lib/opencode/providers.js +3 -0
  138. package/server/lib/opencode/providers.test.js +62 -0
  139. package/server/lib/opencode/proxy.js +53 -16
  140. package/server/lib/opencode/routes-upgrade.test.js +1 -1
  141. package/server/lib/opencode/routes.js +36 -1
  142. package/server/lib/opencode/server-startup-runtime.js +25 -0
  143. package/server/lib/opencode/server-startup-runtime.test.js +67 -1
  144. package/server/lib/opencode/server-utils-runtime.js +5 -0
  145. package/server/lib/opencode/settings-helpers.js +10 -0
  146. package/server/lib/opencode/settings-helpers.test.js +2 -2
  147. package/server/lib/opencode/settings-registry.json +17 -0
  148. package/server/lib/opencode/settings-runtime.js +1 -0
  149. package/server/lib/opencode/shared-service-runtime.js +155 -0
  150. package/server/lib/opencode/shared-service-runtime.test.js +180 -0
  151. package/server/lib/opencode/shared.js +49 -4
  152. package/server/lib/opencode/shared.test.js +78 -1
  153. package/server/lib/opencode/shutdown-runtime.js +14 -5
  154. package/server/lib/opencode/shutdown-runtime.test.js +77 -2
  155. package/server/lib/opencode/skill-routes.js +28 -12
  156. package/server/lib/opencode/skill-routes.test.js +121 -1
  157. package/server/lib/opencode/startup-pipeline-runtime.js +5 -4
  158. package/server/lib/opencode/startup-pipeline-runtime.test.js +18 -7
  159. package/server/lib/opencode/upgrade-capability.js +0 -14
  160. package/server/lib/opencode/v1-migration-topup.js +21 -15
  161. package/server/lib/opencode/v1-migration-topup.test.js +14 -6
  162. package/server/lib/opencode/v2-install.d.ts +1 -0
  163. package/server/lib/opencode/v2-install.js +8 -7
  164. package/server/lib/opencode/v2-install.test.js +13 -4
  165. package/server/lib/opencode/watcher.js +3 -1
  166. package/server/lib/opencode/watcher.test.js +54 -0
  167. package/server/lib/opencode/websearch-config.js +13 -1
  168. package/server/lib/quota/providers/kimi.js +1 -1
  169. package/server/lib/realtime-proxy.js +5 -1
  170. package/server/lib/realtime-proxy.test.js +29 -0
  171. package/server/lib/relay/tunnel-host.js +4 -1
  172. package/server/lib/relay/tunnel-host.test.js +11 -0
  173. package/server/lib/session-assist/DOCUMENTATION.md +17 -5
  174. package/server/lib/session-assist/runtime.js +19 -2
  175. package/server/lib/session-assist/runtime.test.js +8 -0
  176. package/server/lib/session-goal/DOCUMENTATION.md +11 -0
  177. package/server/lib/small-model/DOCUMENTATION.md +11 -10
  178. package/server/lib/small-model/index.js +27 -32
  179. package/server/lib/small-model/index.test.js +28 -16
  180. package/server/lib/spaces/DOCUMENTATION.md +404 -32
  181. package/server/lib/spaces/code-in-bait.js +145 -6
  182. package/server/lib/spaces/code-in.js +10 -150
  183. package/server/lib/spaces/code-in.test.js +7 -83
  184. package/server/lib/spaces/code-out.js +1819 -0
  185. package/server/lib/spaces/code-out.test.js +3945 -0
  186. package/server/lib/spaces/code-transfer.js +173 -0
  187. package/server/lib/spaces/command-stream.test.js +181 -0
  188. package/server/lib/spaces/dispatcher.js +670 -0
  189. package/server/lib/spaces/dispatcher.test.js +582 -0
  190. package/server/lib/spaces/gatekeeper-channel.js +61 -7
  191. package/server/lib/spaces/gatekeeper-channel.test.js +43 -7
  192. package/server/lib/spaces/gatekeeper-program.cjs +68 -14
  193. package/server/lib/spaces/gatekeeper-program.test.js +118 -3
  194. package/server/lib/spaces/hardening.js +20 -4
  195. package/server/lib/spaces/hardening.test.js +18 -3
  196. package/server/lib/spaces/host-git.js +7 -4
  197. package/server/lib/spaces/host-git.test.js +6 -2
  198. package/server/lib/spaces/host.js +310 -0
  199. package/server/lib/spaces/host.test.js +273 -0
  200. package/server/lib/spaces/journey.js +552 -0
  201. package/server/lib/spaces/journey.test.js +589 -0
  202. package/server/lib/spaces/layout.js +42 -8
  203. package/server/lib/spaces/manager.js +4 -3
  204. package/server/lib/spaces/manager.test.js +6 -0
  205. package/server/lib/spaces/places/code-out.docker.live.test.js +412 -0
  206. package/server/lib/spaces/places/contract-suite.js +36 -0
  207. package/server/lib/spaces/places/dispatcher.docker.live.test.js +296 -0
  208. package/server/lib/spaces/places/docker-live-support.js +27 -4
  209. package/server/lib/spaces/places/docker.js +33 -5
  210. package/server/lib/spaces/places/docker.test.js +42 -3
  211. package/server/lib/spaces/places/escape-suite.js +118 -14
  212. package/server/lib/spaces/places/fake-docker.js +3 -1
  213. package/server/lib/spaces/places/grants.docker.live.test.js +197 -0
  214. package/server/lib/spaces/places/memory-place.js +24 -1
  215. package/server/lib/spaces/places/registry.js +1 -1
  216. package/server/lib/spaces/places/server.docker.live.test.js +69 -33
  217. package/server/lib/spaces/places/sessions.docker.live.test.js +222 -0
  218. package/server/lib/spaces/routes.js +216 -0
  219. package/server/lib/spaces/routes.test.js +213 -0
  220. package/server/lib/spaces/run-command.js +261 -8
  221. package/server/lib/spaces/run-command.test.js +193 -3
  222. package/server/lib/spaces/space-events.js +153 -0
  223. package/server/lib/spaces/space-events.test.js +160 -0
  224. package/server/lib/spaces/space-opencode.js +52 -0
  225. package/server/lib/spaces/space-opencode.test.js +41 -0
  226. package/server/lib/spaces/space-records.js +150 -0
  227. package/server/lib/spaces/space-records.test.js +113 -0
  228. package/server/lib/spaces/space-sessions.js +210 -0
  229. package/server/lib/spaces/space-sessions.test.js +161 -0
  230. package/server/lib/spaces/websocket.js +329 -0
  231. package/server/lib/spaces/websocket.test.js +345 -0
  232. package/server/lib/ui-auth/ui-auth.js +8 -2
  233. package/server/lib/ui-auth/ui-auth.test.js +29 -0
  234. package/server/opencode-proxy.test.js +271 -0
  235. package/server/proxy-headers.js +8 -2
  236. package/server/proxy-headers.test.js +20 -0
  237. package/dist/assets/DiffView-CX3wAX2S.js +0 -4
  238. package/dist/assets/FilesView-OL3CON3r.js +0 -17
  239. package/dist/assets/GuestSurfacePane-B_cqX5I5.js +0 -1
  240. package/dist/assets/MarkdownImageGallery-DFiTFZgj.js +0 -1
  241. package/dist/assets/MarkdownRendererImpl-DZm5sf1W.js +0 -12
  242. package/dist/assets/PluginPane-BibJit86.js +0 -3
  243. package/dist/assets/SettingsView-h-VpVO47.js +0 -29
  244. package/dist/assets/commitAndPush-BIPfOMXY.js +0 -1
  245. package/dist/assets/de-BqCNGROu.js +0 -10
  246. package/dist/assets/es-BTPpTRgN.js +0 -16
  247. package/dist/assets/fr-hxbzMdAg.js +0 -14
  248. package/dist/assets/index-CLcM8HKx.css +0 -1
  249. package/dist/assets/index-CvLgnuNI.js +0 -41
  250. package/dist/assets/ja-_zLwwYiy.js +0 -16
  251. package/dist/assets/ko-CFn3Dzhc.js +0 -16
  252. package/dist/assets/main-BLJaug9I.js +0 -896
  253. package/dist/assets/markdownCore-DS9FdwrI.js +0 -5
  254. package/dist/assets/patchFileDiff-D9iBcTEi.js +0 -13
  255. package/dist/assets/pl-DFHE7xac.js +0 -16
  256. package/dist/assets/pt-BR-CUjZF179.js +0 -16
  257. package/dist/assets/renderMobileApp-CNQhdXsi.js +0 -25
  258. package/dist/assets/runtimeEndpointReset-DeaERc0M.js +0 -27
  259. package/dist/assets/scheduledTasksApi-BD6Vx9pu.js +0 -1
  260. package/dist/assets/tr-BB6sA_zH.js +0 -7
  261. package/dist/assets/uk-CB4o8g-o.js +0 -16
  262. package/dist/assets/vendor-opencode-client-_BAHQo4R.js +0 -12
  263. package/dist/assets/vendor-zod-Gea9uBlY.js +0 -39
  264. package/dist/assets/zh-CN-BRyDuWhc.js +0 -16
  265. package/dist/assets/zh-TW-BDGbWA1Q.js +0 -16
@@ -2,10 +2,18 @@ import { readOpenCodeInfo, isSupportedOpenCodeVersion } from './compatibility.js
2
2
  export const createOpenCodeNetworkRuntime = (deps) => {
3
3
  const {
4
4
  state,
5
- getOpenCodeAuthHeaders,
5
+ getOpenCodeBaseUrl,
6
+ getOpenCodeAuthHeaders: readOpenCodeAuthHeaders,
6
7
  configuredOpenCodeHostname = '127.0.0.1',
7
8
  } = deps;
8
9
 
10
+ const getOpenCodeAuthHeaders = () => Object.fromEntries(
11
+ Object.entries(readOpenCodeAuthHeaders?.() ?? {}).map(([key, value]) => [
12
+ key.toLowerCase() === 'authorization' ? 'Authorization' : key,
13
+ value,
14
+ ]),
15
+ );
16
+
9
17
  const resolveConnectHostname = () => {
10
18
  const raw = typeof configuredOpenCodeHostname === 'string' ? configuredOpenCodeHostname.trim() : '';
11
19
  const hostname = raw || '127.0.0.1';
@@ -83,14 +91,14 @@ export const createOpenCodeNetworkRuntime = (deps) => {
83
91
  };
84
92
 
85
93
  const buildOpenCodeUrl = (path, prefixOverride) => {
86
- if (!state.openCodePort) {
87
- throw new Error('OpenCode port is not available');
94
+ const base = getOpenCodeBaseUrl?.() ?? state.openCodeBaseUrl;
95
+ if (!base && !state.openCodePort) {
96
+ throw new Error('OpenCode endpoint is not available');
88
97
  }
89
98
  const normalizedPath = path.startsWith('/') ? path : `/${path}`;
90
- const prefix = normalizeApiPrefix(prefixOverride !== undefined ? prefixOverride : '');
91
- const fullPath = `${prefix}${normalizedPath}`;
92
- const base = state.openCodeBaseUrl ?? `http://${resolveConnectHostname()}:${state.openCodePort}`;
93
- return `${base}${fullPath}`;
99
+ const prefix = normalizeApiPrefix(prefixOverride ?? '');
100
+ const fallback = `http://${resolveConnectHostname()}:${state.openCodePort}`;
101
+ return `${String(base ?? fallback).replace(/\/+$/, '')}${prefix}${normalizedPath}`;
94
102
  };
95
103
 
96
104
  const detectOpenCodeApiPrefix = () => {
@@ -107,6 +115,7 @@ export const createOpenCodeNetworkRuntime = (deps) => {
107
115
 
108
116
  return {
109
117
  waitForReady,
118
+ getOpenCodeAuthHeaders,
110
119
  normalizeApiPrefix,
111
120
  setDetectedOpenCodeApiPrefix,
112
121
  buildOpenCodeUrl,
@@ -13,7 +13,8 @@ const createRuntime = (overrides = {}) => createOpenCodeNetworkRuntime({
13
13
  openCodeApiDetectionTimer: null,
14
14
  ...overrides.state,
15
15
  },
16
- getOpenCodeAuthHeaders: () => ({}),
16
+ getOpenCodeBaseUrl: overrides.getOpenCodeBaseUrl,
17
+ getOpenCodeAuthHeaders: overrides.getOpenCodeAuthHeaders ?? (() => ({})),
17
18
  configuredOpenCodeHostname: overrides.configuredOpenCodeHostname,
18
19
  });
19
20
 
@@ -48,6 +49,33 @@ describe('OpenCode network runtime', () => {
48
49
  expect(runtime.buildOpenCodeUrl('/provider')).toBe('http://remote.example:4096/provider');
49
50
  });
50
51
 
52
+ it('builds each request from the current shared-service base URL', () => {
53
+ let baseUrl = 'http://127.0.0.1:4096';
54
+ const runtime = createRuntime({
55
+ state: { openCodePort: null },
56
+ getOpenCodeBaseUrl: () => baseUrl,
57
+ });
58
+
59
+ expect(runtime.buildOpenCodeUrl('/api/session')).toBe('http://127.0.0.1:4096/api/session');
60
+ baseUrl = 'http://127.0.0.1:5096/';
61
+ expect(runtime.buildOpenCodeUrl('/api/session')).toBe('http://127.0.0.1:5096/api/session');
62
+ });
63
+
64
+ it('normalizes current service auth without dropping additional headers', () => {
65
+ let headers = { authorization: 'Basic first', 'x-service-header': 'one' };
66
+ const runtime = createRuntime({ getOpenCodeAuthHeaders: () => headers });
67
+
68
+ expect(runtime.getOpenCodeAuthHeaders()).toEqual({
69
+ Authorization: 'Basic first',
70
+ 'x-service-header': 'one',
71
+ });
72
+ headers = { authorization: 'Basic replacement', 'x-service-header': 'two' };
73
+ expect(runtime.getOpenCodeAuthHeaders()).toEqual({
74
+ Authorization: 'Basic replacement',
75
+ 'x-service-header': 'two',
76
+ });
77
+ });
78
+
51
79
  it('normalizes wildcard and IPv6 OpenCode bind hosts for local connects', () => {
52
80
  expect(createRuntime({ configuredOpenCodeHostname: '0.0.0.0' }).buildOpenCodeUrl('/provider'))
53
81
  .toBe('http://127.0.0.1:4096/provider');
@@ -0,0 +1 @@
1
+ export function resolveNpmRegistryRequest(packageName: string): { url: string; headers: Record<string, string> };
@@ -0,0 +1,123 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+
5
+ const DEFAULT_NPM_REGISTRY = 'https://registry.npmjs.org';
6
+
7
+ function configPath() {
8
+ const configured = [process.env.npm_config_userconfig, process.env.NPM_CONFIG_USERCONFIG]
9
+ .map((value) => value?.trim())
10
+ .find(Boolean);
11
+ return configured || path.join(os.homedir(), '.npmrc');
12
+ }
13
+
14
+ // npm's ini parser accepts `key = "value"`; the quotes are not part of the value.
15
+ function unquote(value) {
16
+ const quoted = value.length >= 2 && (value[0] === '"' || value[0] === "'") && value.at(-1) === value[0];
17
+ return quoted ? value.slice(1, -1) : value;
18
+ }
19
+
20
+ function configValues() {
21
+ try {
22
+ const values = new Map();
23
+ for (const rawLine of fs.readFileSync(configPath(), 'utf8').split(/\r?\n/)) {
24
+ const line = rawLine.trim();
25
+ if (line === '' || line.startsWith('#') || line.startsWith(';')) continue;
26
+
27
+ const separator = line.indexOf('=');
28
+ if (separator < 1) continue;
29
+ values.set(line.slice(0, separator).trim(), unquote(line.slice(separator + 1).trim()));
30
+ }
31
+ return values;
32
+ } catch {
33
+ return new Map();
34
+ }
35
+ }
36
+
37
+ function configuredValue(values, key) {
38
+ const configured = values.get(key);
39
+ if (!configured) return configured;
40
+
41
+ return configured.replace(/\$\{([^}]+)\}/g, (_match, variable) => {
42
+ const value = process.env[variable];
43
+ if (value === undefined) throw new Error('Missing npm configuration environment variable');
44
+ return value;
45
+ });
46
+ }
47
+
48
+ function registryFor(packageName, values) {
49
+ const scope = packageName.startsWith('@') ? packageName.split('/', 1)[0] : null;
50
+ const scopedEnvironmentRegistry = scope && [
51
+ process.env[`npm_config_${scope}:registry`],
52
+ process.env[`NPM_CONFIG_${scope}:REGISTRY`],
53
+ ].map((value) => value?.trim()).find(Boolean);
54
+ if (scopedEnvironmentRegistry) return scopedEnvironmentRegistry;
55
+
56
+ const scopedRegistry = scope && configuredValue(values, `${scope}:registry`);
57
+ if (scopedRegistry) return scopedRegistry;
58
+
59
+ const environmentRegistry = [process.env.npm_config_registry, process.env.NPM_CONFIG_REGISTRY]
60
+ .map((value) => value?.trim())
61
+ .find(Boolean);
62
+ if (environmentRegistry) return environmentRegistry;
63
+
64
+ return configuredValue(values, 'registry') || DEFAULT_NPM_REGISTRY;
65
+ }
66
+
67
+ function authFor(registry, values) {
68
+ const registryScope = `//${registry.host}${registry.pathname.replace(/\/+$/, '')}/`;
69
+ let bestMatch = null;
70
+
71
+ for (const key of values.keys()) {
72
+ const match = /^(\/\/.*\/):(?:_authToken|_auth|username|_password)$/.exec(key);
73
+ if (match && registryScope.startsWith(match[1]) && (!bestMatch || match[1].length > bestMatch.length)) {
74
+ bestMatch = match[1];
75
+ }
76
+ }
77
+
78
+ if (!bestMatch) return {};
79
+
80
+ const token = configuredValue(values, `${bestMatch}:_authToken`);
81
+ if (token) return { Authorization: `Bearer ${token}` };
82
+
83
+ const authorization = configuredValue(values, `${bestMatch}:_auth`);
84
+ if (authorization) return { Authorization: `Basic ${authorization}` };
85
+
86
+ const username = configuredValue(values, `${bestMatch}:username`);
87
+ const password = configuredValue(values, `${bestMatch}:_password`);
88
+ if (username && password) {
89
+ const decodedPassword = Buffer.from(password, 'base64').toString('utf8');
90
+ return { Authorization: `Basic ${Buffer.from(`${username}:${decodedPassword}`).toString('base64')}` };
91
+ }
92
+
93
+ return {};
94
+ }
95
+
96
+ /**
97
+ * Build a credential-safe package metadata request using npm's standard user configuration.
98
+ *
99
+ * @param {string} packageName
100
+ * @returns {{ url: string, headers: Record<string, string> }}
101
+ */
102
+ export function resolveNpmRegistryRequest(packageName) {
103
+ const values = configValues();
104
+ let registry;
105
+ try {
106
+ registry = new URL(registryFor(packageName, values));
107
+ if (!['http:', 'https:'].includes(registry.protocol) || registry.search || registry.hash) {
108
+ throw new Error('invalid registry');
109
+ }
110
+ } catch {
111
+ throw new Error('Invalid npm registry URL');
112
+ }
113
+
114
+ const headers = authFor(registry, values);
115
+ registry.username = '';
116
+ registry.password = '';
117
+ registry.pathname = `${registry.pathname.replace(/\/+$/, '')}/`;
118
+
119
+ return {
120
+ url: `${registry.toString()}${encodeURIComponent(packageName).replace(/^%40/i, '@')}`,
121
+ headers,
122
+ };
123
+ }
@@ -0,0 +1,167 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
5
+
6
+ import { resolveNpmRegistryRequest } from './npm-registry-config.js';
7
+
8
+ const originalLowerRegistry = process.env.npm_config_registry;
9
+ const originalUpperRegistry = process.env.NPM_CONFIG_REGISTRY;
10
+ const originalUserConfig = process.env.NPM_CONFIG_USERCONFIG;
11
+ const originalLowerUserConfig = process.env.npm_config_userconfig;
12
+ const originalPrivateRegistry = process.env.PRIVATE_NPM_REGISTRY;
13
+ const originalPrivateToken = process.env.PRIVATE_NPM_TOKEN;
14
+ const originalScopedRegistry = process.env['npm_config_@scope:registry'];
15
+ let userConfigPath;
16
+
17
+ describe('npm registry configuration', () => {
18
+ beforeEach(() => {
19
+ userConfigPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-npmrc-')), '.npmrc');
20
+ process.env.NPM_CONFIG_USERCONFIG = userConfigPath;
21
+ delete process.env.npm_config_userconfig;
22
+ delete process.env.npm_config_registry;
23
+ delete process.env.NPM_CONFIG_REGISTRY;
24
+ delete process.env.PRIVATE_NPM_REGISTRY;
25
+ delete process.env.PRIVATE_NPM_TOKEN;
26
+ delete process.env['npm_config_@scope:registry'];
27
+ });
28
+
29
+ afterEach(() => {
30
+ fs.rmSync(path.dirname(userConfigPath), { recursive: true, force: true });
31
+ if (originalLowerRegistry === undefined) delete process.env.npm_config_registry;
32
+ else process.env.npm_config_registry = originalLowerRegistry;
33
+ if (originalUpperRegistry === undefined) delete process.env.NPM_CONFIG_REGISTRY;
34
+ else process.env.NPM_CONFIG_REGISTRY = originalUpperRegistry;
35
+ if (originalUserConfig === undefined) delete process.env.NPM_CONFIG_USERCONFIG;
36
+ else process.env.NPM_CONFIG_USERCONFIG = originalUserConfig;
37
+ if (originalLowerUserConfig === undefined) delete process.env.npm_config_userconfig;
38
+ else process.env.npm_config_userconfig = originalLowerUserConfig;
39
+ if (originalPrivateRegistry === undefined) delete process.env.PRIVATE_NPM_REGISTRY;
40
+ else process.env.PRIVATE_NPM_REGISTRY = originalPrivateRegistry;
41
+ if (originalPrivateToken === undefined) delete process.env.PRIVATE_NPM_TOKEN;
42
+ else process.env.PRIVATE_NPM_TOKEN = originalPrivateToken;
43
+ if (originalScopedRegistry === undefined) delete process.env['npm_config_@scope:registry'];
44
+ else process.env['npm_config_@scope:registry'] = originalScopedRegistry;
45
+ });
46
+
47
+ test('uses the default public registry without npm configuration', () => {
48
+ expect(resolveNpmRegistryRequest('@scope/pkg')).toEqual({
49
+ url: 'https://registry.npmjs.org/@scope%2Fpkg',
50
+ headers: {},
51
+ });
52
+ });
53
+
54
+ test('uses the user npm registry for an unscoped package', () => {
55
+ fs.writeFileSync(userConfigPath, 'registry=https://mirror.example.com/custom/npm/\n');
56
+
57
+ expect(resolveNpmRegistryRequest('private-plugin')).toEqual({
58
+ url: 'https://mirror.example.com/custom/npm/private-plugin',
59
+ headers: {},
60
+ });
61
+ });
62
+
63
+ test('prefers a scoped user registry', () => {
64
+ fs.writeFileSync(userConfigPath, [
65
+ 'registry=https://mirror.example.com/npm/',
66
+ '@scope:registry=https://packages.example.com/npm/',
67
+ ].join('\n'));
68
+
69
+ expect(resolveNpmRegistryRequest('@scope/plugin').url).toBe('https://packages.example.com/npm/@scope%2Fplugin');
70
+ });
71
+
72
+ test('prefers a scoped user registry over an inherited general registry', () => {
73
+ fs.writeFileSync(userConfigPath, '@scope:registry=https://packages.example.com/npm/\n');
74
+ process.env.NPM_CONFIG_REGISTRY = 'https://inherited.example.com/npm/';
75
+
76
+ expect(resolveNpmRegistryRequest('@scope/plugin').url).toBe('https://packages.example.com/npm/@scope%2Fplugin');
77
+ });
78
+
79
+ test('prefers a scoped inherited registry over user npm configuration', () => {
80
+ fs.writeFileSync(userConfigPath, 'registry=https://mirror.example.com/npm/\n');
81
+ process.env['npm_config_@scope:registry'] = 'https://packages.example.com/npm/';
82
+
83
+ expect(resolveNpmRegistryRequest('@scope/plugin').url).toBe('https://packages.example.com/npm/@scope%2Fplugin');
84
+ });
85
+
86
+ test('prefers an inherited npm registry over user configuration', () => {
87
+ fs.writeFileSync(userConfigPath, 'registry=https://mirror.example.com/npm/\n');
88
+ process.env.NPM_CONFIG_REGISTRY = 'https://inherited.example.com/npm/';
89
+
90
+ expect(resolveNpmRegistryRequest('plugin').url).toBe('https://inherited.example.com/npm/plugin');
91
+ });
92
+
93
+ test('uses a matching npm token without exposing it in the request URL', () => {
94
+ fs.writeFileSync(userConfigPath, [
95
+ 'registry=https://packages.example.com/npm/',
96
+ '//packages.example.com/npm/:_authToken=private-token',
97
+ ].join('\n'));
98
+
99
+ const request = resolveNpmRegistryRequest('private-plugin');
100
+
101
+ expect(request.url).toBe('https://packages.example.com/npm/private-plugin');
102
+ expect(request.url).not.toContain('private-token');
103
+ expect(request.headers).toEqual({ Authorization: 'Bearer private-token' });
104
+ });
105
+
106
+ test('uses npm basic authentication with its base64-encoded password', () => {
107
+ fs.writeFileSync(userConfigPath, [
108
+ 'registry=https://packages.example.com/npm/',
109
+ '//packages.example.com/npm/:username=registry-user',
110
+ '//packages.example.com/npm/:_password=c2VjcmV0',
111
+ ].join('\n'));
112
+
113
+ expect(resolveNpmRegistryRequest('private-plugin').headers).toEqual({
114
+ Authorization: 'Basic cmVnaXN0cnktdXNlcjpzZWNyZXQ=',
115
+ });
116
+ });
117
+
118
+ test('expands environment variables in user npm configuration', () => {
119
+ process.env.PRIVATE_NPM_TOKEN = 'expanded-token';
120
+ fs.writeFileSync(userConfigPath, [
121
+ 'registry=${PRIVATE_NPM_REGISTRY}',
122
+ '//packages.example.com/npm/:_authToken=${PRIVATE_NPM_TOKEN}',
123
+ ].join('\n'));
124
+ process.env.PRIVATE_NPM_REGISTRY = 'https://packages.example.com/npm/';
125
+
126
+ expect(resolveNpmRegistryRequest('private-plugin')).toEqual({
127
+ url: 'https://packages.example.com/npm/private-plugin',
128
+ headers: { Authorization: 'Bearer expanded-token' },
129
+ });
130
+ });
131
+
132
+ test('reads quoted values the way npm does', () => {
133
+ fs.writeFileSync(userConfigPath, [
134
+ '@scope:registry = "https://packages.example.com/npm/"',
135
+ "//packages.example.com/npm/:_authToken='quoted-token'",
136
+ ].join('\n'));
137
+
138
+ expect(resolveNpmRegistryRequest('@scope/plugin')).toEqual({
139
+ url: 'https://packages.example.com/npm/@scope%2Fplugin',
140
+ headers: { Authorization: 'Bearer quoted-token' },
141
+ });
142
+ });
143
+
144
+ test('honors the lowercase userconfig variable npm sets for child processes', () => {
145
+ delete process.env.NPM_CONFIG_USERCONFIG;
146
+ process.env.npm_config_userconfig = userConfigPath;
147
+ fs.writeFileSync(userConfigPath, 'registry=https://mirror.example.com/npm/\n');
148
+
149
+ try {
150
+ expect(resolveNpmRegistryRequest('plugin').url).toBe('https://mirror.example.com/npm/plugin');
151
+ } finally {
152
+ delete process.env.npm_config_userconfig;
153
+ }
154
+ });
155
+
156
+ test('rejects unresolved npm configuration environment variables', () => {
157
+ fs.writeFileSync(userConfigPath, 'registry=${MISSING_NPM_REGISTRY}\n');
158
+
159
+ expect(() => resolveNpmRegistryRequest('private-plugin')).toThrow('Invalid npm registry URL');
160
+ });
161
+
162
+ test('rejects malformed registry values without exposing their contents', () => {
163
+ fs.writeFileSync(userConfigPath, 'registry=not-a-url-private-token\n');
164
+
165
+ expect(() => resolveNpmRegistryRequest('plugin')).toThrow('Invalid npm registry URL');
166
+ });
167
+ });
@@ -2,9 +2,10 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { fileURLToPath } from 'url';
4
4
 
5
+ import { resolveNpmRegistryRequest } from './npm-registry-config.js';
6
+
5
7
  const NPM_CACHE_TTL_MS = 3_600_000;
6
8
  const NPM_FETCH_TIMEOUT_MS = 5_000;
7
- const NPM_REGISTRY_BASE = 'https://registry.npmjs.org';
8
9
 
9
10
  /**
10
11
  * @typedef {Object} NpmPackagePayload
@@ -50,10 +51,6 @@ function _getUserAgent() {
50
51
  return _userAgent;
51
52
  }
52
53
 
53
- function encodeName(name) {
54
- return encodeURIComponent(name).replace(/^%40/, '@');
55
- }
56
-
57
54
  function parseDistTags(value) {
58
55
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
59
56
  return {};
@@ -87,10 +84,12 @@ function cacheResult(name, payload) {
87
84
  */
88
85
  export async function lookupNpmPackage(name) {
89
86
  try {
90
- const response = await fetch(`${NPM_REGISTRY_BASE}/${encodeName(name)}`, {
87
+ const request = resolveNpmRegistryRequest(name);
88
+ const response = await fetch(request.url, {
91
89
  headers: {
92
90
  'User-Agent': _getUserAgent(),
93
91
  Accept: 'application/json',
92
+ ...request.headers,
94
93
  },
95
94
  signal: AbortSignal.timeout(NPM_FETCH_TIMEOUT_MS),
96
95
  });
@@ -1,10 +1,18 @@
1
1
  import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
2
5
  import * as npm from './npm-registry.js';
3
6
 
4
7
  const originalFetch = globalThis.fetch;
5
8
  const originalDateNow = Date.now;
9
+ const originalUserConfig = process.env.NPM_CONFIG_USERCONFIG;
10
+ const originalLowerUserConfig = process.env.npm_config_userconfig;
11
+ const originalLowerRegistry = process.env.npm_config_registry;
12
+ const originalUpperRegistry = process.env.NPM_CONFIG_REGISTRY;
6
13
 
7
14
  let fetchMock;
15
+ let userConfigPath;
8
16
 
9
17
  function jsonResponse(body, status = 200) {
10
18
  return Promise.resolve(new Response(JSON.stringify(body), {
@@ -17,6 +25,11 @@ describe('npm registry client', () => {
17
25
  beforeEach(() => {
18
26
  npm.clearCache();
19
27
  Date.now = originalDateNow;
28
+ userConfigPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-npmrc-')), '.npmrc');
29
+ process.env.NPM_CONFIG_USERCONFIG = userConfigPath;
30
+ delete process.env.npm_config_userconfig;
31
+ delete process.env.npm_config_registry;
32
+ delete process.env.NPM_CONFIG_REGISTRY;
20
33
  fetchMock = mock(() => jsonResponse({}));
21
34
  globalThis.fetch = fetchMock;
22
35
  });
@@ -25,6 +38,15 @@ describe('npm registry client', () => {
25
38
  npm.clearCache();
26
39
  globalThis.fetch = originalFetch;
27
40
  Date.now = originalDateNow;
41
+ fs.rmSync(path.dirname(userConfigPath), { recursive: true, force: true });
42
+ if (originalUserConfig === undefined) delete process.env.NPM_CONFIG_USERCONFIG;
43
+ else process.env.NPM_CONFIG_USERCONFIG = originalUserConfig;
44
+ if (originalLowerUserConfig === undefined) delete process.env.npm_config_userconfig;
45
+ else process.env.npm_config_userconfig = originalLowerUserConfig;
46
+ if (originalLowerRegistry === undefined) delete process.env.npm_config_registry;
47
+ else process.env.npm_config_registry = originalLowerRegistry;
48
+ if (originalUpperRegistry === undefined) delete process.env.NPM_CONFIG_REGISTRY;
49
+ else process.env.NPM_CONFIG_REGISTRY = originalUpperRegistry;
28
50
  });
29
51
 
30
52
  test('200 success returns latest versions and dist tags', async () => {
@@ -171,6 +193,14 @@ describe('npm registry client', () => {
171
193
  expect(fetchMock.mock.calls[0][0]).toBe('https://registry.npmjs.org/@scope%2Fpkg');
172
194
  });
173
195
 
196
+ test('user npm configuration changes the package metadata registry', async () => {
197
+ fs.writeFileSync(userConfigPath, 'registry=https://mirror.example.com/npm/\n');
198
+
199
+ await npm.getNpmInfo('private-plugin');
200
+
201
+ expect(fetchMock.mock.calls[0][0]).toBe('https://mirror.example.com/npm/private-plugin');
202
+ });
203
+
174
204
  test('user-agent header is present', async () => {
175
205
  await npm.getNpmInfo('foo');
176
206
 
@@ -71,6 +71,26 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
71
71
 
72
72
  let desktopRestartError = null;
73
73
 
74
+ /**
75
+ * How this server was launched, read from its instance file. A foreground
76
+ * server outside systemd has no process that can restart it after an
77
+ * install, so it cannot update itself.
78
+ */
79
+ const readLaunchState = async () => {
80
+ const currentPort = server.address()?.port || 3000;
81
+ const instanceFilePath = path.join(openchamberDataDir, 'run', `openchamber-${currentPort}.json`);
82
+ let storedOptions = { port: currentPort, daemon: true };
83
+ try {
84
+ const content = await fs.promises.readFile(instanceFilePath, 'utf8');
85
+ storedOptions = JSON.parse(content);
86
+ } catch {
87
+ }
88
+ const launchMode = storedOptions.launchMode === 'foreground' ? 'foreground' : 'daemon';
89
+ const isForegroundService = launchMode === 'foreground';
90
+ const systemdServiceUnit = isForegroundService ? resolveSystemdServiceUnit(process.env) : null;
91
+ return { storedOptions, launchMode, isForegroundService, systemdServiceUnit };
92
+ };
93
+
74
94
  app.get('/api/openchamber/update-check', async (req, res) => {
75
95
  try {
76
96
  const parseString = (value) => (typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined);
@@ -121,6 +141,14 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
121
141
  } else {
122
142
  const { checkForUpdates } = await import('../package-manager.js');
123
143
  updateInfo = await checkForUpdates(updateRequest);
144
+ // Tell clients up front that the install route will refuse, so they
145
+ // show the manual command instead of an Update button that fails.
146
+ if (updateInfo?.available && updateRequest.appType === 'web') {
147
+ const { isForegroundService, systemdServiceUnit } = await readLaunchState();
148
+ if (isForegroundService && !systemdServiceUnit) {
149
+ updateInfo = { ...updateInfo, installBlocked: 'service-manager' };
150
+ }
151
+ }
124
152
  }
125
153
  res.json(updateInfo);
126
154
  } catch (error) {
@@ -215,17 +243,7 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
215
243
  return;
216
244
  }
217
245
 
218
- const currentPort = server.address()?.port || 3000;
219
- const instanceFilePath = path.join(openchamberDataDir, 'run', `openchamber-${currentPort}.json`);
220
- let storedOptions = { port: currentPort, daemon: true };
221
- try {
222
- const content = await fs.promises.readFile(instanceFilePath, 'utf8');
223
- storedOptions = JSON.parse(content);
224
- } catch {
225
- }
226
- const launchMode = storedOptions.launchMode === 'foreground' ? 'foreground' : 'daemon';
227
- const isForegroundService = launchMode === 'foreground';
228
- const systemdServiceUnit = isForegroundService ? resolveSystemdServiceUnit(process.env) : null;
246
+ const { storedOptions, launchMode, isForegroundService, systemdServiceUnit } = await readLaunchState();
229
247
 
230
248
  if (isForegroundService) {
231
249
  if (!systemdServiceUnit) {
@@ -203,6 +203,24 @@ describe('OpenChamber desktop host update route', () => {
203
203
  });
204
204
 
205
205
  describe('OpenChamber foreground update route', () => {
206
+ it('marks an available update as blocked when the foreground server has no service manager', async () => {
207
+ const { app } = createApp();
208
+
209
+ const response = await request(app).get('/api/openchamber/update-check?appType=web').expect(200);
210
+
211
+ expect(response.body).toMatchObject({ available: true, installBlocked: 'service-manager' });
212
+ });
213
+
214
+ it('leaves the update installable for a systemd-owned or daemon server', async () => {
215
+ const systemd = createApp({ environment: { INVOCATION_ID: 'systemd-invocation' } });
216
+ const daemon = createApp({ storedOptions: { launchMode: 'daemon' } });
217
+
218
+ for (const { app } of [systemd, daemon]) {
219
+ const response = await request(app).get('/api/openchamber/update-check?appType=web').expect(200);
220
+ expect(response.body.installBlocked).toBeUndefined();
221
+ }
222
+ });
223
+
206
224
  it('rejects a foreground update when the server is not owned by systemd', async () => {
207
225
  const { app } = createApp();
208
226
 
@@ -16,6 +16,10 @@ export const createProjectDirectoryRuntime = (dependencies) => {
16
16
  readSettingsFromDiskMigrated,
17
17
  getReadSettingsFromDiskMigrated,
18
18
  sanitizeProjects,
19
+ // `refuseDirectory(candidate)` answers the reason a directory may not be used on this host,
20
+ // or null. It runs before the path is looked at, so a refused directory is never touched and
21
+ // never falls back to another one. The isolated-spaces host refuses `/spaces/...` with it.
22
+ refuseDirectory = () => null,
19
23
  } = dependencies;
20
24
  const realpathCache = createRealpathCache({
21
25
  realpath: fsPromises.realpath.bind(fsPromises),
@@ -38,6 +42,10 @@ export const createProjectDirectoryRuntime = (dependencies) => {
38
42
  if (!resolved) {
39
43
  return { ok: false, error: 'Directory parameter is required' };
40
44
  }
45
+ const refusal = refuseDirectory(resolved);
46
+ if (typeof refusal === 'string' && refusal.length > 0) {
47
+ return { ok: false, error: refusal };
48
+ }
41
49
  try {
42
50
  const stats = await fsPromises.stat(resolved);
43
51
  if (!stats.isDirectory()) {
@@ -42,6 +42,19 @@ describe('project directory runtime', () => {
42
42
  expect(result).toEqual({ ok: true, directory: '/real/path/to/project', requestedDirectory: '/symlink/path/to/project' });
43
43
  });
44
44
 
45
+ it('refuses a directory the host says no to, before it looks at the disk', async () => {
46
+ let statCalls = 0;
47
+ const runtime = createTestRuntime({
48
+ fsPromises: { stat: async () => { statCalls += 1; return { isDirectory: () => true }; }, realpath: async (p) => p },
49
+ refuseDirectory: (candidate) => (candidate.startsWith('/spaces/') ? 'A directory under /spaces/ belongs to an isolated space' : null),
50
+ });
51
+
52
+ expect(await runtime.validateDirectoryPath('/spaces/a1b2c3d4e5f6/repo')).toEqual({ ok: false, error: 'A directory under /spaces/ belongs to an isolated space' });
53
+ expect(statCalls).toBe(0);
54
+ expect(await runtime.validateDirectoryPath('/home/user/project')).toMatchObject({ ok: true });
55
+ expect(statCalls).toBe(1);
56
+ });
57
+
45
58
  it('returns error when candidate is empty', async () => {
46
59
  const runtime = createTestRuntime();
47
60
  const result = await runtime.validateDirectoryPath('');