@openchambery/web 2.0.0-beta.4 → 2.0.0-beta.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/dist/assets/{AgentAvatar-Q1SmFRx2.js → AgentAvatar-BBmQXYDq.js} +1 -1
  2. package/dist/assets/{AssistantView-CwXkiCAd.js → AssistantView-BG8hK4y9.js} +3 -3
  3. package/dist/assets/{DiagramView-BW92schO.js → DiagramView-CN0qWNU5.js} +1 -1
  4. package/dist/assets/{FadeInOnReveal-BZmwl-zE.js → FadeInOnReveal-w_BQ7K7K.js} +1 -1
  5. package/dist/assets/Icon-B9kZgYZn.js +1 -0
  6. package/dist/assets/{MarkdownRenderer-Bv05iDZZ.js → MarkdownRenderer-Ds61--LT.js} +2 -2
  7. package/dist/assets/{MarkdownRendererImpl-C81xqYib.js → MarkdownRendererImpl-DOMmiOH0.js} +1 -1
  8. package/dist/assets/{MarkstreamRendererImpl-CXUCQuKJ.js → MarkstreamRendererImpl-F7MGg5ER.js} +1 -1
  9. package/dist/assets/{MobileDetailNavigation-BMgIu7zc.js → MobileDetailNavigation-DcmVOjkw.js} +1 -1
  10. package/dist/assets/{MobileShareBridge-BOXGNHmC.js → MobileShareBridge-C1Fc4ErB.js} +2 -2
  11. package/dist/assets/MobileSurface-BA7eS5Yd.js +2 -0
  12. package/dist/assets/{MultiRunWindow-B5At2Z95.js → MultiRunWindow-BoTnH6Yp.js} +1 -1
  13. package/dist/assets/{OnboardingScreen-bsPLgz4r.js → OnboardingScreen-BxItVnnO.js} +1 -1
  14. package/dist/assets/{OverlayScrollbar-4wp4migh.js → OverlayScrollbar-B4e0QD05.js} +1 -1
  15. package/dist/assets/SettingsView-CjesWNLw.js +25 -0
  16. package/dist/assets/{SettingsWindow-CMtX2bMl.js → SettingsWindow-CReMbg-w.js} +1 -1
  17. package/dist/assets/TerminalView-C64-QYOG.js +8 -0
  18. package/dist/assets/{ToolOutputDialog-uH1ZZDSS.js → ToolOutputDialog-CJu8DNrt.js} +1 -1
  19. package/dist/assets/{appThemeRegistry-DK7q2juD.js → appThemeRegistry-CF4lIxNt.js} +1 -1
  20. package/dist/assets/{button-CcNJUxUd.js → button-CwXyux0a.js} +1 -1
  21. package/dist/assets/collapsible-BZ6_cYJd.js +1 -0
  22. package/dist/assets/context-menu-DHrRifPE.js +1 -0
  23. package/dist/assets/{desktopHosts-C6HrP0jq.js → desktopHosts-JGFqhlaM.js} +1 -1
  24. package/dist/assets/{desktopRelayRestore-DdVBp45U.js → desktopRelayRestore-CHEwrI93.js} +1 -1
  25. package/dist/assets/{diagram-CxTs9hCQ.js → diagram-BwnPNKWt.js} +1 -1
  26. package/dist/assets/{dialog-KMVL9QKF.js → dialog-Dp0lQv5K.js} +1 -1
  27. package/dist/assets/es-DicJ8lKD.js +15 -0
  28. package/dist/assets/{fileMentionTouchSelection-7RIwlpkB.js → fileMentionTouchSelection-DPZRxNa2.js} +3 -4
  29. package/dist/assets/{fileQueries-CjdBRy8y.js → fileQueries-DN6gbLhM.js} +1 -1
  30. package/dist/assets/fr-lhgVcBW_.js +13 -0
  31. package/dist/assets/{gitApi-yuWg9BN4.js → gitApi-D8R7ic-T.js} +1 -1
  32. package/dist/assets/{imageSource-9vQBq3zp.js → imageSource-BMztRu0c.js} +1 -1
  33. package/dist/assets/{input-CrIvk3HZ.js → input-Biw8aNzR.js} +1 -1
  34. package/dist/assets/insertionBoundaries-DdwCD4S2.js +1 -0
  35. package/dist/assets/ja-B9EZwOcw.js +15 -0
  36. package/dist/assets/ko-Dxk-uJRb.js +15 -0
  37. package/dist/assets/main-BhlwwJ2c.js +169 -0
  38. package/dist/assets/main-D2BzpnUt.css +1 -0
  39. package/dist/assets/main-DSPCkT74.js +2 -0
  40. package/dist/assets/{markdownHeightCache-BmXVY_gc.js → markdownHeightCache-BOOrGCuG.js} +1 -1
  41. package/dist/assets/markdownTheme-DM5h1i18.js +1 -0
  42. package/dist/assets/{mcpQueries-C7jknHV8.js → mcpQueries-CE9tk-Jz.js} +1 -1
  43. package/dist/assets/miniChat-Ckk9ABql.js +2 -0
  44. package/dist/assets/mobile-cLMRZqI9.js +2 -0
  45. package/dist/assets/multirun-DVnFRb1V.js +4 -0
  46. package/dist/assets/persistence-D0y7226-.js +3 -0
  47. package/dist/assets/pl-CFhwWLJj.js +15 -0
  48. package/dist/assets/platform-D_c-T5nO.js +6 -0
  49. package/dist/assets/projectMeta-CSQgHequ.js +3 -0
  50. package/dist/assets/pt-BR-Ba6GfZ6J.js +15 -0
  51. package/dist/assets/renderElectronMiniChatApp-gGFkwoL_.js +2 -0
  52. package/dist/assets/{renderMobileApp-CGMMqbsO.js → renderMobileApp-B2w50kAc.js} +7 -7
  53. package/dist/assets/{runtimeConfig-BEANkvOd.js → runtimeConfig-BHH1hJDd.js} +1 -1
  54. package/dist/assets/runtimeEndpointReset-5uJ4sJk0.js +149 -0
  55. package/dist/assets/{sessionChangeSummary-qOLrP9fw.js → sessionChangeSummary-spf_atO8.js} +2 -2
  56. package/dist/assets/{sessionLookup-34XFmP9C.js → sessionLookup-BGjjwiCq.js} +1 -1
  57. package/dist/assets/{sortable-tabs-strip-DyEV0Heg.js → sortable-tabs-strip-CTY_wG46.js} +1 -1
  58. package/dist/assets/src-BjEyU6if.css +2 -0
  59. package/dist/assets/store-DmowK-GO.js +15 -0
  60. package/dist/assets/{terminalApi-CXaBEoa_.js → terminalApi-Dhqqp4xi.js} +1 -1
  61. package/dist/assets/{textarea-BBOlRI9O.js → textarea-QxsmcBGj.js} +1 -1
  62. package/dist/assets/{tooltip-CPI4fr1a.js → tooltip-CpMLl5_k.js} +1 -1
  63. package/dist/assets/uk-ChchBzy6.js +15 -0
  64. package/dist/assets/{url-DRDQXjyX.js → url-CGbJbiJq.js} +1 -1
  65. package/dist/assets/{useAppFontEffects-CiDe42I5.js → useAppFontEffects-B1h7ab_J.js} +56 -51
  66. package/dist/assets/{useAssistantUIStore-asNG6K7n.js → useAssistantUIStore-Bz4tRflF.js} +1 -1
  67. package/dist/assets/useDesktopWindowControlsLayout-Cxsx84NO.js +1 -0
  68. package/dist/assets/{useEffectiveDirectory-C_CouL5v.js → useEffectiveDirectory-DsqhqsJR.js} +1 -1
  69. package/dist/assets/{useFontPreferences-B0oUZAiB.js → useFontPreferences-Xw1vSkmC.js} +1 -1
  70. package/dist/assets/useMobileNavigationStore-B1axS4g-.js +414 -0
  71. package/dist/assets/{useRuntimeAPIs-DwLtO0c_.js → useRuntimeAPIs-DSLXJybW.js} +1 -1
  72. package/dist/assets/useSessionAutoCleanup-B5FTG_qf.js +2 -0
  73. package/dist/assets/{useThemeSystem-BlNUWH47.js → useThemeSystem-DMzWVJaW.js} +1 -1
  74. package/dist/assets/useUIStore-syVmbuDA.js +1 -0
  75. package/dist/assets/{useWorkerHighlightedLines-CPNYz6Wf.js → useWorkerHighlightedLines-DC_Bc-RV.js} +1 -1
  76. package/dist/assets/{vendor-base-ui-CDjyofeD.js → vendor-base-ui-CnHfUmDN.js} +1 -1
  77. package/dist/assets/{vendor-cmdk-BFH0UpXX.js → vendor-cmdk-CeAGfrLo.js} +1 -1
  78. package/dist/assets/{vendor-dnd-kit-accessibility-BrR3AKvM.js → vendor-dnd-kit-accessibility-BY5LhhUB.js} +1 -1
  79. package/dist/assets/{vendor-dnd-kit-core-CNfdvk6H.js → vendor-dnd-kit-core-CvWxUPdb.js} +1 -1
  80. package/dist/assets/{vendor-dnd-kit-sortable-DQUaTeGj.js → vendor-dnd-kit-sortable-hHdP9lLb.js} +1 -1
  81. package/dist/assets/{vendor-framer-motion-4dogsUk3.js → vendor-framer-motion-DFy_BSVd.js} +1 -1
  82. package/dist/assets/{vendor-legendapp-list-BU7MdPZR.js → vendor-legendapp-list-WE32lwNk.js} +1 -1
  83. package/dist/assets/{vendor-markstream-react-qHC2FykU.js → vendor-markstream-react-DX55XLGO.js} +3 -3
  84. package/dist/assets/vendor-opencode-client-C12gfjag.js +12 -0
  85. package/dist/assets/{vendor-pierre-diffs-CUfqC003.js → vendor-pierre-diffs-DX7XTPG3.js} +1 -1
  86. package/dist/assets/{vendor-react-C94A0AHl.js → vendor-react-DLPElpoQ.js} +1 -1
  87. package/dist/assets/{vendor-react-drawio-FVlWqKVE.js → vendor-react-drawio-ECtEwvbc.js} +1 -1
  88. package/dist/assets/{vendor-reactuses-core-C2A3EjUi.js → vendor-reactuses-core-B8parU1Y.js} +1 -1
  89. package/dist/assets/{vendor-sonner-DNqHmUQs.js → vendor-sonner-DVzioF3d.js} +1 -1
  90. package/dist/assets/{vendor-stream-diffs-CwbKsmlv.js → vendor-stream-diffs-DWy3xSmp.js} +2 -2
  91. package/dist/assets/{vendor-tanstack-react-query-D0iFK57C.js → vendor-tanstack-react-query-D7pys_c9.js} +1 -1
  92. package/dist/assets/{vendor-tanstack-react-virtual-B2kV6cnT.js → vendor-tanstack-react-virtual-tlDgH6PG.js} +1 -1
  93. package/dist/assets/{vendor-zustand-BqLKf6ZY.js → vendor-zustand-B4dlGEM2.js} +1 -1
  94. package/dist/assets/zh-CN-Z_bkUQk5.js +15 -0
  95. package/dist/assets/zh-TW-CZeTm7kd.js +15 -0
  96. package/dist/index.html +13 -13
  97. package/dist/mini-chat.html +13 -13
  98. package/dist/mobile.html +13 -13
  99. package/package.json +3 -2
  100. package/server/index.js +38 -7
  101. package/server/lib/browser-provider/DOCUMENTATION.md +141 -0
  102. package/server/lib/browser-provider/contract.js +312 -0
  103. package/server/lib/browser-provider/host.js +421 -0
  104. package/server/lib/browser-provider/host.test.js +304 -0
  105. package/server/lib/browser-provider/routes.js +122 -0
  106. package/server/lib/browser-provider/routes.test.js +120 -0
  107. package/server/lib/browser-provider/selection-store.js +43 -0
  108. package/server/lib/browser-provider/selection-store.test.js +35 -0
  109. package/server/lib/notifications/DOCUMENTATION.md +3 -1
  110. package/server/lib/notifications/runtime.js +110 -26
  111. package/server/lib/notifications/runtime.test.js +92 -0
  112. package/server/lib/notifications/template-runtime.js +130 -38
  113. package/server/lib/notifications/template-runtime.test.js +96 -0
  114. package/server/lib/opencode/DOCUMENTATION.md +4 -0
  115. package/server/lib/opencode/ensure-cli.d.ts +8 -0
  116. package/server/lib/opencode/feature-routes-runtime.js +10 -0
  117. package/server/lib/opencode/feature-routes-runtime.test.js +7 -0
  118. package/server/lib/opencode/proxy.js +3 -1
  119. package/server/lib/opencode/proxy.test.js +3 -3
  120. package/server/lib/opencode/routes.js +30 -0
  121. package/server/lib/opencode/upgrade-screen.d.ts +30 -0
  122. package/server/lib/opencode/upgrade-screen.js +299 -0
  123. package/server/lib/opencode/upgrade-screen.test.js +196 -0
  124. package/server/lib/session-metadata/DOCUMENTATION.md +27 -17
  125. package/server/lib/session-metadata/opencode-session-record.d.ts +57 -0
  126. package/server/lib/session-metadata/opencode-session-record.js +316 -0
  127. package/server/lib/session-metadata/opencode-session-record.test.js +242 -0
  128. package/server/lib/session-metadata/routes.js +5 -1
  129. package/server/lib/session-metadata/session-archive.js +1 -1
  130. package/server/lib/session-metadata/session-metadata-store.d.ts +2 -0
  131. package/server/lib/session-metadata/session-metadata-store.js +63 -16
  132. package/server/lib/session-metadata/session-projection.js +26 -2
  133. package/dist/assets/Icon-AQPvxMiU.js +0 -1
  134. package/dist/assets/MobileSurface-xuc_z0_G.js +0 -2
  135. package/dist/assets/SettingsView-CJ6tcH5_.js +0 -25
  136. package/dist/assets/TerminalView-Dvr-ysTS.js +0 -8
  137. package/dist/assets/collapsible-ZmZ6rLWs.js +0 -3
  138. package/dist/assets/context-menu-B2LXJgXL.js +0 -1
  139. package/dist/assets/es-CN3E_zXj.js +0 -15
  140. package/dist/assets/fr-9zZvQZD2.js +0 -13
  141. package/dist/assets/insertionBoundaries-BiUCSkx-.js +0 -1
  142. package/dist/assets/ja-Dnjk_82N.js +0 -15
  143. package/dist/assets/ko-BxCEE99C.js +0 -15
  144. package/dist/assets/main-C9mIBo77.js +0 -2
  145. package/dist/assets/main-DiL6w6KF.js +0 -169
  146. package/dist/assets/main-qSeeJRW1.css +0 -1
  147. package/dist/assets/markdownTheme-CKac3wKC.js +0 -1
  148. package/dist/assets/miniChat-CwtUaElU.js +0 -2
  149. package/dist/assets/mobile-DB-6--ws.js +0 -2
  150. package/dist/assets/multirun-TAsxFI8e.js +0 -4
  151. package/dist/assets/persistence-56kq8Z4V.js +0 -3
  152. package/dist/assets/pl-CNNsWnPM.js +0 -15
  153. package/dist/assets/platform-C13fMVEs.js +0 -6
  154. package/dist/assets/pt-BR-BZB2DJMp.js +0 -15
  155. package/dist/assets/renderElectronMiniChatApp-CGj0MNRU.js +0 -2
  156. package/dist/assets/runtimeEndpointReset-A_ZBUJFS.js +0 -147
  157. package/dist/assets/src-ubCWXZhR.css +0 -2
  158. package/dist/assets/store-DLCiZaA6.js +0 -15
  159. package/dist/assets/uk-rwQrxeYM.js +0 -15
  160. package/dist/assets/useDesktopWindowControlsLayout-BlizAlOt.js +0 -1
  161. package/dist/assets/useMobileNavigationStore-DfPClaW-.js +0 -414
  162. package/dist/assets/useSessionAutoCleanup-DtrfbxAp.js +0 -2
  163. package/dist/assets/useUIStore-CQYKJnrg.js +0 -1
  164. package/dist/assets/vendor-opencode-client-BOe2qpQb.js +0 -12
  165. package/dist/assets/zh-CN-BvmBDnkD.js +0 -15
  166. package/dist/assets/zh-TW-9wcHTX0y.js +0 -15
