@vantran-se/openchamber-web 1.22.1 → 1.23.0

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 (238) hide show
  1. package/README.md +2 -3
  2. package/bin/cli.js +11 -1
  3. package/bin/cli.test.js +92 -0
  4. package/bin/lib/cli-startup.js +49 -0
  5. package/bin/lib/commands-startup.js +41 -6
  6. package/dist/assets/{CodeMirrorCommentWidgets-C53Q7pbX.js → CodeMirrorCommentWidgets-DFg2wnpo.js} +1 -1
  7. package/dist/assets/DiffView-3Dn4QHbu.js +4 -0
  8. package/dist/assets/DirtyBranchSwitchDialog-CwOerqVi.js +1 -0
  9. package/dist/assets/FilesView-BdTOpew9.js +17 -0
  10. package/dist/assets/GitView-Dqf9vHJh.js +6 -0
  11. package/dist/assets/{JsonTreeView-Bl0lp9Rs.js → JsonTreeView-BKdsjg5B.js} +1 -1
  12. package/dist/assets/LinearIssuesView-CmbeL7I6.js +1 -0
  13. package/dist/assets/MarkdownImageGallery-_5gEVr0X.js +1 -0
  14. package/dist/assets/MarkdownRendererImpl-wVZlx4dd.js +12 -0
  15. package/dist/assets/OnboardingScreen-DdP4_nuc.js +1 -0
  16. package/dist/assets/{PatchDiff-D6NXAfJk.js → PatchDiff-am3SAss2.js} +1 -1
  17. package/dist/assets/PierreDiffViewer-DCsydlKs.js +161 -0
  18. package/dist/assets/PlanView-BoJxdRZe.js +5 -0
  19. package/dist/assets/SettingsView-CcM8pF5O.js +28 -0
  20. package/dist/assets/{SettingsWindow-BiWDBRvb.js → SettingsWindow-DVTdDSol.js} +1 -1
  21. package/dist/assets/SymbolsNerdFontMono-Regular-aK5vsLov.woff2 +0 -0
  22. package/dist/assets/{ToolOutputDialog-BQexMQIm.js → ToolOutputDialog-BgfTbDZ4.js} +6 -6
  23. package/dist/assets/ToolPartDiffPreview-C7WLk5i9.js +8 -0
  24. package/dist/assets/{VirtualizedFileDiff-Dpz2ra8h.js → VirtualizedFileDiff-DcdgkH-x.js} +1 -1
  25. package/dist/assets/WalkthroughView-BEVUDxuw.js +4 -0
  26. package/dist/assets/{computeVirtualFileMetrics-DtyozJdt.js → computeVirtualFileMetrics-C8byMnRh.js} +2 -2
  27. package/dist/assets/contextPanelEmbeddedChat-CirXEPJq.js +1 -0
  28. package/dist/assets/de-C8jV7J9y.js +10 -0
  29. package/dist/assets/es-BI6J1M5R.js +16 -0
  30. package/dist/assets/fr-D6oDFt7E.js +14 -0
  31. package/dist/assets/ghostty-vt-DdA0Zryv.wasm +0 -0
  32. package/dist/assets/{index-DmhujXbT.js → index-BB7ryz8y.js} +1 -1
  33. package/dist/assets/index-BqhiCmYS.js +39 -0
  34. package/dist/assets/index-FqSOBz0J.css +1 -0
  35. package/dist/assets/{isDiffPlainText-XyulMHxx.js → isDiffPlainText-45ujMPGb.js} +1 -1
  36. package/dist/assets/{isFilePlainText-B19Ddps1.js → isFilePlainText-Cc4KFSYi.js} +1 -1
  37. package/dist/assets/ja-C_4i5EQu.js +16 -0
  38. package/dist/assets/ko-BFbOTpbq.js +16 -0
  39. package/dist/assets/{main-B2a_JfKZ.js → main-C9ETLKFc.js} +2 -2
  40. package/dist/assets/main-CJmb2dF8.js +930 -0
  41. package/dist/assets/markdownCore-DVuZpnli.js +5 -0
  42. package/dist/assets/{miniChat-B3CSU_Ps.js → miniChat-B4Ytoq2n.js} +2 -2
  43. package/dist/assets/{mobile-QNcFIVCW.js → mobile-BUT6-lo1.js} +2 -2
  44. package/dist/assets/patchFileDiff-8j1apICU.js +13 -0
  45. package/dist/assets/pl-COFvT56l.js +16 -0
  46. package/dist/assets/pt-BR-DtVOO2Lj.js +16 -0
  47. package/dist/assets/renderElectronMiniChatApp-B6vUG-vF.js +2 -0
  48. package/dist/assets/renderMobileApp-BNgHraq6.js +25 -0
  49. package/dist/assets/runtimeEndpointReset-B8LYpWvr.js +17 -0
  50. package/dist/assets/scheduledTasksApi-GC4epIYA.js +1 -0
  51. package/dist/assets/{shikiHighlight-DE-OwqGw.js → shikiHighlight-BJISeZsQ.js} +1 -1
  52. package/dist/assets/surface-BvRzS58U.js +1 -0
  53. package/dist/assets/tr-B8Q0Go2l.js +7 -0
  54. package/dist/assets/uk-Dk4a-Fn-.js +16 -0
  55. package/dist/assets/{useAppFontEffects-COAeIX-D.js → useAppFontEffects-DpFNefUA.js} +153 -179
  56. package/dist/assets/useGitComparison-Dfxaqd0s.js +1 -0
  57. package/dist/assets/useInlineCommentController-CscsE4t-.js +2 -0
  58. package/dist/assets/useRootScrollLock-BaCvuRlT.js +1 -0
  59. package/dist/assets/{vendor-base-ui-BeMD8X_w.js → vendor-base-ui-DdV8vhHn.js} +1 -1
  60. package/dist/assets/{vendor-codemirror-language-data-BG3v1Ghq.js → vendor-codemirror-language-data-DzlSIu_W.js} +2 -2
  61. package/dist/assets/vendor-legendapp-list-BuBPBC3R.js +1 -0
  62. package/dist/assets/vendor-zod-BPAANqvH.js +39 -0
  63. package/dist/assets/{web-components-BNMwa0qm.js → web-components-CdGfR2XJ.js} +1 -1
  64. package/dist/assets/zh-CN-B_BXxG1Q.js +16 -0
  65. package/dist/assets/zh-TW-DLrl1SPZ.js +16 -0
  66. package/dist/index.html +5 -43
  67. package/dist/mini-chat.html +4 -4
  68. package/dist/mobile.html +4 -4
  69. package/package.json +8 -5
  70. package/server/index.d.ts +15 -0
  71. package/server/index.js +82 -13
  72. package/server/lib/agent-tool/runtime.js +2 -19
  73. package/server/lib/changelog/update-notes.js +82 -0
  74. package/server/lib/changelog/update-notes.test.js +59 -0
  75. package/server/lib/data-dir-migration.js +40 -0
  76. package/server/lib/data-dir-migration.test.js +59 -0
  77. package/server/lib/dev-tunnel/DOCUMENTATION.md +13 -4
  78. package/server/lib/dev-tunnel/runtime.js +4 -4
  79. package/server/lib/dev-tunnel/tunnel.test.js +20 -0
  80. package/server/lib/event-stream/runtime.test.js +12 -12
  81. package/server/lib/fs/DOCUMENTATION.md +5 -0
  82. package/server/lib/fs/routes.js +63 -21
  83. package/server/lib/fs/routes.test.js +187 -1
  84. package/server/lib/git/DOCUMENTATION.md +32 -6
  85. package/server/lib/git/routes.js +40 -4
  86. package/server/lib/git/service.js +291 -215
  87. package/server/lib/git/service.test.js +593 -25
  88. package/server/lib/inherited-env.js +16 -9
  89. package/server/lib/inherited-env.test.js +8 -9
  90. package/server/lib/mcp-reconnect/DOCUMENTATION.md +55 -0
  91. package/server/lib/mcp-reconnect/runtime.js +122 -0
  92. package/server/lib/mcp-reconnect/runtime.test.js +140 -0
  93. package/server/lib/message-queue/DOCUMENTATION.md +161 -0
  94. package/server/lib/message-queue/runtime.js +870 -0
  95. package/server/lib/message-queue/runtime.test.js +482 -0
  96. package/server/lib/network-defaults.js +23 -0
  97. package/server/lib/network-defaults.test.js +52 -0
  98. package/server/lib/openchamber-sessions/routes.js +102 -0
  99. package/server/lib/openchamber-sessions/routes.test.js +88 -0
  100. package/server/lib/opencode/DOCUMENTATION.md +30 -6
  101. package/server/lib/opencode/bootstrap-runtime.js +2 -0
  102. package/server/lib/opencode/config-paths.test.js +116 -0
  103. package/server/lib/opencode/core-routes.js +1 -0
  104. package/server/lib/opencode/feature-routes-runtime.js +7 -0
  105. package/server/lib/opencode/input-history-scope.js +14 -0
  106. package/server/lib/opencode/managed-plugin-config.js +35 -0
  107. package/server/lib/opencode/openchamber-routes.js +64 -4
  108. package/server/lib/opencode/openchamber-routes.test.js +130 -3
  109. package/server/lib/opencode/plugins.js +2 -2
  110. package/server/lib/opencode/providers.js +44 -2
  111. package/server/lib/opencode/providers.test.js +131 -4
  112. package/server/lib/opencode/routes.js +9 -7
  113. package/server/lib/opencode/server-startup-runtime.js +1 -1
  114. package/server/lib/opencode/settings-files.js +214 -0
  115. package/server/lib/opencode/settings-helpers.js +113 -41
  116. package/server/lib/opencode/settings-helpers.test.js +342 -0
  117. package/server/lib/opencode/settings-registry.json +747 -0
  118. package/server/lib/opencode/settings-runtime.js +141 -20
  119. package/server/lib/opencode/settings-runtime.test.js +191 -4
  120. package/server/lib/opencode/shared.js +4 -1
  121. package/server/lib/opencode/shutdown-runtime.js +2 -0
  122. package/server/lib/opencode/skill-routes.js +2 -1
  123. package/server/lib/opencode/snippets.js +1 -2
  124. package/server/lib/package-manager.js +4 -29
  125. package/server/lib/package-manager.test.js +2 -2
  126. package/server/lib/project-context/DOCUMENTATION.md +25 -1
  127. package/server/lib/project-context/routes.http.test.js +18 -0
  128. package/server/lib/project-context/routes.js +15 -0
  129. package/server/lib/project-context/runtime.js +248 -23
  130. package/server/lib/project-context/runtime.test.js +106 -3
  131. package/server/lib/projects/DOCUMENTATION.md +76 -0
  132. package/server/lib/projects/project-config.js +142 -9
  133. package/server/lib/projects/project-id.js +17 -0
  134. package/server/lib/projects/project-setup.js +409 -0
  135. package/server/lib/projects/project-setup.test.js +557 -0
  136. package/server/lib/projects/routes.js +59 -0
  137. package/server/lib/quota/DOCUMENTATION.md +40 -2
  138. package/server/lib/quota/credentials/providers.js +4 -0
  139. package/server/lib/quota/credentials/store.js +1 -1
  140. package/server/lib/quota/credentials/store.test.js +4 -4
  141. package/server/lib/quota/index.js +1 -0
  142. package/server/lib/quota/providers/cline-pass.js +125 -0
  143. package/server/lib/quota/providers/cline-pass.test.js +103 -0
  144. package/server/lib/quota/providers/exe-dev.js +75 -0
  145. package/server/lib/quota/providers/exe-dev.test.js +47 -0
  146. package/server/lib/quota/providers/hyper.js +118 -0
  147. package/server/lib/quota/providers/hyper.test.js +126 -0
  148. package/server/lib/quota/providers/index.js +22 -0
  149. package/server/lib/quota/providers/neuralwatt.js +3 -6
  150. package/server/lib/quota/providers/neuralwatt.test.js +7 -7
  151. package/server/lib/quota/providers/ollama-cloud.js +31 -0
  152. package/server/lib/quota/providers/ollama-cloud.test.js +33 -1
  153. package/server/lib/quota/providers/opencode-go.js +2 -1
  154. package/server/lib/quota/providers/opencode-go.test.js +31 -2
  155. package/server/lib/quota/providers/openrouter.js +119 -24
  156. package/server/lib/quota/providers/openrouter.test.js +330 -0
  157. package/server/lib/quota/routes.js +2 -0
  158. package/server/lib/quota/utils/auth.js +1 -1
  159. package/server/lib/relay/DOCUMENTATION.md +56 -1
  160. package/server/lib/relay/cross-compat.test.js +26 -0
  161. package/server/lib/relay/downstream-scheduler.js +154 -0
  162. package/server/lib/relay/downstream-scheduler.test.js +162 -0
  163. package/server/lib/relay/e2ee.js +10 -8
  164. package/server/lib/relay/flow-control.test.js +177 -0
  165. package/server/lib/relay/host-client.js +37 -8
  166. package/server/lib/relay/host-client.test.js +9 -1
  167. package/server/lib/relay/tunnel-codec.js +9 -0
  168. package/server/lib/relay/tunnel-host.js +69 -18
  169. package/server/lib/relay/tunnel-host.test.js +9 -1
  170. package/server/lib/security/request-security.js +21 -6
  171. package/server/lib/security/request-security.test.js +42 -8
  172. package/server/lib/session-assist/runtime.js +7 -10
  173. package/server/lib/session-goal/DOCUMENTATION.md +20 -7
  174. package/server/lib/session-goal/create.js +3 -1
  175. package/server/lib/session-goal/runtime.js +81 -20
  176. package/server/lib/session-goal/runtime.test.js +426 -0
  177. package/server/lib/skills-catalog/DOCUMENTATION.md +1 -1
  178. package/server/lib/skills-catalog/install.js +3 -2
  179. package/server/lib/small-model/DOCUMENTATION.md +7 -2
  180. package/server/lib/small-model/call.js +17 -6
  181. package/server/lib/small-model/call.test.js +52 -0
  182. package/server/lib/small-model/index.js +7 -11
  183. package/server/lib/small-model/routes.js +2 -1
  184. package/server/lib/small-model/runtime-providers.js +15 -7
  185. package/server/lib/small-model/runtime-providers.test.js +7 -1
  186. package/server/lib/system-prompt/runtime.js +2 -21
  187. package/server/lib/terminal/DOCUMENTATION.md +14 -10
  188. package/server/lib/terminal/runtime.js +190 -31
  189. package/server/lib/terminal/runtime.test.js +571 -37
  190. package/server/lib/terminal/shells.js +17 -0
  191. package/server/lib/terminal/shells.test.js +32 -1
  192. package/server/lib/ui-auth/ui-auth.js +4 -1
  193. package/server/lib/ui-auth/ui-auth.test.js +16 -0
  194. package/server/lib/walkthrough/DOCUMENTATION.md +33 -14
  195. package/server/lib/walkthrough/index.js +1 -0
  196. package/server/lib/walkthrough/model-settings.js +8 -12
  197. package/server/lib/walkthrough/prompt.js +3 -1
  198. package/server/lib/walkthrough/routes.test.js +26 -5
  199. package/server/lib/walkthrough/sources.js +23 -2
  200. package/dist/assets/CommitSection-X-f2RgYr.js +0 -1
  201. package/dist/assets/DiffView-Dh1ey2x9.js +0 -4
  202. package/dist/assets/FilesView-3ZYP72ki.js +0 -17
  203. package/dist/assets/GitView-CMuN7z2Z.js +0 -6
  204. package/dist/assets/LinearIssuesView-CQiUDU8b.js +0 -1
  205. package/dist/assets/MarkdownImageGallery-bA0PS-tn.js +0 -1
  206. package/dist/assets/MarkdownRendererImpl-BGQi2PLq.js +0 -12
  207. package/dist/assets/OnboardingScreen-D3kYJIb6.js +0 -1
  208. package/dist/assets/PierreDiffViewer-Ct2A13NO.js +0 -157
  209. package/dist/assets/PlanView-6AUjzNQn.js +0 -5
  210. package/dist/assets/SettingsView-CHI8fFaK.js +0 -28
  211. package/dist/assets/ToolPartDiffPreview-DPAATNn6.js +0 -8
  212. package/dist/assets/WalkthroughView-AX-u2pFg.js +0 -4
  213. package/dist/assets/contextPanelEmbeddedChat-C9EcRSDF.js +0 -1
  214. package/dist/assets/de-DqVn5vme.js +0 -9
  215. package/dist/assets/es-hj43Z53i.js +0 -15
  216. package/dist/assets/fr-BmkZt2mF.js +0 -13
  217. package/dist/assets/index-L_ncV-6H.css +0 -1
  218. package/dist/assets/index-qES19dlJ.js +0 -7
  219. package/dist/assets/ja-DioMIEGF.js +0 -15
  220. package/dist/assets/ko-BqPgaQJb.js +0 -15
  221. package/dist/assets/main-CYgSu6dx.js +0 -928
  222. package/dist/assets/markdownCore-CWRCqSH2.js +0 -3
  223. package/dist/assets/patchFileDiff-BpMQZnYu.js +0 -10
  224. package/dist/assets/pl-slULBF4R.js +0 -15
  225. package/dist/assets/pt-BR-BqH8fuCH.js +0 -15
  226. package/dist/assets/renderElectronMiniChatApp-D77bmLqo.js +0 -2
  227. package/dist/assets/renderMobileApp-BQ3Kdm-d.js +0 -25
  228. package/dist/assets/runtimeEndpointReset-CRGn1gVj.js +0 -16
  229. package/dist/assets/scheduledTasksApi-BSRCtP6T.js +0 -1
  230. package/dist/assets/tr-CjEbw1Aw.js +0 -6
  231. package/dist/assets/uk-BkkfxbOs.js +0 -15
  232. package/dist/assets/useInlineCommentController-JsBboQwt.js +0 -2
  233. package/dist/assets/useRootScrollLock-C0N9mCwz.js +0 -1
  234. package/dist/assets/vendor-ghostty-web-CrxinGGH.js +0 -13
  235. package/dist/assets/vendor-legendapp-list-gg7YLRW7.js +0 -1
  236. package/dist/assets/vendor-zod-BLfBSgBh.js +0 -39
  237. package/dist/assets/zh-CN-C6FMdVgv.js +0 -15
  238. package/dist/assets/zh-TW-CdcMjDxv.js +0 -15
