@vantran-se/openchamber-web 2.0.2 → 2.0.4

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 (93) 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-B82Oye3V.js → CodeMirrorCommentWidgets-DZF8ntaq.js} +1 -1
  7. package/dist/assets/{DiffView-DJLmWn6Z.js → DiffView-D2p4Ar0H.js} +1 -1
  8. package/dist/assets/{FilesView-DjksmZRo.js → FilesView-BeTtbsip.js} +1 -1
  9. package/dist/assets/{GitView-DiUsUIgr.js → GitView-DwQwqDvu.js} +1 -1
  10. package/dist/assets/{GuestAttachDialog-CYwWYLr3.js → GuestAttachDialog-CoXM0gh_.js} +1 -1
  11. package/dist/assets/{GuestSurfacePane-CmctyRI4.js → GuestSurfacePane-DIJvkkM-.js} +1 -1
  12. package/dist/assets/{JsonTreeView-DOHHcbgJ.js → JsonTreeView-Cq_27v0y.js} +1 -1
  13. package/dist/assets/{LinearIssuesView-DaqnylcV.js → LinearIssuesView-CsSSRMhn.js} +1 -1
  14. package/dist/assets/{MarkdownImageGallery-HJNvWt7L.js → MarkdownImageGallery-CY0dWTaE.js} +1 -1
  15. package/dist/assets/{MarkdownRendererImpl-nDZRGg2I.js → MarkdownRendererImpl-ILdXzLrt.js} +1 -1
  16. package/dist/assets/{OnboardingScreen-C2B5Bsof.js → OnboardingScreen-ClatMQAX.js} +1 -1
  17. package/dist/assets/{PatchDiff-C4FLBjpt.js → PatchDiff-DOwlrIK9.js} +1 -1
  18. package/dist/assets/{PierreDiffViewer-C_1S9c9n.js → PierreDiffViewer-CrcGqqD_.js} +1 -1
  19. package/dist/assets/{PlanView-_eZvb-G5.js → PlanView-BGLsMgQk.js} +1 -1
  20. package/dist/assets/{PluginPane-sM8fcZIV.js → PluginPane-B39JEUL8.js} +1 -1
  21. package/dist/assets/{SettingsView-BRfr46hb.js → SettingsView-13B2_G-A.js} +1 -1
  22. package/dist/assets/{SettingsWindow-CcUDwQHg.js → SettingsWindow-B0BAy-f9.js} +1 -1
  23. package/dist/assets/{SubmoduleDiffSummary-CrN1ZGwl.js → SubmoduleDiffSummary-fcdDzCSW.js} +1 -1
  24. package/dist/assets/{ToolOutputDialog-Cij4jI09.js → ToolOutputDialog-B7xaUz2H.js} +1 -1
  25. package/dist/assets/{ToolPartDiffPreview-BXzwQ-JY.js → ToolPartDiffPreview-CkWCaUmZ.js} +1 -1
  26. package/dist/assets/{VirtualizedFileDiff-DSWTLJNH.js → VirtualizedFileDiff-DONC4eI2.js} +1 -1
  27. package/dist/assets/{WalkthroughView-CpUUgY8H.js → WalkthroughView-CXDImOXW.js} +1 -1
  28. package/dist/assets/{appThemeRegistry-B5tHTkR8.js → appThemeRegistry-Br2TLSlb.js} +1 -1
  29. package/dist/assets/{commitAndPush-D7D8qV8p.js → commitAndPush-CcI4ol7c.js} +1 -1
  30. package/dist/assets/{computeVirtualFileMetrics-cq9uQ30i.js → computeVirtualFileMetrics-_-20umCs.js} +1 -1
  31. package/dist/assets/{de-CUO1-KqF.js → de-BtYnUgII.js} +1 -1
  32. package/dist/assets/{es-BlbC6sGJ.js → es-crP5qPye.js} +1 -1
  33. package/dist/assets/{fr-D2nwe_0W.js → fr-CLSRuALk.js} +1 -1
  34. package/dist/assets/{index-BQOoYIOu.js → index-OKDYjcMF.js} +1 -1
  35. package/dist/assets/{isDiffPlainText-D0obJ2p8.js → isDiffPlainText-D77M6q1f.js} +1 -1
  36. package/dist/assets/{isFilePlainText-DB3_rjyW.js → isFilePlainText-C54B4Mns.js} +1 -1
  37. package/dist/assets/{ja-CA7vnDsZ.js → ja-SZ1-ucLV.js} +1 -1
  38. package/dist/assets/{ko-Chy6a9l-.js → ko-HthbT_Ve.js} +1 -1
  39. package/dist/assets/{main-B9JFK3vF.js → main-BJklC7Cl.js} +4 -4
  40. package/dist/assets/{main-Bhiqnrhd.js → main-CGEvLMd2.js} +2 -2
  41. package/dist/assets/{markdownCore-tfqOFaBd.js → markdownCore-BzQuETm0.js} +1 -1
  42. package/dist/assets/{miniChat-B0UOL5zA.js → miniChat-BSxkRQLj.js} +2 -2
  43. package/dist/assets/{mobile-BJppFIEo.js → mobile-C5KRTNOB.js} +2 -2
  44. package/dist/assets/{patchFileDiff-Db6KuwCP.js → patchFileDiff-DGw2KryM.js} +1 -1
  45. package/dist/assets/{pl-CTR5A_1P.js → pl-C8eYfjL1.js} +1 -1
  46. package/dist/assets/{pt-BR-BE1O1KEx.js → pt-BR-cYOrZdxS.js} +1 -1
  47. package/dist/assets/{renderElectronMiniChatApp-Yz5sfoFh.js → renderElectronMiniChatApp-CnkAQV6o.js} +2 -2
  48. package/dist/assets/{renderMobileApp-QQ-yB8aY.js → renderMobileApp-CMxjwisj.js} +3 -3
  49. package/dist/assets/{runtimeEndpointReset-DKDdzRzm.js → runtimeEndpointReset-C45jrFq4.js} +3 -3
  50. package/dist/assets/{scheduledTasksApi-C5sCuNqf.js → scheduledTasksApi-CkK1F9s2.js} +1 -1
  51. package/dist/assets/{shikiHighlight-xURoo5t_.js → shikiHighlight-4O0ZM14D.js} +1 -1
  52. package/dist/assets/{textMateThemeFromAppTheme-BZLGuYTU.js → textMateThemeFromAppTheme-yNq7b0DZ.js} +1 -1
  53. package/dist/assets/{tr-Bh5vK6cW.js → tr-B4IQ5t-T.js} +1 -1
  54. package/dist/assets/{uk-CLDkZFy_.js → uk-DCoXG6ve.js} +1 -1
  55. package/dist/assets/{useAppFontEffects-BxrtCnfR.js → useAppFontEffects-Bzyszv2N.js} +48 -48
  56. package/dist/assets/{useInlineCommentController-CeXuR-SR.js → useInlineCommentController-zOf6ORAq.js} +1 -1
  57. package/dist/assets/{useRootScrollLock-DgWxezc1.js → useRootScrollLock-DCaZ4BWi.js} +2 -2
  58. package/dist/assets/{vendor-codemirror-language-data-DOuaQUZ0.js → vendor-codemirror-language-data-6cEFPNUR.js} +2 -2
  59. package/dist/assets/{web-components-63H1h21j.js → web-components-BEo4-ISu.js} +1 -1
  60. package/dist/assets/{zh-CN-DODgcmYj.js → zh-CN-BXBoxsFk.js} +1 -1
  61. package/dist/assets/{zh-TW-ByNfIwLA.js → zh-TW-C6oelpnL.js} +1 -1
  62. package/dist/index.html +1 -1
  63. package/dist/mini-chat.html +1 -1
  64. package/dist/mobile.html +1 -1
  65. package/package.json +1 -1
  66. package/server/index.d.ts +2 -0
  67. package/server/index.js +114 -52
  68. package/server/lib/agent-tool/DOCUMENTATION.md +47 -127
  69. package/server/lib/agent-tool/runtime.js +66 -47
  70. package/server/lib/agent-tool/runtime.test.js +2 -0
  71. package/server/lib/event-stream/DOCUMENTATION.md +2 -2
  72. package/server/lib/event-stream/global-hub.js +22 -6
  73. package/server/lib/event-stream/global-hub.test.js +93 -2
  74. package/server/lib/event-stream/rebind.test.js +1 -0
  75. package/server/lib/event-stream/runtime.js +1 -2
  76. package/server/lib/opencode/DOCUMENTATION.md +23 -23
  77. package/server/lib/opencode/lifecycle.js +126 -0
  78. package/server/lib/opencode/lifecycle.test.js +228 -1
  79. package/server/lib/opencode/network-runtime.js +16 -7
  80. package/server/lib/opencode/network-runtime.test.js +29 -1
  81. package/server/lib/opencode/proxy.js +15 -14
  82. package/server/lib/opencode/server-startup-runtime.js +25 -0
  83. package/server/lib/opencode/server-startup-runtime.test.js +67 -1
  84. package/server/lib/opencode/shared-service-runtime.js +155 -0
  85. package/server/lib/opencode/shared-service-runtime.test.js +180 -0
  86. package/server/lib/opencode/shutdown-runtime.js +11 -5
  87. package/server/lib/opencode/shutdown-runtime.test.js +77 -2
  88. package/server/lib/opencode/startup-pipeline-runtime.js +5 -4
  89. package/server/lib/opencode/startup-pipeline-runtime.test.js +18 -7
  90. package/server/lib/opencode/watcher.test.js +54 -0
  91. package/server/opencode-proxy.test.js +158 -0
  92. package/server/proxy-headers.js +8 -2
  93. package/server/proxy-headers.test.js +20 -0