@@ -0,0 +1,304 @@
1
+ import http from 'node:http';
2
+
3
+ import { describe, expect, test } from 'vitest';
4
+
5
+ import {
6
+ BROWSER_PROVIDER_PATH,
7
+ extensionDeclaresBrowser,
8
+ readBrowserProviderRequest,
9
+ } from './contract.js';
10
+ import { BrowserProviderError } from './contract.js';
11
+ import { createBrowserProviderHost } from './host.js';
12
+
13
+ const declaring = (overrides = {}) => ({
14
+ id: 'server-chrome',
15
+ name: 'Server Chrome',
16
+ service: { provides: ['browser'] },
17
+ answer: async () => ({ ok: true, data: { url: 'http://127.0.0.1/', title: 'App' } }),
18
+ ...overrides,
19
+ });
20
+
21
+ describe('extensionDeclaresBrowser', () => {
22
+ test('recognizes a browser declaration and ignores other roles', () => {
23
+ expect(extensionDeclaresBrowser({ service: { provides: ['browser'] } })).toBe(true);
24
+ expect(extensionDeclaresBrowser({ provides: ['browser'] })).toBe(true);
25
+ expect(extensionDeclaresBrowser({ contributes: { service: { provides: ['browser'] } } })).toBe(true);
26
+ expect(extensionDeclaresBrowser({ service: { provides: ['printer'] } })).toBe(false);
27
+ expect(extensionDeclaresBrowser({ service: { provides: [] } })).toBe(false);
28
+ expect(extensionDeclaresBrowser({ service: {} })).toBe(false);
29
+ expect(extensionDeclaresBrowser(null)).toBe(false);
30
+ expect(extensionDeclaresBrowser({ service: { provides: ['printer'] }, provides: ['browser'] })).toBe(false);
31
+ });
32
+ });
33
+
34
+ describe('browser provider host', () => {
35
+ test('rejects an extension that does not declare browser and does not list it', () => {
36
+ const host = createBrowserProviderHost();
37
+ expect(() => host.install({ id: 'other', name: 'Other', answer: async () => ({ ok: true, data: {} }) }))
38
+ .toThrow(BrowserProviderError);
39
+ return expect(host.list()).resolves.toEqual([]);
40
+ });
41
+
42
+ test('lists a reachable browser declaration and hides the transport', async () => {
43
+ const host = createBrowserProviderHost();
44
+ const answer = async () => ({ ok: true, data: { url: 'http://a/' } });
45
+ expect(host.install(declaring({ answer, endpoint: 'http://example.com/steal' }))).toEqual({
46
+ id: 'server-chrome',
47
+ name: 'Server Chrome',
48
+ surface: false,
49
+ });
50
+ const providers = await host.list();
51
+ expect(providers).toEqual([{ id: 'server-chrome', name: 'Server Chrome', surface: false }]);
52
+ expect(JSON.stringify(providers)).not.toContain('endpoint');
53
+ expect(JSON.stringify(providers)).not.toContain('answer');
54
+ });
55
+
56
+ test('refuses a non-loopback endpoint and does not register it', async () => {
57
+ const host = createBrowserProviderHost();
58
+ expect(() => host.install(declaring({
59
+ answer: undefined,
60
+ endpoint: 'http://example.com/browser-control',
61
+ }))).toThrow(/loopback/);
62
+ await expect(host.list()).resolves.toEqual([]);
63
+ });
64
+
65
+ test('sends the agent action to the selected provider and adopts its data', async () => {
66
+ const seen = [];
67
+ const host = createBrowserProviderHost({ createId: () => 'req-1' });
68
+ host.install(declaring({
69
+ answer: async (request) => {
70
+ seen.push(request);
71
+ return { ok: true, data: { url: 'http://127.0.0.1/', title: 'App', clicked: '#save' } };
72
+ },
73
+ }));
74
+ await host.select('server-chrome');
75
+ const result = await host.dispatchAgentBrowserAction({
76
+ action: 'browser.click',
77
+ parameters: { selector: '#save', extra: 'drop-me' },
78
+ context: { directory: '/repo', sessionId: 'ses_1' },
79
+ });
80
+ expect(result).toEqual({
81
+ providerId: 'server-chrome',
82
+ data: { url: 'http://127.0.0.1/', title: 'App', clicked: '#save' },
83
+ });
84
+ expect(seen).toEqual([{
85
+ requestId: 'req-1',
86
+ action: 'browser.click',
87
+ parameters: { selector: '#save' },
88
+ context: { directory: '/repo', sessionId: 'ses_1' },
89
+ }]);
90
+ expect(readBrowserProviderRequest(JSON.stringify(seen[0]))).toEqual(seen[0]);
91
+ });
92
+
93
+ test('posts to the extension loopback and adopts that result', async () => {
94
+ const seen = [];
95
+ const server = http.createServer((req, res) => {
96
+ const chunks = [];
97
+ req.on('data', (chunk) => chunks.push(chunk));
98
+ req.on('end', () => {
99
+ seen.push({ method: req.method, url: req.url, body: JSON.parse(Buffer.concat(chunks).toString('utf8')) });
100
+ res.writeHead(200, { 'content-type': 'application/json' });
101
+ res.end(JSON.stringify({ ok: true, data: { url: 'http://127.0.0.1/', title: 'Loop' } }));
102
+ });
103
+ });
104
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
105
+ const { port } = server.address();
106
+ try {
107
+ const host = createBrowserProviderHost({ createId: () => 'req-loop' });
108
+ host.install(declaring({
109
+ answer: undefined,
110
+ endpoint: `http://127.0.0.1:${port}`,
111
+ }));
112
+ await host.select('server-chrome');
113
+ const result = await host.dispatchAgentBrowserAction({
114
+ action: 'browser.open',
115
+ parameters: { url: 'http://127.0.0.1:3000/app' },
116
+ context: { directory: '', sessionId: 7 },
117
+ });
118
+ expect(result.data).toEqual({ url: 'http://127.0.0.1/', title: 'Loop' });
119
+ expect(seen[0].method).toBe('POST');
120
+ expect(seen[0].url).toBe(BROWSER_PROVIDER_PATH);
121
+ expect(seen[0].body.context).toEqual({ directory: null, sessionId: null });
122
+ expect(seen[0].body.action).toBe('browser.open');
123
+ } finally {
124
+ await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
125
+ }
126
+ });
127
+
128
+ test('uses the extension error and does not turn it into empty data', async () => {
129
+ const host = createBrowserProviderHost();
130
+ host.install(declaring({
131
+ answer: async () => ({ ok: false, error: 'No element matches #save' }),
132
+ }));
133
+ await host.select('server-chrome');
134
+ await expect(host.dispatchAgentBrowserAction({
135
+ action: 'browser.click',
136
+ parameters: { selector: '#save' },
137
+ })).rejects.toMatchObject({
138
+ code: 'PROVIDER_REJECTED',
139
+ status: 400,
140
+ message: 'No element matches #save',
141
+ });
142
+ });
143
+
144
+ test('a malformed answer is unknown page state, not success', async () => {
145
+ const host = createBrowserProviderHost();
146
+ host.install(declaring({ answer: async () => ({ ok: true }) }));
147
+ await host.select('server-chrome');
148
+ await expect(host.dispatchAgentBrowserAction({ action: 'browser.snapshot', parameters: {} }))
149
+ .rejects.toMatchObject({ code: 'UNKNOWN_RESULT', status: 502 });
150
+ });
151
+
152
+ test('no selected provider rejects and does not call the extension', async () => {
153
+ const seen = [];
154
+ const host = createBrowserProviderHost();
155
+ host.install(declaring({
156
+ answer: async () => {
157
+ seen.push('called');
158
+ return { ok: true, data: {} };
159
+ },
160
+ }));
161
+ await expect(host.dispatchAgentBrowserAction({ action: 'browser.snapshot', parameters: {} }))
162
+ .rejects.toMatchObject({ code: 'NO_PROVIDER', status: 409 });
163
+ expect(seen).toEqual([]);
164
+ });
165
+
166
+ test('an unknown selection does not change the current choice or run an action', async () => {
167
+ const host = createBrowserProviderHost();
168
+ host.install(declaring());
169
+ await host.select('server-chrome');
170
+ await expect(host.select('missing')).rejects.toMatchObject({ code: 'PROVIDER_NOT_FOUND', status: 404 });
171
+ expect(host.selectedId()).toBe('server-chrome');
172
+ await expect(host.dispatchAgentBrowserAction({
173
+ action: 'browser.snapshot',
174
+ parameters: {},
175
+ providerId: 'missing',
176
+ })).rejects.toMatchObject({ code: 'PROVIDER_NOT_FOUND' });
177
+ });
178
+
179
+ test('a catalog read failure is not an empty provider list and does not send the action', async () => {
180
+ const seen = [];
181
+ const host = createBrowserProviderHost({
182
+ readInstalled: async () => {
183
+ throw new Error('disk');
184
+ },
185
+ });
186
+ await expect(host.list()).rejects.toMatchObject({ code: 'CATALOG_UNAVAILABLE', status: 503 });
187
+ await expect(host.dispatchAgentBrowserAction({
188
+ action: 'browser.snapshot',
189
+ parameters: {},
190
+ providerId: 'server-chrome',
191
+ })).rejects.toMatchObject({ code: 'CATALOG_UNAVAILABLE' });
192
+ expect(seen).toEqual([]);
193
+ });
194
+
195
+ test('a non-array catalog is not treated as no providers', async () => {
196
+ const host = createBrowserProviderHost({
197
+ readInstalled: async () => /** @type {any} */ (null),
198
+ });
199
+ await expect(host.list()).rejects.toMatchObject({ code: 'CATALOG_UNAVAILABLE' });
200
+ });
201
+
202
+ test('invalid actions are not sent', async () => {
203
+ const seen = [];
204
+ const host = createBrowserProviderHost();
205
+ host.install(declaring({
206
+ answer: async () => {
207
+ seen.push('called');
208
+ return { ok: true, data: {} };
209
+ },
210
+ }));
211
+ await host.select('server-chrome');
212
+ await expect(host.dispatchAgentBrowserAction({ action: 'browser.explode', parameters: {} }))
213
+ .rejects.toMatchObject({ code: 'INVALID_ACTION' });
214
+ await expect(host.dispatchAgentBrowserAction({ action: 'browser.open', parameters: { url: 'file:///tmp/x' } }))
215
+ .rejects.toMatchObject({ code: 'INVALID_ACTION' });
216
+ expect(seen).toEqual([]);
217
+ });
218
+
219
+ test('a provider that throws after receiving the action is not reported as unchanged', async () => {
220
+ const host = createBrowserProviderHost();
221
+ host.install(declaring({
222
+ answer: async () => {
223
+ throw new Error('socket dropped');
224
+ },
225
+ }));
226
+ await host.select('server-chrome');
227
+ await expect(host.dispatchAgentBrowserAction({ action: 'browser.snapshot', parameters: {} }))
228
+ .rejects.toMatchObject({ code: 'REQUEST_FAILED', status: 504 });
229
+ });
230
+
231
+ test('a refused connection is not sent and is not success', async () => {
232
+ const host = createBrowserProviderHost({
233
+ fetchImpl: async () => {
234
+ const error = new TypeError('fetch failed');
235
+ error.cause = Object.assign(new Error('refused'), { code: 'ECONNREFUSED' });
236
+ throw error;
237
+ },
238
+ });
239
+ host.install(declaring({ answer: undefined, endpoint: 'http://127.0.0.1:9' }));
240
+ await host.select('server-chrome');
241
+ await expect(host.dispatchAgentBrowserAction({ action: 'browser.snapshot', parameters: {} }))
242
+ .rejects.toMatchObject({ code: 'PROVIDER_UNAVAILABLE', status: 503 });
243
+ });
244
+
245
+ test('an HTTP error answer is unknown page state', async () => {
246
+ const host = createBrowserProviderHost({
247
+ fetchImpl: async () => new Response('boom', { status: 500 }),
248
+ });
249
+ host.install(declaring({ answer: undefined, endpoint: 'http://127.0.0.1:9/browser-control' }));
250
+ await host.select('server-chrome');
251
+ await expect(host.dispatchAgentBrowserAction({ action: 'browser.back', parameters: {} }))
252
+ .rejects.toMatchObject({ code: 'UNKNOWN_RESULT', status: 502 });
253
+ });
254
+
255
+ test('a saved selection is used after restart and a failed save does not change it', async () => {
256
+ const writes = [];
257
+ let saved = 'server-chrome';
258
+ const store = {
259
+ read: async () => saved,
260
+ write: async (id) => {
261
+ writes.push(id);
262
+ if (id === 'other-chrome') throw new Error('disk');
263
+ saved = id;
264
+ },
265
+ };
266
+ const restored = createBrowserProviderHost({ selectionStore: store });
267
+ restored.install(declaring());
268
+ await expect(restored.dispatchAgentBrowserAction({
269
+ action: 'browser.snapshot',
270
+ parameters: {},
271
+ })).resolves.toMatchObject({ providerId: 'server-chrome' });
272
+
273
+ restored.install(declaring({ id: 'other-chrome', name: 'Other Chrome' }));
274
+ await expect(restored.select('other-chrome')).rejects.toMatchObject({ code: 'SELECTION_UNAVAILABLE' });
275
+ expect(restored.selectedId()).toBe('server-chrome');
276
+ expect(writes).toEqual(['other-chrome']);
277
+ await expect(restored.catalog()).resolves.toMatchObject({ selectedId: 'server-chrome' });
278
+ });
279
+
280
+ test('a saved id that is not installed is not reported as the connected provider', async () => {
281
+ const host = createBrowserProviderHost({
282
+ selectionStore: { read: async () => 'server-chrome', write: async () => undefined },
283
+ });
284
+ await expect(host.catalog()).resolves.toEqual({ providers: [], selectedId: null });
285
+ await expect(host.dispatchAgentBrowserAction({ action: 'browser.snapshot', parameters: {} }))
286
+ .rejects.toMatchObject({ code: 'PROVIDER_NOT_FOUND' });
287
+ });
288
+
289
+ test('a cancelled action is not sent', async () => {
290
+ const seen = [];
291
+ const host = createBrowserProviderHost();
292
+ host.install(declaring({
293
+ answer: async () => {
294
+ seen.push('called');
295
+ return { ok: true, data: {} };
296
+ },
297
+ }));
298
+ await host.select('server-chrome');
299
+ const signal = AbortSignal.abort();
300
+ await expect(host.dispatchAgentBrowserAction({ action: 'browser.back', parameters: {}, signal }))
301
+ .rejects.toMatchObject({ code: 'CANCELLED' });
302
+ expect(seen).toEqual([]);
303
+ });
304
+ });
@@ -0,0 +1,122 @@
1
+ /**
2
+ * HTTP contract for the browser-provider host.
3
+ *
4
+ * These routes are not in the common JSON allowlist, so each mutating route
5
+ * parses its own body. A missing parser would turn every action into a
6
+ * validation failure and hide that the provider was never asked.
7
+ *
8
+ * List failure is never `{ providers: [] }`. Action failure is never
9
+ * `{ ok: true, data: {} }`.
10
+ */
11
+
12
+ import express from 'express';
13
+
14
+ import {
15
+ BROWSER_PROVIDER_ACTIONS_ROUTE,
16
+ BROWSER_PROVIDER_SELECTION_ROUTE,
17
+ BROWSER_PROVIDERS_ROUTE,
18
+ BrowserProviderError,
19
+ } from './contract.js';
20
+
21
+ const parseSelection = express.json({ limit: '4kb' });
22
+ const parseAction = express.json({ limit: '64kb' });
23
+
24
+ const readJson = (parser, invalid) => (req, res, next) => {
25
+ parser(req, res, (error) => {
26
+ if (!error) {
27
+ next();
28
+ return;
29
+ }
30
+ const status = error.status || error.statusCode || 400;
31
+ res.status(status).json(invalid);
32
+ });
33
+ };
34
+
35
+ const catalogError = (res, error) => {
36
+ if (error instanceof BrowserProviderError) {
37
+ res.status(error.status).json({ error: error.message, code: error.code });
38
+ return;
39
+ }
40
+ res.status(503).json({
41
+ error: 'OpenChamber could not read the browser provider catalog.',
42
+ code: 'CATALOG_UNAVAILABLE',
43
+ });
44
+ };
45
+
46
+ const actionError = (res, error) => {
47
+ if (error instanceof BrowserProviderError) {
48
+ res.status(error.status).json({ ok: false, code: error.code, error: error.message });
49
+ return;
50
+ }
51
+ res.status(500).json({
52
+ ok: false,
53
+ code: 'PROVIDER_UNAVAILABLE',
54
+ error: 'The browser action was not completed. Nothing is known about the page.',
55
+ });
56
+ };
57
+
58
+ /**
59
+ * @param {import('express').Express} app
60
+ * @param {{ host: { list: Function, selectedId: Function, select: Function, dispatchAgentBrowserAction: Function } }} deps
61
+ */
62
+ export const registerBrowserProviderRoutes = (app, { host }) => {
63
+ if (!host || typeof host.catalog !== 'function' || typeof host.dispatchAgentBrowserAction !== 'function') {
64
+ throw new Error('registerBrowserProviderRoutes requires a browser provider host');
65
+ }
66
+
67
+ app.get(BROWSER_PROVIDERS_ROUTE, async (_req, res) => {
68
+ try {
69
+ res.json(await host.catalog());
70
+ } catch (error) {
71
+ catalogError(res, error);
72
+ }
73
+ });
74
+
75
+ app.put(BROWSER_PROVIDER_SELECTION_ROUTE, readJson(parseSelection, {
76
+ error: 'The selection body could not be read. The selection was not changed.',
77
+ code: 'INVALID_SELECTION',
78
+ }), async (req, res) => {
79
+ const body = req.body;
80
+ if (!body || typeof body !== 'object' || Array.isArray(body) || typeof body.id !== 'string') {
81
+ res.status(400).json({
82
+ error: 'id is required and must be a browser provider id. The selection was not changed.',
83
+ code: 'INVALID_SELECTION',
84
+ });
85
+ return;
86
+ }
87
+ try {
88
+ const selectedId = await host.select(body.id);
89
+ res.json({ selectedId });
90
+ } catch (error) {
91
+ catalogError(res, error);
92
+ }
93
+ });
94
+
95
+ app.post(BROWSER_PROVIDER_ACTIONS_ROUTE, readJson(parseAction, {
96
+ ok: false,
97
+ code: 'INVALID_ACTION',
98
+ error: 'The action body could not be read. Nothing was sent.',
99
+ }), async (req, res) => {
100
+ const controller = new AbortController();
101
+ const onClose = () => {
102
+ if (!res.writableEnded) controller.abort();
103
+ };
104
+ res.on('close', onClose);
105
+ try {
106
+ const body = req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? req.body : {};
107
+ const result = await host.dispatchAgentBrowserAction({
108
+ action: body.action,
109
+ parameters: body.parameters,
110
+ context: body.context,
111
+ providerId: body.providerId,
112
+ signal: controller.signal,
113
+ });
114
+ res.json({ ok: true, providerId: result.providerId, data: result.data });
115
+ } catch (error) {
116
+ if (res.headersSent || res.writableEnded) return;
117
+ actionError(res, error);
118
+ } finally {
119
+ res.off('close', onClose);
120
+ }
121
+ });
122
+ };
@@ -0,0 +1,120 @@
1
+ import express from 'express';
2
+ import request from 'supertest';
3
+ import { describe, expect, test } from 'vitest';
4
+
5
+ import { createBrowserProviderHost } from './host.js';
6
+ import { registerBrowserProviderRoutes } from './routes.js';
7
+
8
+ const createApp = (host = createBrowserProviderHost()) => {
9
+ const app = express();
10
+ registerBrowserProviderRoutes(app, { host });
11
+ return { app, host };
12
+ };
13
+
14
+ describe('browser provider routes', () => {
15
+ test('an empty catalog is an authoritative empty list, not a failure', async () => {
16
+ const { app } = createApp();
17
+ const response = await request(app).get('/api/browser-providers');
18
+ expect(response.status).toBe(200);
19
+ expect(response.body).toEqual({ providers: [], selectedId: null });
20
+ });
21
+
22
+ test('a catalog read failure is not an empty successful list', async () => {
23
+ const host = createBrowserProviderHost({
24
+ readInstalled: async () => {
25
+ throw new Error('disk');
26
+ },
27
+ });
28
+ const { app } = createApp(host);
29
+ const response = await request(app).get('/api/browser-providers');
30
+ expect(response.status).toBe(503);
31
+ expect(response.body.code).toBe('CATALOG_UNAVAILABLE');
32
+ expect(response.body.providers).toBeUndefined();
33
+ });
34
+
35
+ test('selects a provider and sends one action to it', async () => {
36
+ const { app, host } = createApp();
37
+ host.install({
38
+ id: 'server-chrome',
39
+ name: 'Server Chrome',
40
+ service: { provides: ['browser'] },
41
+ answer: async (action) => ({
42
+ ok: true,
43
+ data: { echoed: action.action, selector: action.parameters.selector },
44
+ }),
45
+ });
46
+
47
+ const selected = await request(app)
48
+ .put('/api/browser-providers/selection')
49
+ .send({ id: 'server-chrome' });
50
+ expect(selected.status).toBe(200);
51
+ expect(selected.body).toEqual({ selectedId: 'server-chrome' });
52
+
53
+ const acted = await request(app)
54
+ .post('/api/browser-providers/actions')
55
+ .send({
56
+ action: 'browser.click',
57
+ parameters: { selector: '#save' },
58
+ context: { directory: '/repo', sessionId: 'ses_1' },
59
+ });
60
+ expect(acted.status).toBe(200);
61
+ expect(acted.body).toEqual({
62
+ ok: true,
63
+ providerId: 'server-chrome',
64
+ data: { echoed: 'browser.click', selector: '#save' },
65
+ });
66
+ });
67
+
68
+ test('no provider is an explicit refusal, not an empty success', async () => {
69
+ const { app } = createApp();
70
+ const response = await request(app)
71
+ .post('/api/browser-providers/actions')
72
+ .send({ action: 'browser.snapshot', parameters: {} });
73
+ expect(response.status).toBe(409);
74
+ expect(response.body.ok).toBe(false);
75
+ expect(response.body.code).toBe('NO_PROVIDER');
76
+ expect(response.body.data).toBeUndefined();
77
+ });
78
+
79
+ test('an unknown selection is not stored', async () => {
80
+ const { app, host } = createApp();
81
+ const response = await request(app)
82
+ .put('/api/browser-providers/selection')
83
+ .send({ id: 'missing' });
84
+ expect(response.status).toBe(404);
85
+ expect(response.body.code).toBe('PROVIDER_NOT_FOUND');
86
+ expect(host.selectedId()).toBeNull();
87
+ });
88
+
89
+ test('a provider rejection is not ok data', async () => {
90
+ const { app, host } = createApp();
91
+ host.install({
92
+ id: 'server-chrome',
93
+ name: 'Server Chrome',
94
+ provides: ['browser'],
95
+ answer: async () => ({ ok: false, error: 'No element matches #save' }),
96
+ });
97
+ await host.select('server-chrome');
98
+ const response = await request(app)
99
+ .post('/api/browser-providers/actions')
100
+ .send({ action: 'browser.click', parameters: { selector: '#save' } });
101
+ expect(response.status).toBe(400);
102
+ expect(response.body).toEqual({
103
+ ok: false,
104
+ code: 'PROVIDER_REJECTED',
105
+ error: 'No element matches #save',
106
+ });
107
+ });
108
+
109
+ test('invalid JSON is not sent and is not success', async () => {
110
+ const { app } = createApp();
111
+ const response = await request(app)
112
+ .post('/api/browser-providers/actions')
113
+ .set('content-type', 'application/json')
114
+ .send('{');
115
+ expect(response.status).toBe(400);
116
+ expect(response.body.ok).toBe(false);
117
+ expect(response.body.code).toBe('INVALID_ACTION');
118
+ expect(response.body.data).toBeUndefined();
119
+ });
120
+ });
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Persists only the selected provider id. A missing file is no selection.
3
+ * A read that fails is not "nothing selected": the caller must not overwrite
4
+ * the file or treat the catalog as an empty success.
5
+ */
6
+
7
+ import fs from 'node:fs/promises';
8
+ import path from 'node:path';
9
+
10
+ import { readBrowserProviderId } from './contract.js';
11
+
12
+ /**
13
+ * @param {string} filePath
14
+ */
15
+ export const createBrowserProviderSelectionStore = (filePath) => ({
16
+ async read() {
17
+ let raw;
18
+ try {
19
+ raw = await fs.readFile(filePath, 'utf8');
20
+ } catch (error) {
21
+ if (error?.code === 'ENOENT') return null;
22
+ throw error;
23
+ }
24
+ let parsed;
25
+ try {
26
+ parsed = JSON.parse(raw);
27
+ } catch (error) {
28
+ throw error;
29
+ }
30
+ return readBrowserProviderId(parsed?.selectedId);
31
+ },
32
+
33
+ async write(id) {
34
+ const selectedId = readBrowserProviderId(id);
35
+ if (!selectedId) {
36
+ throw new Error('browser provider selection id is invalid');
37
+ }
38
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
39
+ const temp = `${filePath}.${process.pid}.tmp`;
40
+ await fs.writeFile(temp, `${JSON.stringify({ selectedId })}\n`, 'utf8');
41
+ await fs.rename(temp, filePath);
42
+ },
43
+ });
@@ -0,0 +1,35 @@
1
+ import fs from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ import { afterEach, describe, expect, test } from 'vitest';
6
+
7
+ import { createBrowserProviderSelectionStore } from './selection-store.js';
8
+
9
+ const dirs = [];
10
+
11
+ afterEach(async () => {
12
+ await Promise.all(dirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
13
+ });
14
+
15
+ describe('browser provider selection store', () => {
16
+ test('a missing file is no selection, and a write round-trips only the id', async () => {
17
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'browser-provider-'));
18
+ dirs.push(dir);
19
+ const store = createBrowserProviderSelectionStore(path.join(dir, 'nested', 'browser-provider-selection.json'));
20
+ await expect(store.read()).resolves.toBeNull();
21
+ await store.write('server-chrome');
22
+ await expect(store.read()).resolves.toBe('server-chrome');
23
+ const raw = await fs.readFile(path.join(dir, 'nested', 'browser-provider-selection.json'), 'utf8');
24
+ expect(JSON.parse(raw)).toEqual({ selectedId: 'server-chrome' });
25
+ });
26
+
27
+ test('a corrupt file is a read failure, not an empty selection', async () => {
28
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'browser-provider-'));
29
+ dirs.push(dir);
30
+ const filePath = path.join(dir, 'browser-provider-selection.json');
31
+ await fs.writeFile(filePath, '{', 'utf8');
32
+ const store = createBrowserProviderSelectionStore(filePath);
33
+ await expect(store.read()).rejects.toThrow();
34
+ });
35
+ });
@@ -107,7 +107,9 @@ This module provides notification message preparation utilities for the web serv
107
107
  - `extractLastMessageText(payload, maxLength?)`
