@vantran-se/openchamber-web 1.22.1 → 1.22.2

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 (163) hide show
  1. package/README.md +2 -3
  2. package/bin/cli.js +4 -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-CV1HNV5l.js} +1 -1
  7. package/dist/assets/{DiffView-Dh1ey2x9.js → DiffView-DkkSa30n.js} +3 -3
  8. package/dist/assets/DirtyBranchSwitchDialog-DW4WaXPc.js +1 -0
  9. package/dist/assets/FilesView-DDu8zDy5.js +17 -0
  10. package/dist/assets/GitView-1Ixo8z-M.js +6 -0
  11. package/dist/assets/{JsonTreeView-Bl0lp9Rs.js → JsonTreeView-DvCLL7dT.js} +1 -1
  12. package/dist/assets/LinearIssuesView-Bs1rZrF2.js +1 -0
  13. package/dist/assets/{MarkdownImageGallery-bA0PS-tn.js → MarkdownImageGallery-DyJUc6h9.js} +1 -1
  14. package/dist/assets/MarkdownRendererImpl-Bdm8vMkS.js +12 -0
  15. package/dist/assets/{OnboardingScreen-D3kYJIb6.js → OnboardingScreen-Dg66B6oB.js} +1 -1
  16. package/dist/assets/{PatchDiff-D6NXAfJk.js → PatchDiff-CAduNjfZ.js} +1 -1
  17. package/dist/assets/{PierreDiffViewer-Ct2A13NO.js → PierreDiffViewer-CTIb1GYD.js} +2 -2
  18. package/dist/assets/PlanView-trDaszfq.js +5 -0
  19. package/dist/assets/SettingsView-DStMfBmy.js +28 -0
  20. package/dist/assets/{SettingsWindow-BiWDBRvb.js → SettingsWindow-DQQFnrSZ.js} +1 -1
  21. package/dist/assets/{ToolOutputDialog-BQexMQIm.js → ToolOutputDialog-SjY2XZlA.js} +6 -6
  22. package/dist/assets/ToolPartDiffPreview-DJdzUQPE.js +8 -0
  23. package/dist/assets/{VirtualizedFileDiff-Dpz2ra8h.js → VirtualizedFileDiff-Iehm4iMH.js} +1 -1
  24. package/dist/assets/{WalkthroughView-AX-u2pFg.js → WalkthroughView-BALp2d9O.js} +2 -2
  25. package/dist/assets/{computeVirtualFileMetrics-DtyozJdt.js → computeVirtualFileMetrics-BKZA43vN.js} +2 -2
  26. package/dist/assets/{contextPanelEmbeddedChat-C9EcRSDF.js → contextPanelEmbeddedChat-D2PM_pEv.js} +1 -1
  27. package/dist/assets/{de-DqVn5vme.js → de-DlSNLAcy.js} +6 -5
  28. package/dist/assets/{es-hj43Z53i.js → es-D1GbowZ3.js} +6 -5
  29. package/dist/assets/{fr-BmkZt2mF.js → fr-BPOLXIAY.js} +6 -5
  30. package/dist/assets/{index-DmhujXbT.js → index-Ce0LR32V.js} +1 -1
  31. package/dist/assets/index-D42fPvt4.css +1 -0
  32. package/dist/assets/index-DlFbCkEy.js +39 -0
  33. package/dist/assets/{isDiffPlainText-XyulMHxx.js → isDiffPlainText-DaB2u_Kv.js} +1 -1
  34. package/dist/assets/{isFilePlainText-B19Ddps1.js → isFilePlainText-yJZa2IAC.js} +1 -1
  35. package/dist/assets/{ja-DioMIEGF.js → ja-Dm2-m9io.js} +7 -6
  36. package/dist/assets/{ko-BqPgaQJb.js → ko-CIXlzQ5-.js} +7 -6
  37. package/dist/assets/main-B7qTsd_w.js +930 -0
  38. package/dist/assets/{main-B2a_JfKZ.js → main-CyQrD9dG.js} +2 -2
  39. package/dist/assets/{markdownCore-CWRCqSH2.js → markdownCore-CeEwGgEK.js} +2 -2
  40. package/dist/assets/{miniChat-B3CSU_Ps.js → miniChat-7cPEx4sV.js} +2 -2
  41. package/dist/assets/{mobile-QNcFIVCW.js → mobile-QtzkPTM8.js} +2 -2
  42. package/dist/assets/{patchFileDiff-BpMQZnYu.js → patchFileDiff-No55fDiD.js} +1 -1
  43. package/dist/assets/pl-B2zmkOpw.js +16 -0
  44. package/dist/assets/{pt-BR-BqH8fuCH.js → pt-BR-C-JE5H6y.js} +6 -5
  45. package/dist/assets/{renderElectronMiniChatApp-D77bmLqo.js → renderElectronMiniChatApp-ePTkBJNS.js} +2 -2
  46. package/dist/assets/renderMobileApp-D9RjB9DX.js +25 -0
  47. package/dist/assets/runtimeEndpointReset-DfJIWwwo.js +17 -0
  48. package/dist/assets/{scheduledTasksApi-BSRCtP6T.js → scheduledTasksApi-DuhdyYEa.js} +1 -1
  49. package/dist/assets/{shikiHighlight-DE-OwqGw.js → shikiHighlight-EHEg58-n.js} +1 -1
  50. package/dist/assets/tr-MJmRIzEY.js +7 -0
  51. package/dist/assets/{uk-BkkfxbOs.js → uk-BmD7eM8k.js} +6 -5
  52. package/dist/assets/{useAppFontEffects-COAeIX-D.js → useAppFontEffects-CuJPKtI1.js} +151 -180
  53. package/dist/assets/useInlineCommentController-DD7_7OP_.js +2 -0
  54. package/dist/assets/{useRootScrollLock-C0N9mCwz.js → useRootScrollLock-DRGAe8PN.js} +1 -1
  55. package/dist/assets/{vendor-codemirror-language-data-BG3v1Ghq.js → vendor-codemirror-language-data-9ZzoH0o0.js} +2 -2
  56. package/dist/assets/vendor-zod-CK21ihK-.js +39 -0
  57. package/dist/assets/{web-components-BNMwa0qm.js → web-components-PttKBrhi.js} +1 -1
  58. package/dist/assets/{zh-CN-C6FMdVgv.js → zh-CN-JJHl4ehT.js} +6 -5
  59. package/dist/assets/{zh-TW-CdcMjDxv.js → zh-TW-DPc4DEEW.js} +6 -5
  60. package/dist/index.html +5 -5
  61. package/dist/mini-chat.html +4 -4
  62. package/dist/mobile.html +4 -4
  63. package/package.json +8 -4
  64. package/server/index.js +38 -5
  65. package/server/lib/agent-tool/runtime.js +2 -19
  66. package/server/lib/changelog/update-notes.js +82 -0
  67. package/server/lib/changelog/update-notes.test.js +59 -0
  68. package/server/lib/dev-tunnel/DOCUMENTATION.md +13 -4
  69. package/server/lib/dev-tunnel/runtime.js +4 -4
  70. package/server/lib/dev-tunnel/tunnel.test.js +20 -0
  71. package/server/lib/fs/DOCUMENTATION.md +2 -0
  72. package/server/lib/fs/routes.js +32 -21
  73. package/server/lib/fs/routes.test.js +77 -0
  74. package/server/lib/git/DOCUMENTATION.md +3 -1
  75. package/server/lib/git/routes.js +16 -0
  76. package/server/lib/git/service.js +107 -17
  77. package/server/lib/git/service.test.js +171 -0
  78. package/server/lib/mcp-reconnect/DOCUMENTATION.md +55 -0
  79. package/server/lib/mcp-reconnect/runtime.js +122 -0
  80. package/server/lib/mcp-reconnect/runtime.test.js +140 -0
  81. package/server/lib/message-queue/DOCUMENTATION.md +158 -0
  82. package/server/lib/message-queue/runtime.js +870 -0
  83. package/server/lib/message-queue/runtime.test.js +482 -0
  84. package/server/lib/openchamber-sessions/routes.js +102 -0
  85. package/server/lib/openchamber-sessions/routes.test.js +88 -0
  86. package/server/lib/opencode/DOCUMENTATION.md +13 -5
  87. package/server/lib/opencode/config-paths.test.js +116 -0
  88. package/server/lib/opencode/core-routes.js +1 -0
  89. package/server/lib/opencode/feature-routes-runtime.js +5 -0
  90. package/server/lib/opencode/input-history-scope.js +14 -0
  91. package/server/lib/opencode/managed-plugin-config.js +35 -0
  92. package/server/lib/opencode/openchamber-routes.test.js +2 -2
  93. package/server/lib/opencode/plugins.js +2 -2
  94. package/server/lib/opencode/providers.js +44 -2
  95. package/server/lib/opencode/providers.test.js +131 -4
  96. package/server/lib/opencode/routes.js +4 -4
  97. package/server/lib/opencode/server-startup-runtime.js +1 -1
  98. package/server/lib/opencode/settings-helpers.js +28 -0
  99. package/server/lib/opencode/settings-helpers.test.js +177 -0
  100. package/server/lib/opencode/settings-runtime.js +2 -2
  101. package/server/lib/opencode/settings-runtime.test.js +29 -0
  102. package/server/lib/opencode/shared.js +4 -1
  103. package/server/lib/opencode/shutdown-runtime.js +2 -0
  104. package/server/lib/opencode/skill-routes.js +2 -1
  105. package/server/lib/opencode/snippets.js +1 -2
  106. package/server/lib/package-manager.js +4 -29
  107. package/server/lib/package-manager.test.js +2 -2
  108. package/server/lib/quota/DOCUMENTATION.md +2 -1
  109. package/server/lib/quota/credentials/providers.js +4 -0
  110. package/server/lib/quota/credentials/store.js +1 -1
  111. package/server/lib/quota/credentials/store.test.js +4 -4
  112. package/server/lib/quota/providers/exe-dev.js +75 -0
  113. package/server/lib/quota/providers/exe-dev.test.js +47 -0
  114. package/server/lib/quota/providers/index.js +7 -0
  115. package/server/lib/quota/providers/opencode-go.js +1 -0
  116. package/server/lib/quota/providers/opencode-go.test.js +5 -1
  117. package/server/lib/quota/routes.js +2 -0
  118. package/server/lib/quota/utils/auth.js +1 -1
  119. package/server/lib/relay/DOCUMENTATION.md +1 -1
  120. package/server/lib/relay/host-client.test.js +9 -1
  121. package/server/lib/relay/tunnel-host.js +3 -1
  122. package/server/lib/relay/tunnel-host.test.js +9 -1
  123. package/server/lib/security/request-security.js +21 -6
  124. package/server/lib/security/request-security.test.js +42 -8
  125. package/server/lib/session-assist/runtime.js +1 -0
  126. package/server/lib/session-goal/DOCUMENTATION.md +20 -7
  127. package/server/lib/session-goal/create.js +3 -1
  128. package/server/lib/session-goal/runtime.js +77 -11
  129. package/server/lib/session-goal/runtime.test.js +426 -0
  130. package/server/lib/skills-catalog/DOCUMENTATION.md +1 -1
  131. package/server/lib/skills-catalog/install.js +3 -2
  132. package/server/lib/small-model/DOCUMENTATION.md +4 -0
  133. package/server/lib/small-model/call.js +8 -2
  134. package/server/lib/small-model/call.test.js +23 -0
  135. package/server/lib/small-model/index.js +2 -1
  136. package/server/lib/small-model/routes.js +2 -1
  137. package/server/lib/system-prompt/runtime.js +2 -21
  138. package/server/lib/terminal/DOCUMENTATION.md +11 -7
  139. package/server/lib/terminal/runtime.js +180 -26
  140. package/server/lib/terminal/runtime.test.js +553 -26
  141. package/server/lib/terminal/shells.js +17 -0
  142. package/server/lib/terminal/shells.test.js +32 -1
  143. package/server/lib/ui-auth/ui-auth.js +4 -1
  144. package/server/lib/ui-auth/ui-auth.test.js +16 -0
  145. package/server/lib/walkthrough/index.js +1 -0
  146. package/server/lib/walkthrough/routes.test.js +26 -5
  147. package/dist/assets/CommitSection-X-f2RgYr.js +0 -1
  148. package/dist/assets/FilesView-3ZYP72ki.js +0 -17
  149. package/dist/assets/GitView-CMuN7z2Z.js +0 -6
  150. package/dist/assets/LinearIssuesView-CQiUDU8b.js +0 -1
  151. package/dist/assets/MarkdownRendererImpl-BGQi2PLq.js +0 -12
  152. package/dist/assets/PlanView-6AUjzNQn.js +0 -5
  153. package/dist/assets/SettingsView-CHI8fFaK.js +0 -28
  154. package/dist/assets/ToolPartDiffPreview-DPAATNn6.js +0 -8
  155. package/dist/assets/index-L_ncV-6H.css +0 -1
  156. package/dist/assets/index-qES19dlJ.js +0 -7
  157. package/dist/assets/main-CYgSu6dx.js +0 -928
  158. package/dist/assets/pl-slULBF4R.js +0 -15
  159. package/dist/assets/renderMobileApp-BQ3Kdm-d.js +0 -25
  160. package/dist/assets/runtimeEndpointReset-CRGn1gVj.js +0 -16
  161. package/dist/assets/tr-CjEbw1Aw.js +0 -6
  162. package/dist/assets/useInlineCommentController-JsBboQwt.js +0 -2
  163. package/dist/assets/vendor-zod-BLfBSgBh.js +0 -39
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { fetchExeDevUsage, parseExeDevUsage } from './exe-dev.js';
3
+
4
+ const payload = {
5
+ allowance_spend_usd: 0.11,
6
+ extra_spend_usd: 0,
7
+ group: 'day',
8
+ month: '2026-09',
9
+ monthly_allowance_usd: 20,
10
+ period_end: '2026-10-01T00:00:00Z',
11
+ period_start: '2026-09-01T00:00:00Z',
12
+ total_cost_usd: 0.11,
13
+ total_requests: 17,
14
+ };
15
+
16
+ describe('exe.dev quota provider', () => {
17
+ it('parses monthly credit usage', () => {
18
+ const windows = parseExeDevUsage(payload);
19
+ expect(windows?.monthly.usedPercent).toBeCloseTo(0.55);
20
+ expect(windows?.monthly.valueLabel).toBe('$0.11 / $20.00');
21
+ expect(windows?.monthly.resetAt).toBe(Date.parse('2026-10-01T00:00:00Z'));
22
+ });
23
+
24
+ it('sends the scoped billing command without exposing the token elsewhere', async () => {
25
+ const requests = [];
26
+ const windows = await fetchExeDevUsage({ usageToken: 'test-token' }, async (url, init) => {
27
+ requests.push({ url, init });
28
+ return Response.json(payload);
29
+ });
30
+ expect(windows.monthly.valueLabel).toBe('$0.11 / $20.00');
31
+ expect(requests).toHaveLength(1);
32
+ expect(requests[0].url).toBe('https://exe.dev/exec');
33
+ expect(requests[0].init.method).toBe('POST');
34
+ expect(requests[0].init.body).toBe('billing credits usage --group=day --json');
35
+ expect(requests[0].init.headers.Authorization).toBe('Bearer test-token');
36
+ });
37
+
38
+ it('rejects malformed successful responses', async () => {
39
+ await expect(fetchExeDevUsage({ usageToken: 'test-token' }, async () => Response.json({})))
40
+ .rejects.toThrow('could not be parsed');
41
+ });
42
+
43
+ it('reports authentication failure without including the token', async () => {
44
+ await expect(fetchExeDevUsage({ usageToken: 'test-token' }, async () => new Response('', { status: 401 })))
45
+ .rejects.toThrow('exe.dev authentication failed');
46
+ });
47
+ });
@@ -13,6 +13,7 @@ import * as copilot from './copilot.js';
13
13
  import * as crof from './crof.js';