@@ -55,6 +55,132 @@ const WARMUP_REQUEST_TIMEOUT_MS = 30000;
55
55
  const MANAGED_STDERR_TAIL_MAX_BYTES = 32 * 1024;
56
56
  const HEALTH_FAILURE_DETAIL_MAX_LENGTH = 256;
57
57
 
58
+ export const resolveOpenCodeConnectionKind = ({ runtime, configuredHost, lifecycleMode }) => {
59
+ if (configuredHost) return 'explicit-external';
60
+ if (lifecycleMode === 'managed' || runtime === 'desktop') return 'managed-owned';
61
+ return 'shared-local';
62
+ };
63
+
64
+ export const createOpenCodeServerComposition = ({
65
+ runtime,
66
+ configuredHost,
67
+ lifecycleMode,
68
+ createSharedRuntime,
69
+ createManagedPluginRuntime = () => null,
70
+ getManagedBaseUrl = () => null,
71
+ getManagedAuthHeaders = () => ({}),
72
+ }) => {
73
+ const kind = resolveOpenCodeConnectionKind({ runtime, configuredHost, lifecycleMode });
74
+ if (kind === 'shared-local' && !createSharedRuntime) {
75
+ throw new Error('Shared OpenCode composition requires a shared runtime factory');
76
+ }
77
+ const sharedRuntime = kind === 'shared-local' ? createSharedRuntime() : null;
78
+ const managedPluginRuntime = kind === 'managed-owned' ? createManagedPluginRuntime() : null;
79
+ const connection = createOpenCodeConnectionAdapter({
80
+ kind,
81
+ sharedRuntime,
82
+ getManagedBaseUrl,
83
+ getManagedAuthHeaders,
84
+ });
85
+ return { kind, sharedRuntime, managedPluginRuntime, connection };
86
+ };
87
+
88
+ export const createOpenCodeRecoveryCallbacks = ({
89
+ resetOpenCodeRuntimeProviders,
90
+ rebindUpstream,
91
+ interruptBusySessionsAfterRestart,
92
+ broadcastUiNotification,
93
+ }) => ({
94
+ onManagedRestarted() {
95
+ resetOpenCodeRuntimeProviders();
96
+ try {
97
+ rebindUpstream();
98
+ } catch (error) {
99
+ console.warn('Failed to rebind message stream after OpenCode restart:', error?.message ?? error);
100
+ }
101
+ try {
102
+ const { sessionIds } = interruptBusySessionsAfterRestart();
103
+ if (sessionIds.length === 0) return;
104
+ const multiple = sessionIds.length > 1;
105
+ broadcastUiNotification({
106
+ title: multiple ? 'Chats interrupted' : 'Chat interrupted',
107
+ body: multiple
108
+ ? 'OpenCode restarted during running responses. Send a message in each chat to continue.'
109
+ : 'OpenCode restarted during a running response. Send a message to continue.',
110
+ tag: 'opencode-restart-interrupted',
111
+ kind: 'opencode-restart-interrupted',
112
+ sessionId: sessionIds[0],
113
+ });
114
+ } catch (error) {
115
+ console.warn('Failed to reconcile sessions after OpenCode restart:', error?.message ?? error);
116
+ }
117
+ },
118
+ onSharedRecovered() {
119
+ resetOpenCodeRuntimeProviders();
120
+ rebindUpstream();
121
+ },
122
+ });
123
+
124
+ export const createOpenCodeLifecycleControls = ({
125
+ getKind,
126
+ restartLifecycle,
127
+ triggerLifecycleHealthCheck,
128
+ recoverShared,
129
+ onSharedRecovered,
130
+ }) => {
131
+ let sharedRecoveryPromise = null;
132
+ const recover = () => {
133
+ if (!recoverShared || !onSharedRecovered) {
134
+ return Promise.reject(new Error('Shared OpenCode recovery is not configured'));
135
+ }
136
+ if (!sharedRecoveryPromise) {
137
+ sharedRecoveryPromise = Promise.resolve()
138
+ .then(() => recoverShared())
139
+ .then(() => onSharedRecovered())
140
+ .finally(() => {
141
+ sharedRecoveryPromise = null;
142
+ });
143
+ }
144
+ return sharedRecoveryPromise;
145
+ };
146
+
147
+ return {
148
+ restart: (...args) => (
149
+ getKind() === 'shared-local' ? recover() : restartLifecycle(...args)
150
+ ),
151
+ triggerHealthCheck: (...args) => {
152
+ if (getKind() !== 'shared-local') {
153
+ return triggerLifecycleHealthCheck(...args);
154
+ }
155
+ return recover().catch((error) => {
156
+ console.warn('Failed to recover shared OpenCode service:', error?.message ?? error);
157
+ });
158
+ },
159
+ };
160
+ };
161
+
162
+ export const createOpenCodeConnectionAdapter = ({
163
+ kind,
164
+ sharedRuntime = null,
165
+ getManagedBaseUrl = () => null,
166
+ getManagedAuthHeaders = () => ({}),
167
+ }) => {
168
+ if (kind === 'shared-local' && !sharedRuntime) {
169
+ throw new Error('Shared OpenCode connection requires a shared service runtime');
170
+ }
171
+
172
+ return {
173
+ getOpenCodeConnectionKind: () => kind,
174
+ getOpenCodeBaseUrl: () => (
175
+ kind === 'shared-local' ? sharedRuntime.getBaseUrl() : getManagedBaseUrl()
176
+ ),
177
+ getOpenCodeAuthHeaders: () => (
178
+ kind === 'shared-local' ? sharedRuntime.getHeaders() : getManagedAuthHeaders()
179
+ ),
180
+ ownsOpenCodeProcess: () => kind === 'managed-owned',
181
+ };
182
+ };
183
+
58
184
  const getBoundedTextTail = (value, maxBytes) => {
59
185
  const buffer = Buffer.from(String(value ?? ''));
60
186
  if (buffer.byteLength <= maxBytes) return buffer.toString();
@@ -18,7 +18,14 @@ vi.mock('./startup-performance.js', () => ({
18
18
  recordStartupPerformance: recordStartupPerformanceMock,
19
19
  }));
20
20
 
21
- const { createOpenCodeLifecycleRuntime } = await import('./lifecycle.js');
21
+ const {
22
+ createOpenCodeConnectionAdapter,
23
+ createOpenCodeLifecycleControls,
24
+ createOpenCodeLifecycleRuntime,
25
+ createOpenCodeRecoveryCallbacks,
26
+ createOpenCodeServerComposition,
27
+ resolveOpenCodeConnectionKind,
28
+ } = await import('./lifecycle.js');
22
29
 
23
30
  const originalOpencodeBinary = process.env.OPENCODE_BINARY;
24
31
  const originalPath = process.env.PATH;
@@ -128,6 +135,226 @@ const createRuntime = (overrides = {}, stateOverrides = {}, envOverrides = {}) =
128
135
  return runtime;
129
136
  };
