@vantran-se/openchamber-web 2.0.4 → 2.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (243) hide show
  1. package/dist/assets/{CodeMirrorCommentWidgets-DZF8ntaq.js → CodeMirrorCommentWidgets-BlzB2e0C.js} +1 -1
  2. package/dist/assets/DiffView-D7JQzQVZ.js +4 -0
  3. package/dist/assets/FilesView-DC2gadXM.js +17 -0
  4. package/dist/assets/{GitView-DwQwqDvu.js → GitView-Gzl0wbdd.js} +4 -4
  5. package/dist/assets/{GuestAttachDialog-CoXM0gh_.js → GuestAttachDialog-CQdDIeco.js} +1 -1
  6. package/dist/assets/GuestSurfacePane-CrOhlIdt.js +1 -0
  7. package/dist/assets/{JsonTreeView-Cq_27v0y.js → JsonTreeView-C3QZifYe.js} +1 -1
  8. package/dist/assets/{LinearIssuesView-CsSSRMhn.js → LinearIssuesView-X27TCI3v.js} +1 -1
  9. package/dist/assets/MarkdownImageGallery-BlTWU9oH.js +1 -0
  10. package/dist/assets/MarkdownRendererImpl-Cno7QsJO.js +12 -0
  11. package/dist/assets/{OnboardingScreen-ClatMQAX.js → OnboardingScreen-Wr_UzNtt.js} +1 -1
  12. package/dist/assets/{PatchDiff-DOwlrIK9.js → PatchDiff-D6pWS7cD.js} +1 -1
  13. package/dist/assets/{PierreDiffViewer-CrcGqqD_.js → PierreDiffViewer-DDljFbJJ.js} +3 -3
  14. package/dist/assets/{PlanView-BGLsMgQk.js → PlanView-LcfEbBPi.js} +4 -4
  15. package/dist/assets/PluginPane-DPWPLc2f.js +3 -0
  16. package/dist/assets/SettingsView-CFe9O3EM.js +30 -0
  17. package/dist/assets/{SettingsWindow-B0BAy-f9.js → SettingsWindow-DT1xhvgi.js} +1 -1
  18. package/dist/assets/{SubmoduleDiffSummary-fcdDzCSW.js → SubmoduleDiffSummary-CBHXaIkB.js} +1 -1
  19. package/dist/assets/{ToolOutputDialog-B7xaUz2H.js → ToolOutputDialog-B_ZX8ZbZ.js} +6 -6
  20. package/dist/assets/{ToolPartDiffPreview-CkWCaUmZ.js → ToolPartDiffPreview-D_HQzkXU.js} +2 -2
  21. package/dist/assets/{VirtualizedFileDiff-DONC4eI2.js → VirtualizedFileDiff-BUK1ujXg.js} +1 -1
  22. package/dist/assets/{WalkthroughView-CXDImOXW.js → WalkthroughView-H-tYOvhO.js} +2 -2
  23. package/dist/assets/{appThemeRegistry-Br2TLSlb.js → appThemeRegistry-BoJGAwCP.js} +1 -1
  24. package/dist/assets/changesTree-CePHbFcj.js +1 -0
  25. package/dist/assets/commitAndPush-BzZrF8bK.js +1 -0
  26. package/dist/assets/{computeVirtualFileMetrics-_-20umCs.js → computeVirtualFileMetrics-kYyVjXLu.js} +2 -2
  27. package/dist/assets/de-ByfQDcPj.js +10 -0
  28. package/dist/assets/es-DzNbjsQS.js +16 -0
  29. package/dist/assets/fr-B8AVLrnX.js +14 -0
  30. package/dist/assets/{index-OKDYjcMF.js → index-0-ON1L7L.js} +1 -1
  31. package/dist/assets/index-DUke1nme.css +1 -0
  32. package/dist/assets/index-Di6zUe5h.js +41 -0
  33. package/dist/assets/{isDiffPlainText-D77M6q1f.js → isDiffPlainText-Bgm_aLpu.js} +1 -1
  34. package/dist/assets/{isFilePlainText-C54B4Mns.js → isFilePlainText-D5GMgURH.js} +1 -1
  35. package/dist/assets/ja-1g6S0G19.js +16 -0
  36. package/dist/assets/ko-CxplvYkJ.js +16 -0
  37. package/dist/assets/{main-CGEvLMd2.js → main--Y98ZLsD.js} +2 -2
  38. package/dist/assets/main-GjXmVNpD.js +896 -0
  39. package/dist/assets/{markdown-shiki.worker-CahLvHIq.js → markdown-shiki.worker-Bg8SoAJJ.js} +8 -5
  40. package/dist/assets/markdownCore-CVdUcVCi.js +7 -0
  41. package/dist/assets/{miniChat-BSxkRQLj.js → miniChat-tTx5mpbd.js} +2 -2
  42. package/dist/assets/{mobile-C5KRTNOB.js → mobile-BmbiDWGb.js} +2 -2
  43. package/dist/assets/patchFileDiff-CV4iVQ8L.js +13 -0
  44. package/dist/assets/pl-f3QyqvIB.js +16 -0
  45. package/dist/assets/pt-BR-BsaoaiCK.js +16 -0
  46. package/dist/assets/{renderElectronMiniChatApp-CnkAQV6o.js → renderElectronMiniChatApp-BnCdnDhv.js} +2 -2
  47. package/dist/assets/renderMobileApp-CKn8nbSW.js +25 -0
  48. package/dist/assets/runtimeEndpointReset-sCi88eed.js +27 -0
  49. package/dist/assets/scheduledTasksApi-Dahv3ws0.js +1 -0
  50. package/dist/assets/{shikiHighlight-4O0ZM14D.js → shikiHighlight-O5_xLmWt.js} +1 -1
  51. package/dist/assets/{textMateThemeFromAppTheme-yNq7b0DZ.js → textMateThemeFromAppTheme-XM4VefqN.js} +1 -1
  52. package/dist/assets/tr-BNFsa6nz.js +7 -0
  53. package/dist/assets/uk-CJ9y5ULA.js +16 -0
  54. package/dist/assets/{useAppFontEffects-Bzyszv2N.js → useAppFontEffects-BN06mgHB.js} +160 -156
  55. package/dist/assets/{useInlineCommentController-zOf6ORAq.js → useInlineCommentController-Bb9Gjz1Q.js} +2 -2
  56. package/dist/assets/{useRootScrollLock-DCaZ4BWi.js → useRootScrollLock-Chk1cFUw.js} +2 -2
  57. package/dist/assets/{vendor-codemirror-language-data-6cEFPNUR.js → vendor-codemirror-language-data-C2qMZiA7.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-BQWjCn_U.js} +1 -1
  61. package/dist/assets/zh-CN-U0QjVlAA.js +16 -0
  62. package/dist/assets/zh-TW-J9PTJQJi.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 +4 -4
  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