14
14
  import * as cursor from './cursor.js';
15
15
  import * as deepseek from './deepseek.js';
16
+ import * as exeDev from './exe-dev.js';
16
17
  import * as google from './google/index.js';
17
18
  import * as kimi from './kimi.js';
18
19
  import * as nanogpt from './nanogpt.js';
@@ -59,6 +60,12 @@ const registry = {
59
60
  isConfigured: deepseek.isConfigured,
60
61
  fetchQuota: deepseek.fetchQuota
61
62
  },
63
+ 'exe-dev': {
64
+ providerId: exeDev.providerId,
65
+ providerName: exeDev.providerName,
66
+ isConfigured: exeDev.isConfigured,
67
+ fetchQuota: exeDev.fetchQuota
68
+ },
62
69
  google: {
63
70
  providerId: google.providerId,
64
71
  providerName: google.providerName,
@@ -37,6 +37,7 @@ export const fetchOpenCodeGoUsage = async (apiKey, fetchImpl = fetch) => {
37
37
  headers: {
38
38
  Accept: 'application/json',
39
39
  Authorization: `Bearer ${apiKey}`,
40
+ 'x-opencode-session': 'openchamber-usage',
40
41
  'User-Agent': 'OpenChamber quota provider',
41
42
  },
42
43
  signal: AbortSignal.timeout(15_000),
@@ -43,7 +43,11 @@ describe('OpenCode Go quota provider', () => {
43
43
  return new Response(JSON.stringify({ usage: { rolling: { percent: 25, resetsAt: '2026-08-12T12:00:00.000Z' } } }));
44
44
  });
45
45
  expect(request.url).toBe('https://opencode.ai/zen/go/v1/usage');
46
- expect(request.options.headers).toMatchObject({ Accept: 'application/json', Authorization: 'Bearer secret' });
46
+ expect(request.options.headers).toMatchObject({
47
+ Accept: 'application/json',
48
+ Authorization: 'Bearer secret',
49
+ 'x-opencode-session': 'openchamber-usage',
50
+ });
47
51
  expect(request.options.headers.Cookie).toBeUndefined();
48
52
  expect(usage['5h'].usedPercent).toBe(25);
49
53
  });