130
137
 
138
+ describe('OpenCode connection composition', () => {
139
+ it('selects shared-local for Web without an explicit host', () => {
140
+ expect(resolveOpenCodeConnectionKind({ runtime: 'web', configuredHost: null })).toBe('shared-local');
141
+ });
142
+
143
+ it('selects explicit-external when a host is configured', () => {
144
+ expect(resolveOpenCodeConnectionKind({
145
+ runtime: 'web',
146
+ configuredHost: { origin: 'https://opencode.example.com', port: 443 },
147
+ })).toBe('explicit-external');
148
+ });
149
+
150
+ it('keeps Desktop on the managed-owned lifecycle', () => {
151
+ expect(resolveOpenCodeConnectionKind({ runtime: 'desktop', configuredHost: null })).toBe('managed-owned');
152
+ });
153
+
154
+ it('gives an explicit host priority over the Desktop managed lifecycle', () => {
155
+ expect(resolveOpenCodeConnectionKind({
156
+ runtime: 'desktop',
157
+ configuredHost: { origin: 'https://opencode.example.com', port: 443 },
158
+ })).toBe('explicit-external');
159
+ });
160
+
161
+ it('keeps an embedded managed override on the managed-owned lifecycle', () => {
162
+ expect(resolveOpenCodeConnectionKind({
163
+ runtime: 'web',
164
+ configuredHost: null,
165
+ lifecycleMode: 'managed',
166
+ })).toBe('managed-owned');
167
+ });
168
+
169
+ it('requires a shared runtime factory for shared-local composition', () => {
170
+ expect(() => createOpenCodeServerComposition({
171
+ runtime: 'web',
172
+ configuredHost: null,
173
+ })).toThrow('Shared OpenCode composition requires a shared runtime factory');
174
+ });
175
+
176
+ it('creates shared and managed-only runtimes only for their owning connection kind', () => {
177
+ const sharedRuntime = { getBaseUrl: () => null, getHeaders: () => ({}) };
178
+ const managedPluginRuntime = { buildManagedChildEnv: () => ({}) };
179
+ const createSharedRuntime = vi.fn(() => sharedRuntime);
180
+ const createManagedPluginRuntime = vi.fn(() => managedPluginRuntime);
181
+
182
+ const shared = createOpenCodeServerComposition({
183
+ runtime: 'web',
184
+ configuredHost: null,
185
+ createSharedRuntime,
186
+ createManagedPluginRuntime,
187
+ });
188
+ const external = createOpenCodeServerComposition({
189
+ runtime: 'web',
190
+ configuredHost: { origin: 'https://opencode.example.com', port: 443 },
191
+ createSharedRuntime,
192
+ createManagedPluginRuntime,
193
+ });
194
+ const desktop = createOpenCodeServerComposition({
195
+ runtime: 'desktop',
196
+ configuredHost: null,
197
+ createSharedRuntime,
198
+ createManagedPluginRuntime,
199
+ });
200
+
201
+ expect(shared.sharedRuntime).toBe(sharedRuntime);
202
+ expect(shared.managedPluginRuntime).toBeNull();
203
+ expect(external.sharedRuntime).toBeNull();
204
+ expect(external.managedPluginRuntime).toBeNull();
205
+ expect(desktop.sharedRuntime).toBeNull();
206
+ expect(desktop.managedPluginRuntime).toBe(managedPluginRuntime);
207
+ expect(createSharedRuntime).toHaveBeenCalledTimes(1);
208
+ expect(createManagedPluginRuntime).toHaveBeenCalledTimes(1);
209
+ });
210
+
211
+ it('recovers the shared service through the production callback without interrupting sessions', async () => {
212
+ const restartManaged = vi.fn(async () => {});
213
+ const triggerManagedHealthCheck = vi.fn(async () => {});
214
+ const recoverShared = vi.fn(async () => {});
215
+ const resetOpenCodeRuntimeProviders = vi.fn();
216
+ const rebindUpstream = vi.fn();
217
+ const interruptBusySessionsAfterRestart = vi.fn(() => ({ sessionIds: [] }));
218
+ const broadcastUiNotification = vi.fn();
219
+ const callbacks = createOpenCodeRecoveryCallbacks({
220
+ resetOpenCodeRuntimeProviders,
221
+ rebindUpstream,
222
+ interruptBusySessionsAfterRestart,
223
+ broadcastUiNotification,
224
+ });
225
+ const shared = createOpenCodeLifecycleControls({
226
+ getKind: () => 'shared-local',
227
+ restartLifecycle: restartManaged,
228
+ triggerLifecycleHealthCheck: triggerManagedHealthCheck,
229
+ recoverShared,
230
+ onSharedRecovered: callbacks.onSharedRecovered,
231
+ });
232
+
233
+ await shared.restart('manual');
234
+
235
+ expect(recoverShared).toHaveBeenCalledOnce();
236
+ expect(resetOpenCodeRuntimeProviders).toHaveBeenCalledOnce();
237
+ expect(rebindUpstream).toHaveBeenCalledOnce();
238
+ expect(recoverShared.mock.invocationCallOrder[0]).toBeLessThan(rebindUpstream.mock.invocationCallOrder[0]);
239
+ expect(interruptBusySessionsAfterRestart).not.toHaveBeenCalled();
240
+ expect(broadcastUiNotification).not.toHaveBeenCalled();
241
+ expect(restartManaged).not.toHaveBeenCalled();
242
+ expect(triggerManagedHealthCheck).not.toHaveBeenCalled();
243
+ });
244
+
245
+ it('keeps managed restart interruption behavior in the production callback', () => {
246
+ const resetOpenCodeRuntimeProviders = vi.fn();
247
+ const rebindUpstream = vi.fn();
248
+ const interruptBusySessionsAfterRestart = vi.fn(() => ({ sessionIds: ['session-1'] }));
249
+ const broadcastUiNotification = vi.fn();
250
+ const callbacks = createOpenCodeRecoveryCallbacks({
251
+ resetOpenCodeRuntimeProviders,
252
+ rebindUpstream,
253
+ interruptBusySessionsAfterRestart,
254
+ broadcastUiNotification,
255
+ });
256
+
257
+ callbacks.onManagedRestarted();
258
+
259
+ expect(resetOpenCodeRuntimeProviders).toHaveBeenCalledOnce();
260
+ expect(rebindUpstream).toHaveBeenCalledOnce();
261
+ expect(interruptBusySessionsAfterRestart).toHaveBeenCalledOnce();
262
+ expect(broadcastUiNotification).toHaveBeenCalledWith(expect.objectContaining({
263
+ kind: 'opencode-restart-interrupted',
264
+ sessionId: 'session-1',
265
+ }));
266
+ });
267
+
268
+ it('shares concurrent shared recovery attempts', async () => {
269
+ let finishRecovery;
270
+ const recoverShared = vi.fn(() => new Promise((resolve) => {
271
+ finishRecovery = resolve;
272
+ }));
273
+ const onSharedRecovered = vi.fn(async () => {});
274
+ const shared = createOpenCodeLifecycleControls({
275
+ getKind: () => 'shared-local',
276
+ restartLifecycle: vi.fn(),
277
+ triggerLifecycleHealthCheck: vi.fn(),
278
+ recoverShared,
279
+ onSharedRecovered,
280
+ });
281
+
282
+ const first = shared.triggerHealthCheck();
283
+ const second = shared.triggerHealthCheck();
284
+ await vi.waitFor(() => expect(recoverShared).toHaveBeenCalledOnce());
285
+ finishRecovery();
286
+ await Promise.all([first, second]);
287
+
288
+ expect(recoverShared).toHaveBeenCalledOnce();
289
+ expect(onSharedRecovered).toHaveBeenCalledOnce();
290
+ });
291
+
292
+ it('contains shared health recovery failures and permits a later retry', async () => {
293
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
294
+ const recoverShared = vi.fn()
295
+ .mockRejectedValueOnce(new Error('service unavailable'))
296
+ .mockResolvedValueOnce(undefined);
297
+ const onSharedRecovered = vi.fn(async () => {});
298
+ const shared = createOpenCodeLifecycleControls({
299
+ getKind: () => 'shared-local',
300
+ restartLifecycle: vi.fn(),
301
+ triggerLifecycleHealthCheck: vi.fn(),
302
+ recoverShared,
303
+ onSharedRecovered,
304
+ });
305
+
306
+ await expect(shared.triggerHealthCheck()).resolves.toBeUndefined();
307
+ await expect(shared.restart()).resolves.toBeUndefined();
308
+
309
+ expect(recoverShared).toHaveBeenCalledTimes(2);
310
+ expect(onSharedRecovered).toHaveBeenCalledOnce();
311
+ });
312
+
313
+ it('preserves managed lifecycle controls', async () => {
314
+ const restartManaged = vi.fn(async () => {});
315
+ const triggerManagedHealthCheck = vi.fn(async () => {});
316
+ const managed = createOpenCodeLifecycleControls({
317
+ getKind: () => 'managed-owned',
318
+ restartLifecycle: restartManaged,
319
+ triggerLifecycleHealthCheck: triggerManagedHealthCheck,
320
+ });
321
+
322
+ await managed.restart('manual');
323
+ await managed.triggerHealthCheck();
324
+
325
+ expect(restartManaged).toHaveBeenCalledWith('manual');
326
+ expect(triggerManagedHealthCheck).toHaveBeenCalledTimes(1);
327
+ });
328
+
329
+ it('requires a shared runtime for shared-local composition', () => {
330
+ expect(() => createOpenCodeConnectionAdapter({ kind: 'shared-local' })).toThrow(
331
+ 'Shared OpenCode connection requires a shared service runtime',
332
+ );
333
+ });
334
+
335
+ it('reads shared URL and auth from the shared runtime at call time without owning its process', () => {
336
+ let baseUrl = 'http://127.0.0.1:4096';
337
+ let headers = { authorization: 'Basic first' };
338
+ const adapter = createOpenCodeConnectionAdapter({
339
+ kind: 'shared-local',
340
+ sharedRuntime: {
341
+ getBaseUrl: () => baseUrl,
342
+ getHeaders: () => ({ ...headers }),
343
+ },
344
+ });
345
+
346
+ expect(adapter.getOpenCodeConnectionKind()).toBe('shared-local');
347
+ expect(adapter.getOpenCodeBaseUrl()).toBe('http://127.0.0.1:4096');
348
+ expect(adapter.getOpenCodeAuthHeaders()).toEqual({ authorization: 'Basic first' });
349
+ expect(adapter.ownsOpenCodeProcess()).toBe(false);
350
+
351
+ baseUrl = 'http://127.0.0.1:5096';
352
+ headers = { authorization: 'Basic replacement' };
353
+ expect(adapter.getOpenCodeBaseUrl()).toBe('http://127.0.0.1:5096');
354
+ expect(adapter.getOpenCodeAuthHeaders()).toEqual({ authorization: 'Basic replacement' });
355
+ });
356
+ });
357
+
131
358
  describe('OpenCode lifecycle', () => {
132
359
  it('uses the resolved binary directly on startup and managed restart without an env override', async () => {
133
360
  delete process.env.OPENCODE_BINARY;
@@ -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');
@@ -477,18 +477,15 @@ export const registerOpenCodeProxy = (app, deps) => {
477
477
  const resolveProxyTarget = () => {
478
478
  const runtimeState = getRuntime();
479
479
 
480
- // `buildOpenCodeUrl` throws while the port is unknown, and the port is
481
- // nulled on several runtime paths (health-check failure, failed restart),
482
- // not just cold start. Checking first keeps a degraded OpenCode from
483
- // making every proxied request pay for a thrown-and-caught exception.
484
- if (runtimeState.openCodePort) {
485
- try {
486
- const resolved = normalizeProxyTarget(buildOpenCodeUrl('/', ''));
487
- if (resolved) {
488
- return resolved;
489
- }
490
- } catch {
480
+ // The shared-service endpoint can remain authoritative even when it has no
481
+ // explicit port (for example, HTTPS on its default port). Resolve it before
482
+ // consulting the legacy runtime snapshot.
483
+ try {
484
+ const resolved = normalizeProxyTarget(buildOpenCodeUrl('/', ''));
485
+ if (resolved) {
486
+ return resolved;
491
487
  }
488
+ } catch {
492
489
  }
493
490
 
494
491
  const externalBase = normalizeProxyTarget(runtimeState.openCodeBaseUrl);
@@ -973,10 +970,14 @@ export const registerOpenCodeProxy = (app, deps) => {
973
970
  router: () => resolveProxyTarget(),
974
971
  on: {
975
972
  proxyReq: (proxyReq, req) => {
976
- // Inject OpenCode auth headers
973
+ // The proxy middleware starts with browser headers. Never let an
974
+ // OpenChamber client credential reach OpenCode, even when the active
975
+ // OpenCode connection itself has no authorization header.
976
+ proxyReq.removeHeader('authorization');
977
977
  const authHeaders = getOpenCodeAuthHeaders();
978
- if (authHeaders.Authorization) {
979
- proxyReq.setHeader('Authorization', authHeaders.Authorization);
978
+ for (const [key, value] of Object.entries(authHeaders)) {
979
+ proxyReq.removeHeader(key);
980
+ proxyReq.setHeader(key, value);
980
981
  }
981
982
 
982
983
  if (req.headers?.['x-opencode-directory-encoding'] === 'uri') {
@@ -31,6 +31,7 @@ export const createServerStartupRuntime = (dependencies) => {
31
31
  bindHost,
32
32
  startupTunnelRequest,
33
33
  onTunnelReady,
34
+ afterListening,
34
35
  }) => {
35
36
  let activePort = port;
36
37
 
@@ -45,6 +46,30 @@ export const createServerStartupRuntime = (dependencies) => {
45
46
  try {
46
47
  const addressInfo = server.address();
47
48
  activePort = typeof addressInfo === 'object' && addressInfo ? addressInfo.port : port;
49
+ try {
50
+ await afterListening?.({ activePort, bindHost });
51
+ } catch (error) {
52
+ if (typeof process.send === 'function' && process.connected) {
53
+ const startupError = {
54
+ type: 'openchamber:error',
55
+ code: error?.code || 'WEB_STARTUP_ERROR',
56
+ message: error instanceof Error ? error.message : String(error),
57
+ };
58
+ if (error?.url) startupError.url = error.url;
59
+ await new Promise((resolveStartupError) => {
60
+ try {
61
+ process.send(startupError, () => resolveStartupError());
62
+ } catch {
63
+ resolveStartupError();
64
+ }
65
+ });
66
+ }
67
+ await new Promise((resolveClose) => {
68
+ server.close(() => resolveClose());
69
+ server.closeAllConnections?.();
70
+ });
71
+ throw error;
72
+ }
48
73
 
49
74
  if (typeof process.send === 'function') {
50
75
  if (!process.connected) {
@@ -1,4 +1,4 @@
1
- import { describe, expect, test } from 'bun:test';
1
+ import { describe, expect, test, mock } from 'bun:test';
2
2
  import { EventEmitter } from 'node:events';
3
3
  import { createServerStartupRuntime } from './server-startup-runtime.js';
4
4
 
@@ -7,6 +7,72 @@ import { createServerStartupRuntime } from './server-startup-runtime.js';
7
7
  * on a single uncaught exception turned every stray socket error into "the
8
8
  * instance is unreachable until restarted". Only a sustained storm shuts down.
9
9
  */
10
+ describe('server startup readiness', () => {
11
+ test('reports daemon ready only after listener-dependent startup finishes', async () => {
12
+ const order = [];
13
+ const fakeProcess = {
14
+ connected: true,
15
+ send: mock((message, callback) => {
16
+ order.push(message.type);
17
+ callback();
18
+ }),
19
+ };
20
+ const server = new EventEmitter();
21
+ server.address = () => ({ port: 3901 });
22
+ server.listen = (_port, _host, callback) => callback();
23
+ const runtime = createServerStartupRuntime({
24
+ process: fakeProcess,
25
+ crypto: {},
26
+ server,
27
+ readSettingsFromDiskMigrated: async () => ({}),
28
+ tunnelAuthController: {},
29
+ });
30
+
31
+ await runtime.startListeningAndMaybeTunnel({
32
+ port: 0,
33
+ bindHost: '127.0.0.1',
34
+ afterListening: async () => order.push('prepared'),
35
+ });
36
+
37
+ expect(order).toEqual(['prepared', 'openchamber:ready']);
38
+ });
39
+
40
+ test('closes the listener and reports startup failure when listener-dependent startup fails', async () => {
41
+ const sent = [];
42
+ const fakeProcess = {
43
+ connected: true,
44
+ send: mock((message, callback) => {
45
+ sent.push(message);
46
+ callback();
47
+ }),
48
+ };
49
+ const server = new EventEmitter();
50
+ server.address = () => ({ port: 3901 });
51
+ server.listen = (_port, _host, callback) => callback();
52
+ server.close = mock((callback) => callback());
53
+ server.closeAllConnections = mock(() => {});
54
+ const runtime = createServerStartupRuntime({ process: fakeProcess, crypto: {}, server });
55
+ const startupError = Object.assign(new Error('shared service rejected'), {
56
+ code: 'OPENCODE_INCOMPATIBLE',
57
+ });
58
+
59
+ await expect(runtime.startListeningAndMaybeTunnel({
60
+ port: 0,
61
+ bindHost: '127.0.0.1',
62
+ afterListening: async () => { throw startupError; },
63
+ })).rejects.toBe(startupError);
64
+
65
+ expect(server.close).toHaveBeenCalledTimes(1);
66
+ expect(server.closeAllConnections).toHaveBeenCalledTimes(1);
67
+ expect(sent).toEqual([expect.objectContaining({
68
+ type: 'openchamber:error',
69
+ code: 'OPENCODE_INCOMPATIBLE',
70
+ })]);
71
+ expect(sent).not.toContainEqual(expect.objectContaining({ type: 'openchamber:ready' }));
72
+ });
73
+
74
+ });
75
+
10
76
  describe('uncaught exception policy', () => {
11
77
  const setup = () => {
12
78
  const fakeProcess = new EventEmitter();