@@ -29,11 +29,13 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
29
29
  readSettingsFromDiskMigrated,
30
30
  fetchFreeZenModels,
31
31
  getCachedZenModels,
32
+ desktopUpdater,
32
33
  } = dependencies;
33
34
 
35
+ let desktopRestartError = null;
36
+
34
37
  app.get('/api/openchamber/update-check', async (req, res) => {
35
38
  try {
36
- const { checkForUpdates } = await import('../package-manager.js');
37
39
  const parseString = (value) => (typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined);
38
40
  const parseReportUsage = (value) => {
39
41
  if (typeof value !== 'string') return true;
@@ -49,8 +51,7 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
49
51
  return 'desktop';
50
52
  };
51
53
  const userAgent = typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'] : '';
52
-
53
- const updateInfo = await checkForUpdates({
54
+ const updateRequest = {
54
55
  appType: parseString(req.query.appType),
55
56
  deviceClass: parseString(req.query.deviceClass) || inferDeviceClass(userAgent),
56
57
  platform: parseString(req.query.platform),
@@ -59,7 +60,31 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
59
60
  currentVersion: parseString(req.query.currentVersion),
60
61
  installId: parseString(req.query.installId),
61
62
  reportUsage: parseReportUsage(parseString(req.query.reportUsage)),
62
- });
63
+ };
64
+ let updateInfo;
65
+ if (process.env.OPENCHAMBER_RUNTIME === 'desktop' && updateRequest.appType === 'web') {
66
+ if (desktopRestartError && req.query.updateStatus === 'true') {
67
+ return res.status(503).json({
68
+ code: 'DESKTOP_UPDATE_RESTART_FAILED',
69
+ error: desktopRestartError,
70
+ });
71
+ }
72
+ if (typeof desktopUpdater?.check !== 'function') {
73
+ return res.status(503).json({
74
+ available: false,
75
+ code: 'DESKTOP_UPDATER_UNAVAILABLE',
76
+ error: 'The desktop updater is not available.',
77
+ });
78
+ }
79
+ updateInfo = {
80
+ ...await desktopUpdater.check(),
81
+ packageManager: 'electron',
82
+ updateOwner: 'electron-updater',
83
+ };
84
+ } else {
85
+ const { checkForUpdates } = await import('../package-manager.js');
86
+ updateInfo = await checkForUpdates(updateRequest);
87
+ }
63
88
  res.json(updateInfo);
64
89
  } catch (error) {
65
90
  console.error('Failed to check for updates:', error);
@@ -72,6 +97,41 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
72
97
 
73
98
  app.post('/api/openchamber/update-install', async (_req, res) => {
74
99
  try {
100
+ if (process.env.OPENCHAMBER_RUNTIME === 'desktop') {
101
+ if (typeof desktopUpdater?.install !== 'function' || typeof desktopUpdater?.restart !== 'function') {
102
+ return res.status(503).json({
103
+ code: 'DESKTOP_UPDATER_UNAVAILABLE',
104
+ error: 'The desktop updater is not available.',
105
+ });
106
+ }
107
+
108
+ desktopRestartError = null;
109
+ const updateInfo = await desktopUpdater.install();
110
+ if (!updateInfo?.available) {
111
+ return res.status(400).json({ error: 'No update available' });
112
+ }
113
+
114
+ res.json({
115
+ success: true,
116
+ message: 'Desktop update downloaded, host will restart shortly',
117
+ version: updateInfo.version,
118
+ packageManager: 'electron',
119
+ updateOwner: 'electron-updater',
120
+ autoRestart: true,
121
+ restartManager: 'electron-updater',
122
+ });
123
+
124
+ setImmediate(() => {
125
+ Promise.resolve()
126
+ .then(() => desktopUpdater.restart())
127
+ .catch((error) => {
128
+ desktopRestartError = error instanceof Error ? error.message : 'Failed to restart after desktop update';
129
+ console.error('Failed to restart after desktop update:', error);
130
+ });
131
+ });
132
+ return;
133
+ }
134
+
75
135
  const { spawn: spawnChild, spawnSync } = await import('child_process');
76
136
  const {
77
137
  checkForUpdates,
@@ -18,7 +18,7 @@ const childProcess = await import('child_process');
18
18
  const packageManager = await import('../package-manager.js');
19
19
  const { registerOpenChamberRoutes } = await import('./openchamber-routes.js');
20
20
 
21
- const createApp = ({ environment = {}, storedOptions = {} } = {}) => {
21
+ const createApp = ({ environment = {}, storedOptions = {}, desktopUpdater } = {}) => {
22
22
  const app = express();
23
23
  const dependencies = {
24
24
  fs: {
@@ -47,6 +47,7 @@ const createApp = ({ environment = {}, storedOptions = {} } = {}) => {
47
47
  readSettingsFromDiskMigrated: vi.fn(),
48
48
  fetchFreeZenModels: vi.fn(),
49
49
  getCachedZenModels: vi.fn(),
50
+ desktopUpdater,
50
51
  };
51
52
 
52
53
  registerOpenChamberRoutes(app, dependencies);
@@ -62,7 +63,7 @@ beforeEach(() => {
62
63
  packageManager: 'npm',
63
64
  });
64
65
  packageManager.getUpdateCommand.mockReturnValue(
65
- 'npm install -g @vantran-se/openchamber-web@latest --registry=https://npm.pkg.github.com',
66
+ 'npm install -g @vantran-se/openchamber-web@latest --registry=https://registry.npmjs.org',
66
67
  );
67
68
  });
68
69
 
@@ -71,6 +72,132 @@ afterEach(() => {
71
72
  vi.clearAllMocks();
72
73
  });
73
74
 
75
+ describe('OpenChamber desktop host update route', () => {
76
+ it('reports a restart rejection until the user retries installation', async () => {
77
+ const desktopUpdater = {
78
+ check: vi.fn(async () => ({ available: true, currentVersion: '1.17.0', version: '1.17.1' })),
79
+ install: vi.fn(async () => ({ available: true, version: '1.17.1' })),
80
+ restart: vi.fn().mockRejectedValueOnce(new Error('Signature rejected')).mockResolvedValue(undefined),
81
+ };
82
+ const { app } = createApp({ environment: { OPENCHAMBER_RUNTIME: 'desktop' }, desktopUpdater });
83
+ const logError = vi.spyOn(console, 'error').mockImplementation(() => {});
84
+
85
+ await request(app).post('/api/openchamber/update-install').expect(200);
86
+ await new Promise(resolve => setImmediate(resolve));
87
+ await request(app).get('/api/openchamber/update-check?appType=web&reportUsage=false&updateStatus=true').expect(503, {
88
+ code: 'DESKTOP_UPDATE_RESTART_FAILED', error: 'Signature rejected',
89
+ });
90
+ expect(desktopUpdater.check).not.toHaveBeenCalled();
91
+ expect(logError).toHaveBeenCalledOnce();
92
+ // Availability remains reachable after a browser reload, so users can retry.
93
+ await request(app).get('/api/openchamber/update-check?appType=web&reportUsage=false').expect(200);
94
+
95
+ await request(app).post('/api/openchamber/update-install').expect(200);
96
+ await new Promise(resolve => setImmediate(resolve));
97
+ const response = await request(app).get('/api/openchamber/update-check?appType=web&reportUsage=false').expect(200);
98
+ expect(response.body.currentVersion).toBe('1.17.0');
99
+ expect(response.body.updateOwner).toBe('electron-updater');
100
+ expect(packageManager.checkForUpdates).not.toHaveBeenCalled();
101
+ });
102
+
103
+ it('rejects native checks without a bridge and preserves explicit non-web checks', async () => {
104
+ const { app } = createApp({ environment: { OPENCHAMBER_RUNTIME: 'desktop' } });
105
+ await request(app).get('/api/openchamber/update-check?appType=web').expect(503, {
106
+ available: false, code: 'DESKTOP_UPDATER_UNAVAILABLE', error: 'The desktop updater is not available.',
107
+ });
108
+ expect(packageManager.checkForUpdates).not.toHaveBeenCalled();
109
+ await request(app).get('/api/openchamber/update-check?appType=desktop-electron').expect(200);
110
+ expect(packageManager.checkForUpdates).toHaveBeenCalledOnce();
111
+ });
112
+
113
+ it('uses electron-updater to check for Web client updates', async () => {
114
+ const desktopUpdater = {
115
+ check: vi.fn(async () => ({
116
+ available: true,
117
+ currentVersion: '1.17.0',
118
+ version: '1.17.1',
119
+ })),
120
+ install: vi.fn(),
121
+ restart: vi.fn(),
122
+ };
123
+ const { app } = createApp({
124
+ environment: {
125
+ OPENCHAMBER_RUNTIME: 'desktop',
126
+ },
127
+ desktopUpdater,
128
+ });
129
+
130
+ await request(app)
131
+ .get('/api/openchamber/update-check?appType=web&reportUsage=false')
132
+ .expect(200, {
133
+ available: true,
134
+ currentVersion: '1.17.0',
135
+ version: '1.17.1',
136
+ packageManager: 'electron',
137
+ updateOwner: 'electron-updater',
138
+ });
139
+
140
+ expect(desktopUpdater.check).toHaveBeenCalledOnce();
141
+ expect(packageManager.checkForUpdates).not.toHaveBeenCalled();
142
+ });
143
+
144
+ it('installs through electron-updater and restarts after responding', async () => {
145
+ const desktopUpdater = {
146
+ check: vi.fn(),
147
+ install: vi.fn(async () => ({
148
+ available: true,
149
+ version: '1.17.1',
150
+ })),
151
+ restart: vi.fn(),
152
+ };
153
+ const { app } = createApp({
154
+ environment: {
155
+ OPENCHAMBER_RUNTIME: 'desktop',
156
+ },
157
+ desktopUpdater,
158
+ });
159
+
160
+ await request(app)
161
+ .post('/api/openchamber/update-install')
162
+ .expect(200, {
163
+ success: true,
164
+ message: 'Desktop update downloaded, host will restart shortly',
165
+ version: '1.17.1',
166
+ packageManager: 'electron',
167
+ updateOwner: 'electron-updater',
168
+ autoRestart: true,
169
+ restartManager: 'electron-updater',
170
+ });
171
+ await new Promise((resolve) => setImmediate(resolve));
172
+
173
+ expect(desktopUpdater.install).toHaveBeenCalledOnce();
174
+ expect(desktopUpdater.restart).toHaveBeenCalledOnce();
175
+ expect(packageManager.checkForUpdates).not.toHaveBeenCalled();
176
+ expect(packageManager.detectPackageManagerDetails).not.toHaveBeenCalled();
177
+ expect(packageManager.getUpdateCommand).not.toHaveBeenCalled();
178
+ expect(childProcess.spawn).not.toHaveBeenCalled();
179
+ expect(childProcess.spawnSync).not.toHaveBeenCalled();
180
+ });
181
+
182
+ it('fails safely when the Electron updater bridge is unavailable', async () => {
183
+ const { app } = createApp({
184
+ environment: {
185
+ OPENCHAMBER_RUNTIME: 'desktop',
186
+ },
187
+ });
188
+
189
+ await request(app)
190
+ .post('/api/openchamber/update-install')
191
+ .expect(503, {
192
+ code: 'DESKTOP_UPDATER_UNAVAILABLE',
193
+ error: 'The desktop updater is not available.',
194
+ });
195
+
196
+ expect(packageManager.checkForUpdates).not.toHaveBeenCalled();
197
+ expect(childProcess.spawn).not.toHaveBeenCalled();
198
+ });
199
+ });
200
+
74
201
  describe('OpenChamber foreground update route', () => {
75
202
  it('rejects a foreground update when the server is not owned by systemd', async () => {
76
203
  const { app } = createApp();
@@ -133,7 +260,7 @@ describe('OpenChamber foreground update route', () => {
133
260
  '--setenv=PATH=/home/syu/.npm-global/bin:/usr/bin:/bin',
134
261
  '/bin/sh',
135
262
  '-c',
136
- "set -eu\nnpm install -g @vantran-se/openchamber-web@latest --registry=https://npm.pkg.github.com\nsystemctl --user restart 'openchamber@wsl.service'",
263
+ "set -eu\nnpm install -g @vantran-se/openchamber-web@latest --registry=https://registry.npmjs.org\nsystemctl --user restart 'openchamber@wsl.service'",
137
264
  ], {
138
265
  encoding: 'utf8',
139
266
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -1,8 +1,8 @@
1
1
  import fs from 'fs';
2
- import os from 'os';
3
2
  import path from 'path';
4
3
  import {
5
4
  AGENT_SCOPE,
5
+ OPENCODE_CONFIG_DIR,
6
6
  readConfigFile,
7
7
  readConfigLayer,
8
8
  writeConfig,
@@ -71,7 +71,7 @@ function getActiveOpencodeConfigDir() {
71
71
  if (customConfigPath) {
72
72
  return path.dirname(path.resolve(customConfigPath));
73
73
  }
74
- return path.join(os.homedir(), '.config', 'opencode');
74
+ return OPENCODE_CONFIG_DIR;
75
75
  }
76
76
 
77
77
  function getActiveUserConfigPaths() {
@@ -152,6 +152,37 @@ function validateCustomProviderConfig(providerId, config, options = {}) {
152
152
  return { ok: true, value: { providerId, config: normalized } };
153
153
  }
154
154
 
155
+ function mergeCustomProviderConfig(existingValue, normalizedConfig) {
156
+ const existing = isPlainObject(existingValue) ? existingValue : {};
157
+ const existingOptions = isPlainObject(existing.options) ? existing.options : {};
158
+ const normalizedOptions = isPlainObject(normalizedConfig.options) ? normalizedConfig.options : {};
159
+ const mergedOptions = { ...existingOptions, ...normalizedOptions };
160
+ if (!Object.prototype.hasOwnProperty.call(normalizedOptions, 'headers')) {
161
+ delete mergedOptions.headers;
162
+ }
163
+
164
+ const existingModels = isPlainObject(existing.models) ? existing.models : {};
165
+ const normalizedModels = isPlainObject(normalizedConfig.models) ? normalizedConfig.models : {};
166
+ const mergedModels = Object.fromEntries(
167
+ Object.entries(normalizedModels).map(([modelId, normalizedModel]) => {
168
+ const existingModel = isPlainObject(existingModels[modelId]) ? existingModels[modelId] : {};
169
+ const nextModel = isPlainObject(normalizedModel) ? normalizedModel : {};
170
+ return [modelId, { ...existingModel, ...nextModel }];
171
+ }),
172
+ );
173
+
174
+ const merged = {
175
+ ...existing,
176
+ ...normalizedConfig,
177
+ options: mergedOptions,
178
+ models: mergedModels,
179
+ };
180
+ if (!Object.prototype.hasOwnProperty.call(normalizedConfig, 'env')) {
181
+ delete merged.env;
182
+ }
183
+ return merged;
184
+ }
185
+
155
186
  /**
156
187
  * Persist (create or update) a custom provider block in OpenCode user/project/custom config.
157
188
  * Does not write secrets — API keys remain in auth.json via the OpenCode auth API.
@@ -183,8 +214,19 @@ function upsertProviderConfig(providerId, config, workingDirectory, scope = 'use
183
214
 
184
215
  const targetConfig = getConfigForPath(layers, targetPath);
185
216
  const providerConfig = isPlainObject(targetConfig.provider) ? { ...targetConfig.provider } : {};
186
- providerConfig[validated.value.providerId] = validated.value.config;
217
+ const providersAlias = isPlainObject(targetConfig.providers) ? { ...targetConfig.providers } : {};
218
+ const existingProvider = providerConfig[validated.value.providerId] ?? providersAlias[validated.value.providerId];
219
+ const mergedConfig = mergeCustomProviderConfig(existingProvider, validated.value.config);
220
+ providerConfig[validated.value.providerId] = mergedConfig;
187
221
  targetConfig.provider = providerConfig;
222
+ if (Object.prototype.hasOwnProperty.call(providersAlias, validated.value.providerId)) {
223
+ delete providersAlias[validated.value.providerId];
224
+ if (Object.keys(providersAlias).length === 0) {
225
+ delete targetConfig.providers;
226
+ } else {
227
+ targetConfig.providers = providersAlias;
228
+ }
229
+ }
188
230
 
189
231
  if (Array.isArray(targetConfig.disabled_providers)) {
190
232
  targetConfig.disabled_providers = targetConfig.disabled_providers.filter(
@@ -198,7 +240,7 @@ function upsertProviderConfig(providerId, config, workingDirectory, scope = 'use
198
240
  return {
199
241
  providerId: validated.value.providerId,
200
242
  path: writePath,
201
- config: validated.value.config,
243
+ config: mergedConfig,
202
244
  };
203
245
  }
204
246
 
@@ -9,6 +9,7 @@ import {
9
9
  getProviderSources,
10
10
  removeProviderConfig,
11
11
  } from './providers.js';
12
+ import { OPENCODE_CONFIG_DIR } from './shared.js';
12
13
 
13
14
  let projectDir;
14
15
 
@@ -162,6 +163,132 @@ describe('custom provider config persistence', () => {
162
163
  expect(written.disabled_providers).toEqual(['other']);
163
164
  });
164
165
 
166
+ test('upsertProviderConfig preserves unmanaged provider and model metadata', () => {
167
+ const configPath = path.join(projectDir, 'opencode.json');
168
+ writeJson(configPath, {
169
+ provider: {
170
+ 'campus-llm': {
171
+ npm: '@ai-sdk/openai-compatible',
172
+ name: 'Old',
173
+ customProviderField: { owner: 'user' },
174
+ env: ['OLD_KEY'],
175
+ options: {
176
+ baseURL: 'https://old.example.edu/v1',
177
+ headers: { 'X-Old': '1' },
178
+ timeout: 45_000,
179
+ },
180
+ models: {
181
+ retained: {
182
+ name: 'Old retained name',
183
+ reasoning: true,
184
+ attachment: true,
185
+ tool_call: true,
186
+ modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
187
+ limit: { context: 1_050_000, input: 922_000, output: 128_000 },
188
+ options: { instructions: 'Keep this instruction' },
189
+ variants: {
190
+ low: { reasoningEffort: 'low' },
191
+ high: { reasoningEffort: 'high' },
192
+ },
193
+ customModelField: { source: 'manual' },
194
+ },
195
+ removed: {
196
+ name: 'Remove me',
197
+ reasoning: true,
198
+ },
199
+ },
200
+ },
201
+ },
202
+ });
203
+
204
+ upsertProviderConfig('campus-llm', {
205
+ name: 'Campus LLM',
206
+ options: { baseURL: 'https://new.example.edu/v1' },
207
+ models: {
208
+ retained: { name: 'Retained model' },
209
+ added: { name: 'Added model' },
210
+ },
211
+ }, projectDir, 'project', { hasStoredAuth: true });
212
+
213
+ const written = readJson(configPath).provider['campus-llm'];
214
+ expect(written).toEqual({
215
+ npm: '@ai-sdk/openai-compatible',
216
+ name: 'Campus LLM',
217
+ customProviderField: { owner: 'user' },
218
+ options: {
219
+ baseURL: 'https://new.example.edu/v1',
220
+ timeout: 45_000,
221
+ },
222
+ models: {
223
+ retained: {
224
+ name: 'Retained model',
225
+ reasoning: true,
226
+ attachment: true,
227
+ tool_call: true,
228
+ modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
229
+ limit: { context: 1_050_000, input: 922_000, output: 128_000 },
230
+ options: { instructions: 'Keep this instruction' },
231
+ variants: {
232
+ low: { reasoningEffort: 'low' },
233
+ high: { reasoningEffort: 'high' },
234
+ },
235
+ customModelField: { source: 'manual' },
236
+ },
237
+ added: { name: 'Added model' },
238
+ },
239
+ });
240
+ });
241
+
242
+ test('upsertProviderConfig preserves metadata while migrating the legacy providers alias', () => {
243
+ const configPath = path.join(projectDir, 'opencode.json');
244
+ writeJson(configPath, {
245
+ providers: {
246
+ legacy: {
247
+ name: 'Legacy provider',
248
+ options: { baseURL: 'https://old.example.com/v1', timeout: 30_000 },
249
+ models: { model: { name: 'Old model', reasoning: true } },
250
+ },
251
+ },
252
+ });
253
+
254
+ upsertProviderConfig('legacy', {
255
+ name: 'Updated provider',
256
+ options: { baseURL: 'https://new.example.com/v1' },
257
+ models: { model: { name: 'Updated model' } },
258
+ }, projectDir, 'project', { hasStoredAuth: true });
259
+
260
+ const written = readJson(configPath);
261
+ expect(written.providers).toBeUndefined();
262
+ expect(written.provider.legacy).toEqual({
263
+ npm: '@ai-sdk/openai-compatible',
264
+ name: 'Updated provider',
265
+ options: { baseURL: 'https://new.example.com/v1', timeout: 30_000 },
266
+ models: { model: { name: 'Updated model', reasoning: true } },
267
+ });
268
+ });
269
+
270
+ test('migrating one legacy providers entry keeps the other legacy entries', () => {
271
+ const configPath = path.join(projectDir, 'opencode.json');
272
+ writeJson(configPath, {
273
+ providers: {
274
+ legacy: { name: 'Legacy provider', options: { baseURL: 'https://old.example.com/v1' }, models: { model: { name: 'Old model' } } },
275
+ untouched: { name: 'Untouched', options: { baseURL: 'https://other.example.com/v1' }, models: { model: { name: 'Other model' } } },
276
+ },
277
+ });
278
+
279
+ upsertProviderConfig('legacy', {
280
+ name: 'Updated provider',
281
+ options: { baseURL: 'https://new.example.com/v1' },
282
+ models: { model: { name: 'Updated model' } },
283
+ }, projectDir, 'project', { hasStoredAuth: true });
284
+
285
+ const written = readJson(configPath);
286
+ expect(written.providers).toEqual({
287
+ untouched: { name: 'Untouched', options: { baseURL: 'https://other.example.com/v1' }, models: { model: { name: 'Other model' } } },
288
+ });
289
+ expect(written.provider.legacy.name).toBe('Updated provider');
290
+ });
291
+
165
292
  test('upsert then remove restores absence', () => {
166
293
  upsertProviderConfig('temp-provider', {
167
294
  name: 'Temp',
@@ -230,8 +357,8 @@ describe('custom provider config persistence', () => {
230
357
  expect(sources.sources.custom.exists).toBe(false);
231
358
 
232
359
  for (const userPath of [
233
- path.join(os.homedir(), '.config', 'opencode', 'opencode.json'),
234
- path.join(os.homedir(), '.config', 'opencode', 'config.json'),
360
+ path.join(OPENCODE_CONFIG_DIR, 'opencode.json'),
361
+ path.join(OPENCODE_CONFIG_DIR, 'config.json'),
235
362
  ]) {
236
363
  if (!fs.existsSync(userPath)) continue;
237
364
  const userConfig = readJson(userPath);
@@ -269,8 +396,8 @@ describe('custom provider config persistence', () => {
269
396
  expect(sources.sources.project.exists).toBe(false);
270
397
 
271
398
  for (const userPath of [
272
- path.join(os.homedir(), '.config', 'opencode', 'opencode.json'),
273
- path.join(os.homedir(), '.config', 'opencode', 'config.json'),
399
+ path.join(OPENCODE_CONFIG_DIR, 'opencode.json'),
400
+ path.join(OPENCODE_CONFIG_DIR, 'config.json'),
274
401
  ]) {
275
402
  if (!fs.existsSync(userPath)) continue;
276
403
  const userConfig = readJson(userPath);
@@ -1,12 +1,13 @@
1
1
  import express from 'express';
2
2
  import { createProjectIdFromPath } from '../projects/project-id.js';
3
3
  import fs from 'fs';
4
- import os from 'os';
5
4
  import path from 'path';
6
5
  import {
7
6
  buildDeferredRestartResponse,
8
7
  } from './config-mutation-response.js';
9
8
  import { getClaudeCliAuthStatus } from './claude-cli-auth.js';
9
+ import { OPENCODE_CONFIG_DIR } from './shared.js';
10
+ import { settingsSurfaceOf } from './settings-files.js';
10
11
 
11
12
  export const registerOpenCodeRoutes = (app, dependencies) => {
12
13
  const {
@@ -208,9 +209,10 @@ ${desktopReturn ? `<a class="return" href="openchamber://focus/mcp-auth">Return
208
209
  }
209
210
  };
210
211
 
211
- app.get('/api/config/settings', async (_req, res) => {
212
+ app.get('/api/config/settings', async (req, res) => {
212
213
  try {
213
- const settings = await readSettingsFromDiskMigrated();
214
+ // The surface kind resolves the per-surface profile keys; absent means base.
215
+ const settings = await readSettingsFromDiskMigrated({ surface: settingsSurfaceOf(req) });
214
216
  res.json(formatSettingsResponse(settings));
215
217
  } catch (error) {
216
218
  console.error('Failed to read settings:', error);
@@ -422,7 +424,7 @@ ${desktopReturn ? `<a class="return" href="openchamber://focus/mcp-auth">Return
422
424
 
423
425
  app.put('/api/config/settings', async (req, res) => {
424
426
  try {
425
- const updated = await persistSettings(req.body ?? {});
427
+ const updated = await persistSettings(req.body ?? {}, { surface: settingsSurfaceOf(req) });
426
428
  res.json(updated);
427
429
  } catch (error) {
428
430
  console.error('[API:PUT /api/config/settings] Failed to save settings:', error);
@@ -807,7 +809,7 @@ ${desktopReturn ? `<a class="return" href="openchamber://focus/mcp-auth">Return
807
809
  });
808
810
 
809
811
  // Behavior / Global AGENTS.md endpoints
810
- const AGENTS_MD_PATH = path.join(os.homedir(), '.config', 'opencode', 'AGENTS.md');
812
+ const AGENTS_MD_PATH = path.join(OPENCODE_CONFIG_DIR, 'AGENTS.md');
811
813
  const MAX_BEHAVIOR_PROMPT_SIZE = 1024 * 1024; // 1 MB
812
814
 
813
815
  app.get('/api/behavior/agents-md', async (_req, res) => {
@@ -815,10 +817,10 @@ ${desktopReturn ? `<a class="return" href="openchamber://focus/mcp-auth">Return
815
817
  try {
816
818
  await fs.promises.access(AGENTS_MD_PATH);
817
819
  } catch {
818
- return res.json({ content: '', exists: false });
820
+ return res.json({ content: '', exists: false, path: AGENTS_MD_PATH });
819
821
  }
820
822
  const content = await fs.promises.readFile(AGENTS_MD_PATH, 'utf8');
821
- return res.json({ content, exists: true });
823
+ return res.json({ content, exists: true, path: AGENTS_MD_PATH });
822
824
  } catch (error) {
823
825
  console.error('Failed to read AGENTS.md:', error);
824
826
  return res.status(500).json({ error: 'Failed to read AGENTS.md' });
@@ -138,7 +138,7 @@ export const createServerStartupRuntime = (dependencies) => {
138
138
  // Cover every signal a shell or dev harness may use to stop/restart us, so
139
139
  // the managed OpenCode child is always torn down gracefully instead of
140
140
  // orphaned: SIGINT/SIGQUIT (Ctrl+C/Ctrl+\), SIGTERM (kill/default), SIGHUP
141
- // (terminal close), SIGUSR2 (nodemon restart for `dev:server:watch`).
141
+ // (terminal close), SIGUSR2 (nodemon restart outside Windows).
142
142
  process.on('SIGTERM', handleSignal);
143
143
  process.on('SIGINT', handleSignal);
144
144
  process.on('SIGQUIT', handleSignal);