@@ -2,8 +2,10 @@ import express from 'express';
2
2
  import { deleteManagedCredential, getManagedCredentialStatus, normalizers, readManagedCredential, writeManagedCredential } from './credentials/providers.js';
3
3
  import { fetchOllamaCloudUsage } from './providers/ollama-cloud.js';
4
4
  import { importCursorCredential, validateCursorCredential } from './providers/cursor.js';
5
+ import { fetchExeDevUsage } from './providers/exe-dev.js';
5
6
 
6
7
  const validators = {
8
+ 'exe-dev': fetchExeDevUsage,
7
9
  'ollama-cloud': fetchOllamaCloudUsage,
8
10
  cursor: validateCursorCredential,
9
11
  };
@@ -1,8 +1,8 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import os from 'os';
4
+ import { OPENCODE_CONFIG_DIR } from '../../opencode/shared.js';
4
5
 
5
- const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode');
6
6
  const OPENCODE_DATA_DIR = path.join(os.homedir(), '.local', 'share', 'opencode');
7
7
 
8
8
  export const ANTIGRAVITY_ACCOUNTS_PATHS = [
@@ -41,7 +41,7 @@ Relay is not a separate link format: it is one transport candidate inside the un
41
41
  Everything a client normally sends to the single OpenChamber origin:
42
42
  - **HTTP** — REST endpoints and proxied OpenCode SDK calls under `/api/*`, plus `/auth/*` and `/health`.
43
43
  - **SSE** — long-lived streamed responses (the event stream and notifications). These are just HTTP responses whose body streams; the tunnel needs no special SSE handling.
44
- - **WebSocket** — the endpoints that use a real socket (the global event stream on platforms that support WS, terminal I/O, dictation).
44
+ - **WebSocket** — the endpoints that use a real socket (the global event stream on platforms that support WS, terminal I/O, dictation, and desktop dev-server previews).
45
45
 
46
46
  The host dispatcher restricts tunneled traffic to explicit path allowlists (one for HTTP, one for WS).
47
47
 
@@ -98,6 +98,9 @@ const startFakeRelay = () => {
98
98
  wsUrl: `ws://127.0.0.1:${port}`,
99
99
  state,
100
100
  stop: () => new Promise((r) => {
101
+ // A socket a failed test left open would hold server.close() until
102
+ // the hook timeout; drop them so a failure is reported once.
103
+ for (const client of wss.clients) client.terminate();
101
104
  wss.close();
102
105
  server.close(() => r());
103
106
  }),
@@ -162,7 +165,6 @@ const runScriptedClient = async ({ relayUrl, serverId, hostEncPubJwk }) => {
162
165
  url.searchParams.set('role', 'client');
163
166
  url.searchParams.set('serverId', serverId);
164
167
  url.searchParams.set('connectionId', connectionId);
165
- const ws = new WebSocket(url.toString());
166
168
 
167
169
  const hostPub = await globalThis.crypto.subtle.importKey(
168
170
  'jwk',
@@ -182,6 +184,12 @@ const runScriptedClient = async ({ relayUrl, serverId, hostEncPubJwk }) => {
182
184
  resolveDone = resolve;
183
185
  });
184
186
 
187
+ // Dialed only now, with the key material ready and the listeners attached
188
+ // in the same tick. Dialing before the WebCrypto awaits above let a loopback
189
+ // socket open while the key generation was still queued on the threadpool,
190
+ // and an `open` event with no listener means no hello, no ready, and a
191
+ // client that waits forever. Loaded CI runners hit exactly that.
192
+ const ws = new WebSocket(url.toString());
185
193
  ws.on('open', async () => {
186
194
  ws.send(JSON.stringify({
187
195
  t: 'hello',
@@ -33,7 +33,9 @@ const ALLOWED_WS_PATHS = new Set([
33
33
  '/api/event/ws',
34
34
  '/api/terminal/ws',
35
35
  '/api/dictation/ws',
36
+ '/api/dev-tunnel',
36
37
  ]);
38
+ export const isAllowedRelayWebSocketPath = (pathname) => ALLOWED_WS_PATHS.has(pathname);
37
39
 
38
40
  // Hop-by-hop headers stripped from tunneled requests; `host` is set by fetch
39
41
  // to the loopback origin. content-length is dropped too because the body is
@@ -425,7 +427,7 @@ export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBuf
425
427
  void sendAbort(streamId, error?.message ?? 'malformed ws open');
426
428
  return;
427
429
  }
428
- if (!ALLOWED_WS_PATHS.has(open.path)) {
430
+ if (!isAllowedRelayWebSocketPath(open.path)) {
429
431
  void sendAbort(streamId, 'Path is not allowed through the relay');
430
432
  return;
431
433
  }
@@ -1,7 +1,7 @@
1
1
  import { describe, test, expect } from 'bun:test';
2
2
  import http from 'node:http';
3
3
 
4
- import { createTunnelHost } from './tunnel-host.js';
4
+ import { createTunnelHost, isAllowedRelayWebSocketPath } from './tunnel-host.js';
5
5
  import { decodeTunnelFrame, encodeTunnelFrame, encodeJsonPayload, TunnelFrameType } from './tunnel-codec.js';
6
6
 
7
7
  const startLoopback = () =>
@@ -145,3 +145,11 @@ describe('tunnel-host HTTP body forwarding', () => {
145
145
  await loopback.stop();
146
146
  });
147
147
  });
148
+
149
+ describe('relay host WebSocket allowlist', () => {
150
+ test('allows only the exact dev-server tunnel path', () => {
151
+ expect(isAllowedRelayWebSocketPath('/api/dev-tunnel')).toBe(true);
152
+ expect(isAllowedRelayWebSocketPath('/api/dev-tunnel/')).toBe(false);
153
+ expect(isAllowedRelayWebSocketPath('/api/database/ws')).toBe(false);
154
+ });
155
+ });
@@ -67,6 +67,7 @@ export const createRequestSecurityRuntime = (deps) => {
67
67
 
68
68
  const getRequestOriginCandidates = async (req) => {
69
69
  const origins = new Set();
70
+ const hosts = new Set();
70
71
  const forwardedProto = typeof req.headers['x-forwarded-proto'] === 'string'
71
72
  ? req.headers['x-forwarded-proto'].split(',')[0].trim().toLowerCase()
72
73
  : '';
@@ -78,6 +79,7 @@ export const createRequestSecurityRuntime = (deps) => {
78
79
  const host = forwardedHost || (typeof req.headers.host === 'string' ? req.headers.host.trim() : '');
79
80
 
80
81
  if (host) {
82
+ hosts.add(host.toLowerCase());
81
83
  origins.add(`${protocol}://${host}`);
82
84
  const [hostname, port] = host.split(':');
83
85
  const normalizedHost = typeof hostname === 'string' ? hostname.toLowerCase() : '';
@@ -98,10 +100,10 @@ export const createRequestSecurityRuntime = (deps) => {
98
100
  } catch {
99
101
  }
100
102
 
101
- return origins;
103
+ return { origins, hosts };
102
104
  };
103
105
 
104
- const isRequestOriginAllowed = async (req) => {
106
+ const isRequestOriginAllowed = (req) => {
105
107
  const originHeader = typeof req.headers.origin === 'string' ? req.headers.origin.trim() : '';
106
108
  if (!originHeader) {
107
109
  return false;
@@ -111,15 +113,28 @@ export const createRequestSecurityRuntime = (deps) => {
111
113
  return true;
112
114
  }
113
115
 
114
- let normalizedOrigin = '';
116
+ let origin;
115
117
  try {
116
- normalizedOrigin = new URL(originHeader).origin;
118
+ origin = new URL(originHeader);
117
119
  } catch {
118
120
  return false;
119
121
  }
120
122
 
121
- const allowedOrigins = await getRequestOriginCandidates(req);
122
- return allowedOrigins.has(normalizedOrigin);
123
+ const forwardedHostHeader = req.headers['x-forwarded-host'];
124
+ const forwardedHost = (Array.isArray(forwardedHostHeader) ? forwardedHostHeader[0] : forwardedHostHeader || '')
125
+ .split(',')[0].trim().toLowerCase();
126
+ const hostHeader = req.headers.host;
127
+ const host = forwardedHost || (Array.isArray(hostHeader) ? hostHeader[0] : hostHeader || '').trim().toLowerCase();
128
+ if (host && host === origin.host.toLowerCase()) return true;
129
+
130
+ // TLS commonly ends at a cloud edge before an HTTP hop to OpenChamber.
131
+ // In that setup the browser's Origin is https while a generic reverse
132
+ // proxy reports the upstream request as http. The external host remains
133
+ // authoritative, so compare it directly instead of requiring the proxy to
134
+ // preserve the browser-facing protocol.
135
+ return getRequestOriginCandidates(req).then((candidates) => (
136
+ candidates.origins.has(origin.origin) || candidates.hosts.has(origin.host.toLowerCase())
137
+ ));
123
138
  };
124
139
 
125
140
  return {
@@ -9,41 +9,75 @@ describe('request security runtime', () => {
9
9
  test('allows packaged client origins for remote client transports', async () => {
10
10
  const runtime = createRuntime();
11
11
 
12
- await expect(runtime.isRequestOriginAllowed({
12
+ expect(await runtime.isRequestOriginAllowed({
13
13
  headers: {
14
14
  origin: 'openchamber-ui://app',
15
15
  host: '192.168.1.130:1202',
16
16
  },
17
17
  socket: {},
18
- })).resolves.toBe(true);
18
+ })).toBe(true);
19
19
 
20
- await expect(runtime.isRequestOriginAllowed({
20
+ expect(await runtime.isRequestOriginAllowed({
21
21
  headers: {
22
22
  origin: 'capacitor://localhost',
23
23
  host: '192.168.1.130:1202',
24
24
  },
25
25
  socket: {},
26
- })).resolves.toBe(true);
26
+ })).toBe(true);
27
27
 
28
28
  // Android Capacitor WebView (androidScheme 'https') reports this origin.
29
- await expect(runtime.isRequestOriginAllowed({
29
+ expect(await runtime.isRequestOriginAllowed({
30
30
  headers: {
31
31
  origin: 'https://localhost',
32
32
  host: '192.168.1.130:1202',
33
33
  },
34
34
  socket: {},
35
- })).resolves.toBe(true);
35
+ })).toBe(true);
36
36
  });
37
37
 
38
38
  test('rejects unknown origins', async () => {
39
39
  const runtime = createRuntime();
40
40
 
41
- await expect(runtime.isRequestOriginAllowed({
41
+ expect(await runtime.isRequestOriginAllowed({
42
42
  headers: {
43
43
  origin: 'https://evil.example.com',
44
44
  host: '192.168.1.130:1202',
45
45
  },
46
46
  socket: {},
47
- })).resolves.toBe(false);
47
+ })).toBe(false);
48
+ });
49
+
50
+ test('allows the external host when TLS terminates before an HTTP proxy hop', async () => {
51
+ const runtime = createRuntime();
52
+
53
+ expect(await runtime.isRequestOriginAllowed({
54
+ headers: {
55
+ origin: 'https://devchamber.example.com',
56
+ host: 'devchamber.example.com',
57
+ 'x-forwarded-proto': 'http',
58
+ },
59
+ socket: {},
60
+ })).toBe(true);
61
+ });
62
+
63
+ test('uses the forwarded external host without trusting a different origin', async () => {
64
+ const runtime = createRuntime();
65
+ const request = {
66
+ headers: {
67
+ host: '127.0.0.1:3000',
68
+ 'x-forwarded-host': 'devchamber.example.com',
69
+ 'x-forwarded-proto': 'http',
70
+ },
71
+ socket: {},
72
+ };
73
+
74
+ expect(await runtime.isRequestOriginAllowed({
75
+ ...request,
76
+ headers: { ...request.headers, origin: 'https://devchamber.example.com' },
77
+ })).toBe(true);
78
+ expect(await runtime.isRequestOriginAllowed({
79
+ ...request,
80
+ headers: { ...request.headers, origin: 'https://evil.example.com' },
81
+ })).toBe(false);
48
82
  });
49
83
  });
@@ -257,6 +257,7 @@ export const createSessionAssistRuntime = ({
257
257
  prompt: `The latest exchange in the conversation:\n\n${transcript}\n\nWrite ${requestedFields} in the SAME language as this sample from the conversation: "${languageSample}"`,
258
258
  system: buildAssistSystemPrompt(targets),
259
259
  directory,
260
+ sessionID: sessionId,
260
261
  preferredProviderID: typeof lastAssistantInfo.providerID === 'string' ? lastAssistantInfo.providerID : undefined,
261
262
  preferredModelID: typeof lastAssistantInfo.modelID === 'string' ? lastAssistantInfo.modelID : undefined,
262
263
  });
@@ -108,13 +108,26 @@ before touching the filesystem). Rationale: metadata rides every
108
108
  stop), with a tick-side safety net. Messages sent while paused leave
109
109
  the goal alone; Resume re-arms the loop, and resuming over an aborted
110
110
  tail skips the audit and goes straight to a continuation nudge;
111
- - terminal checks, cheapest first: assistant turn error → `blocked`;
112
- `tokensUsed >= tokenBudget` → `budgetLimited`;
113
- `turnsUsed >= MAX_AUTO_TURNS` (20) → `blocked`;
114
- - if the latest message is a compaction summary, skip the audit and
115
- continue unconditionally running into the context window mid-work is
116
- by definition "in progress, not finished" (the summary is a retelling,
117
- not evidence, and must not be judged);
111
+ - terminal checks, cheapest first: assistant turn error → `blocked`;
112
+ `tokensUsed >= tokenBudget` → `budgetLimited`;
113
+ `turnsUsed >= MAX_AUTO_TURNS` (20) → `blocked`;
114
+ - error classification is independent of `finish`: `MessageAbortedError`
115
+ keeps the pause/resume behavior; only a `finish: "length"` with no
116
+ error, or `MessageOutputLengthError`, is an in-progress truncation that
117
+ skips the audit and continues. Any other non-null error wins over a
118
+ length finish and blocks with its non-empty `error.name`, or
119
+ `assistant turn failed` when unnamed;
120
+ - length recovery is bounded separately from the token budget and
121
+ auto-continuation cap: the first truncation permits one continuation, but
122
+ a second consecutive completed, non-summary assistant turn that is also
123
+ truncated settles the goal as `blocked` (`repeated output truncation`).
124
+ The consecutive state is derived from the loaded message history, not
125
+ persisted, using `info.time.created` chronology rather than message IDs.
126
+ Summary messages are not agent turns; an ordinary completed assistant
127
+ turn naturally breaks the consecutive condition. Explicit Resume grants
128
+ one new recovery attempt over the same transcript; the continuation
129
+ consumes that permission, so another truncation blocks again. Resume
130
+ does not bypass assistant errors or the token budget;
118
131
  - otherwise, small-model audit of the objective + the last assistant turn
119
132
  only — no conversation history and no continuation prompts
120
133
  (`restrictToPreferredProvider`, session's own provider/model preferred):
@@ -14,7 +14,7 @@ export const buildGoalIntroText = (tokenBudget) => {
14
14
  + '\n</system-reminder>';
15
15
  };
16
16
 
17
- const fitObjective = async ({ objective, directory, providerID, modelID, warn }) => {
17
+ const fitObjective = async ({ objective, directory, sessionID, providerID, modelID, warn }) => {
18
18
  if (objective.length <= GOAL_OBJECTIVE_CHAR_LIMIT) return objective;
19
19
 
20
20
  let distilled = null;
@@ -32,6 +32,7 @@ const fitObjective = async ({ objective, directory, providerID, modelID, warn })
32
32
  'Write in the same language as the task text.',
33
33
  ].join('\n'),
34
34
  directory,
35
+ sessionID,
35
36
  preferredProviderID: providerID,
36
37
  preferredModelID: modelID,
37
38
  });
@@ -66,6 +67,7 @@ export const createSessionGoal = async ({
66
67
  const objectiveText = await fitObjective({
67
68
  objective: String(objective ?? '').trim(),
68
69
  directory,
70
+ sessionID,
69
71
  providerID,
70
72
  modelID,
71
73
  warn,
@@ -244,6 +244,57 @@ const messageTokenTotal = (info) => {
244
244
  return input + cachedRead + output;
245
245
  };
246
246
 
247
+ const getErrorName = (error) => error?.name?.trim?.() ?? '';
248
+
249
+ const isLengthTruncated = (info, errorName = getErrorName(info?.error)) => {
250
+ const error = info?.error;
251
+ const hasError = error !== null && error !== undefined;
252
+ return errorName === 'MessageOutputLengthError' || (!hasError && info?.finish === 'length');
253
+ };
254
+
255
+ // Summary messages are assistant-shaped, but they are compaction turns rather
256
+ // than agent turns. They must not break or satisfy the consecutive truncation
257
+ // check; only completed, non-summary assistant turns participate. Chronology
258
+ // comes from `time.created`, never from message IDs; array position is only a
259
+ // tie-breaker for equal timestamps.
260
+ const hasRepeatedLengthTail = (messages, latestAssistant, goalCreatedAt) => {
261
+ const latestInfo = latestAssistant?.info;
262
+ if (latestInfo?.summary === true) return false;
263
+ const latestIndex = messages.indexOf(latestAssistant);
264
+ const latestCreated = latestInfo?.time?.created;
265
+ if (
266
+ latestIndex < 0
267
+ || !(latestInfo?.time?.completed > 0)
268
+ || !(Number.isFinite(latestCreated) && latestCreated > 0)
269
+ || !isLengthTruncated(latestInfo)
270
+ ) return false;
271
+
272
+ let previous = null;
273
+ for (let i = 0; i < messages.length; i += 1) {
274
+ const info = messages[i]?.info;
275
+ if (info?.role !== 'assistant' || info.summary === true || !(info.time?.completed > 0)) continue;
276
+ const created = info.time?.created;
277
+ // An unknown timestamp cannot safely participate in chronology. Ignore it
278
+ // rather than letting an unrelated older message hide known chronology.
279
+ if (!(Number.isFinite(created) && created > 0)) continue;
280
+ if (i === latestIndex) continue;
281
+ if (created > latestCreated || (created === latestCreated && i > latestIndex)) continue;
282
+ if (
283
+ !previous
284
+ || created > previous.created
285
+ || (created === previous.created && i > previous.index)
286
+ ) {
287
+ previous = { info, created, index: i };
288
+ }
289
+ }
290
+
291
+ return Boolean(
292
+ previous
293
+ && previous.created > goalCreatedAt
294
+ && isLengthTruncated(previous.info),
295
+ );
296
+ };
297
+
247
298
  export const createSessionGoalRuntime = ({
248
299
  buildOpenCodeUrl,
249
300
  getOpenCodeAuthHeaders,
@@ -378,6 +429,7 @@ export const createSessionGoalRuntime = ({
378
429
  prompt: `The goal objective:\n\n<objective>\n${goal.objective}\n</objective>\n\nThe agent's latest turn:\n\n${assistantText}\n\nReturn the verdict JSON. Write the note in the SAME language as this sample from the objective: "${goal.objective.slice(0, 200).replace(/\s+/g, ' ').trim()}"`,
379
430
  system: buildAuditSystemPrompt(),
380
431
  directory,
432
+ sessionID: typeof lastAssistantInfo?.sessionID === 'string' ? lastAssistantInfo.sessionID : undefined,
381
433
  preferredProviderID: typeof lastAssistantInfo?.providerID === 'string' ? lastAssistantInfo.providerID : undefined,
382
434
  preferredModelID: typeof lastAssistantInfo?.modelID === 'string' ? lastAssistantInfo.modelID : undefined,
383
435
  });
@@ -608,7 +660,11 @@ export const createSessionGoalRuntime = ({
608
660
  // resumed over an aborted tail: that is an explicit "keep going", so it
609
661
  // falls through to the continuation below (skipping the audit — an
610
662
  // aborted reply is not evidence of anything).
611
- const abortedTail = lastAssistantInfo.error?.name === 'MessageAbortedError';
663
+ const error = lastAssistantInfo.error;
664
+ const errorName = getErrorName(error);
665
+ const hasError = error !== null && error !== undefined;
666
+ const abortedTail = errorName === 'MessageAbortedError';
667
+ const lengthTail = isLengthTruncated(lastAssistantInfo, errorName);
612
668
  if (abortedTail && goal.statusReason !== 'resumed') {
613
669
  await writeGoal(sessionId, directory, goal.id, () => ({
614
670
  status: 'paused',
@@ -622,13 +678,12 @@ export const createSessionGoalRuntime = ({
622
678
  return;
623
679
  }
624
680
 
625
- // Turn error → blocked (prevents runaway auto-continuation into failures).
626
- if (!abortedTail && lastAssistantInfo.error && typeof lastAssistantInfo.error === 'object') {
627
- const reason = typeof lastAssistantInfo.error.name === 'string' && lastAssistantInfo.error.name
628
- ? lastAssistantInfo.error.name
629
- : 'assistant turn failed';
681
+ // Non-length turn error → blocked (prevents runaway auto-continuation into
682
+ // failures). Recognized length cutoffs are in-progress continuations, not
683
+ // hard failures.
684
+ if (!abortedTail && !lengthTail && hasError) {
630
685
  await settleGoal({
631
- sessionId, directory, goal, status: 'blocked', statusReason: reason, tokensUsed, tokensBaseline, tokensCommitted, lastAccountedMessageID,
686
+ sessionId, directory, goal, status: 'blocked', statusReason: errorName || 'assistant turn failed', tokensUsed, tokensBaseline, tokensCommitted, lastAccountedMessageID,
632
687
  });
633
688
  return;
634
689
  }
@@ -649,17 +704,28 @@ export const createSessionGoalRuntime = ({
649
704
  return;
650
705
  }
651
706
 
707
+ // A second consecutive completed, non-summary length-truncated turn is a
708
+ // bounded recovery failure. Derive this from the loaded transcript rather
709
+ // than persisting another goal counter.
710
+ if (lengthTail && goal.statusReason !== 'resumed' && hasRepeatedLengthTail(messages, lastAssistant, goal.createdAt)) {
711
+ await settleGoal({
712
+ sessionId, directory, goal, status: 'blocked', statusReason: 'repeated output truncation', tokensUsed, tokensBaseline, tokensCommitted, lastAccountedMessageID,
713
+ });
714
+ return;
715
+ }
716
+
652
717
  // --- Small-model audit: the sole termination authority besides the hard
653
718
  // stops above (turn error, budget, continuation cap). The working agent
654
719
  // has no channel to settle its own goal.
655
720
  //
656
- // Exception: when the latest message is a compaction summary, the agent
657
- // by definition ran into the context window mid-work that IS
658
- // "in progress, not finished". No audit call; continue unconditionally.
721
+ // Exception: when the latest message is a compaction summary or was cut off
722
+ // by the output token limit (length stop), the agent by definition ran into
723
+ // the context/output limit mid-work — that IS "in progress, not finished".
724
+ // No audit call; continue unconditionally.
659
725
  let audit = null;
660
726
  let blockedStreak = 0;
661
727
  let auditFailStreak = goal.auditFailStreak;
662
- if (lastAssistantInfo.summary === true || abortedTail) {
728
+ if (lastAssistantInfo.summary === true || abortedTail || lengthTail) {
663
729
  blockedStreak = goal.blockedStreak;
664
730
  } else {
665
731
  audit = await runAudit({ goal: { ...goal, objective: effectiveObjective }, assistantText, directory, lastAssistantInfo: executionInfo ?? lastAssistantInfo });