@@ -2,6 +2,17 @@ import { appendManagedPlugin } from './managed-plugin-config.js';
2
2
 
3
3
  export const MANAGED_CONFIG_FILE_NAME = 'opencode.managed.json';
4
4
 
5
+ /**
6
+ * Built-in OpenCode plugins OpenChamber switches off for its managed child.
7
+ *
8
+ * `opencode.browser` only works when OpenCode's own desktop app attaches a
9
+ * browser to the session; OpenChamber does not implement that protocol, so its
10
+ * tools always fail with `browser.disconnected` and agents conclude browsing is
11
+ * unavailable instead of using `openchamber_web`. A project config that lists
12
+ * `opencode.browser` still re-enables it, because that layer sits above ours.
13
+ */
14
+ const DISABLED_BUILTIN_PLUGINS = ['-opencode.browser'];
15
+
5
16
  /**
6
17
  * The managed OpenCode config layer.
7
18
  *
@@ -12,7 +23,8 @@ export const MANAGED_CONFIG_FILE_NAME = 'opencode.managed.json';
12
23
  * process — where `OPENCODE_CONFIG_CONTENT` (an environment variable) could
13
24
  * only ever change across a restart.
14
25
  *
15
- * The file is OpenChamber-owned and holds nothing but `plugins`. It sits above
26
+ * The file is OpenChamber-owned and holds nothing but `plugins`: the built-in
27
+ * plugins OpenChamber disables, then its own plugin directories. It sits above
16
28
  * the user's global `opencode.json` and below their project config, so it never
17
29
  * shadows a project-level choice.
18
30
  *
@@ -46,7 +58,7 @@ export const createManagedConfigRuntime = ({
46
58
 
47
59
  const writeConfigFile = async (pluginDirectories) => {
48
60
  await fsPromises.mkdir(dataDir, { recursive: true });
49
- const text = `${JSON.stringify({ plugins: pluginDirectories }, null, 2)}\n`;
61
+ const text = `${JSON.stringify({ plugins: [...DISABLED_BUILTIN_PLUGINS, ...pluginDirectories] }, null, 2)}\n`;
50
62
  // Temp + rename: OpenCode reacts to the write immediately, and a partial
51
63
  // read would make it drop every managed plugin until the next change.
52
64
  const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
@@ -69,10 +81,11 @@ export const createManagedConfigRuntime = ({
69
81
  const includeControl = settings?.agentControlToolEnabled !== false;
70
82
  const includeWeb = settings?.agentWebToolEnabled !== false;
71
83
  const includeMemory = isAgentMemoryAvailable() && settings?.agentMemoryToolEnabled === true;
84
+ const includeNotify = settings?.agentNotifyToolEnabled === true;
72
85
 
73
86
  const directories = [];
74
- if (agentToolRuntime && (includeControl || includeWeb || includeMemory)) {
75
- directories.push(await agentToolRuntime.materializePlugin({ includeControl, includeWeb, includeMemory }));
87
+ if (agentToolRuntime && (includeControl || includeWeb || includeMemory || includeNotify)) {
88
+ directories.push(await agentToolRuntime.materializePlugin({ includeControl, includeWeb, includeMemory, includeNotify }));
76
89
  }
77
90
  return directories;
78
91
  };
@@ -89,9 +102,8 @@ export const createManagedConfigRuntime = ({
89
102
  await writeConfigFile(directories);
90
103
  return { ...childEnv, OPENCODE_CONFIG: filePath };
91
104
  }
92
- if (directories.length === 0) return childEnv;
93
105
  let content = env.OPENCODE_CONFIG_CONTENT;
94
- for (const directory of directories) {
106
+ for (const directory of [...DISABLED_BUILTIN_PLUGINS, ...directories]) {
95
107
  content = appendManagedPlugin(content, directory, 'managed plugin');
96
108
  }
97
109
  return { ...childEnv, OPENCODE_CONFIG_CONTENT: content };
@@ -52,7 +52,7 @@ describe('managed OpenCode config file', () => {
52
52
  expect(childEnv.OPENCODE_CONFIG_CONTENT).toBeUndefined();
53
53
  expect(childEnv.OPENCHAMBER_AGENT_TOOL_TOKEN).toBe('token');
54
54
  expect(await readConfigFile()).toEqual({
55
- plugins: [path.join(dataDir, 'agent-tool', 'openchamber-agent-tool')],
55
+ plugins: ['-opencode.browser', path.join(dataDir, 'agent-tool', 'openchamber-agent-tool')],
56
56
  });
57
57
  });
58
58
 
@@ -61,7 +61,7 @@ describe('managed OpenCode config file', () => {
61
61
 
62
62
  await runtime.buildManagedChildEnv();
63
63
 
64
- for (const directory of (await readConfigFile()).plugins) {
64
+ for (const directory of (await readConfigFile()).plugins.filter((entry) => !entry.startsWith('-'))) {
65
65
  expect((await fs.stat(directory)).isDirectory()).toBe(true);
66
66
  }
67
67
  });
@@ -74,7 +74,7 @@ describe('managed OpenCode config file', () => {
74
74
  const childEnv = await runtime.buildManagedChildEnv();
75
75
 
76
76
  expect(agentToolRuntime.materializePlugin).not.toHaveBeenCalled();
77
- expect(await readConfigFile()).toEqual({ plugins: [] });
77
+ expect(await readConfigFile()).toEqual({ plugins: ['-opencode.browser'] });
78
78
  // A tool switched on later reaches a process that can already call back.
79
79
  expect(childEnv.OPENCHAMBER_AGENT_TOOL_TOKEN).toBe('token');
80
80
  });
@@ -89,7 +89,7 @@ describe('managed OpenCode config file', () => {
89
89
  expect(await runtime.refreshManagedConfigFile()).toEqual({ updated: true });
90
90
 
91
91
  const { plugins } = await readConfigFile();
92
- expect(plugins).toEqual([]);
92
+ expect(plugins).toEqual(['-opencode.browser']);
93
93
  expect(agentToolRuntime.createChildEnv).toHaveBeenCalledTimes(1);
94
94
  });
95
95
 
@@ -102,11 +102,27 @@ describe('managed OpenCode config file', () => {
102
102
  current.settings = { agentControlToolEnabled: false, agentWebToolEnabled: true };
103
103
  await runtime.refreshManagedConfigFile();
104
104
 
105
- expect((await readConfigFile()).plugins).toHaveLength(1);
105
+ expect((await readConfigFile()).plugins).toHaveLength(2);
106
106
  expect(agentToolRuntime.materializePlugin).toHaveBeenCalledWith({
107
107
  includeControl: false,
108
108
  includeWeb: true,
109
109
  includeMemory: false,
110
+ includeNotify: false,
111
+ });
112
+ });
113
+
114
+ it('injects the notify tool only when it is switched on', async () => {
115
+ const { runtime, agentToolRuntime } = await createHarness({
116
+ settings: { agentControlToolEnabled: false, agentWebToolEnabled: false, agentNotifyToolEnabled: true },
117
+ });
118
+
119
+ await runtime.buildManagedChildEnv();
120
+
121
+ expect(agentToolRuntime.materializePlugin).toHaveBeenCalledWith({
122
+ includeControl: false,
123
+ includeWeb: false,
124
+ includeMemory: false,
125
+ includeNotify: true,
110
126
  });
111
127
  });
112
128
 
@@ -121,7 +137,7 @@ describe('managed OpenCode config file', () => {
121
137
  expect(childEnv.OPENCODE_CONFIG).toBeUndefined();
122
138
  expect(JSON.parse(childEnv.OPENCODE_CONFIG_CONTENT)).toEqual({
123
139
  model: 'test/model',
124
- plugins: [path.join(dataDir, 'agent-tool', 'openchamber-agent-tool')],
140
+ plugins: ['-opencode.browser', path.join(dataDir, 'agent-tool', 'openchamber-agent-tool')],
125
141
  });
126
142
  await expect(fs.stat(path.join(dataDir, MANAGED_CONFIG_FILE_NAME))).rejects.toThrow();
127
143
  expect(await runtime.refreshManagedConfigFile()).toEqual({ updated: false, reason: 'external-config' });
@@ -147,6 +163,6 @@ describe('managed OpenCode config file', () => {
147
163
  await runtime.buildManagedChildEnv();
148
164
 
149
165
  expect(agentToolRuntime.materializePlugin).not.toHaveBeenCalled();
150
- expect(await readConfigFile()).toEqual({ plugins: [] });
166
+ expect(await readConfigFile()).toEqual({ plugins: ['-opencode.browser'] });
151
167
  });
152
168
  });
@@ -48,9 +48,14 @@ function ensureProjectMcpConfigPath(workingDirectory) {
48
48
  return path.join(configDir, 'opencode.json');
49
49
  }
50
50
 
51
- /** Same precedence as `getJsonEntrySource`: custom > project > user. */
51
+ /** Same precedence as `getJsonEntrySource`: custom > project > user override > user. */
52
52
  function readMcpEntriesAcrossLayers(layers) {
53
- return readLayeredMcpEntries([layers?.userConfig, layers?.projectConfig, layers?.customConfig]);
53
+ return readLayeredMcpEntries([
54
+ layers?.userConfig,
55
+ layers?.userOverrideConfig,
56
+ layers?.projectConfig,
57
+ layers?.customConfig,
58
+ ]);
54
59
  }
55
60
 
56
61
  function listMcpConfigs(workingDirectory) {
@@ -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()) {