108
108
  - `fetchLastAssistantMessageText(sessionId, messageId, maxLength?)`
109
109
  - `maybeCacheSessionInfoFromEvent(payload)`
110
- - `buildTemplateVariables(payload, sessionId)`
110
+ - `buildTemplateVariables(payload, sessionId)` — `session_name` is the session title. OpenCode 2 returns that title on `GET /api/session/:id` as `{ data: { title } }` and on `session.created` / `session.renamed` as `data.title`. Legacy `properties.info.title` still counts. An empty title stays empty so push can fall back to the localized "Session" label. `agent_name` / `model_name` fall back to that same session record when the triggering event has none. `fetchLastAssistantMessageText` accepts a legacy message array and an OpenCode 2 `{ data }` page (`order=desc`).
111
+ - Question prompts come from legacy `question.asked` or OpenCode 2 `form.created` (`data.form`). Permission prompts read `permission.asked` from `properties` or `data` (`action`, `message`, `id`). `form.replied` / `form.cancelled` cancel a pending question notification the same way `permission.replied` cancels a permission notification.
112
+ - iOS APNs and Android FCM share this payload: native banners stay scenario title + session name. Desktop, in-app, and web-push use the resolved template title and body.
111
113
  - `getCachedZenModels()`
112
114
 
113
115
  ## Constants