@circuitwall/jarela 2.3.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/app-path-routes-manifest.json +4 -4
  3. package/.next/standalone/.next/build-manifest.json +2 -2
  4. package/.next/standalone/.next/prerender-manifest.json +3 -3
  5. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  6. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  7. package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  11. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  12. package/.next/standalone/.next/server/app/_not-found.html +2 -2
  13. package/.next/standalone/.next/server/app/_not-found.rsc +2 -2
  14. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  15. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  16. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  17. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  18. package/.next/standalone/.next/server/app/api/v1/access/[identity]/route.js +7 -0
  19. package/.next/standalone/.next/server/app/api/v1/access/route.js +7 -0
  20. package/.next/standalone/.next/server/app/api/v1/agents/[id]/compact/route.js +7 -0
  21. package/.next/standalone/.next/server/app/api/v1/agents/[id]/display-filters/route.js +17 -5
  22. package/.next/standalone/.next/server/app/api/v1/agents/[id]/route.js +7 -0
  23. package/.next/standalone/.next/server/app/api/v1/agents/[id]/thread/route.js +17 -5
  24. package/.next/standalone/.next/server/app/api/v1/agents/route.js +7 -0
  25. package/.next/standalone/.next/server/app/api/v1/allowed-sites/[hostname]/route.js +7 -0
  26. package/.next/standalone/.next/server/app/api/v1/allowed-sites/route.js +7 -0
  27. package/.next/standalone/.next/server/app/api/v1/artifacts/lifecycle/route.js +7 -0
  28. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/chats/route.js +26 -32
  29. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/chats/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/ignores/[remote_jid]/route.js +18 -7
  31. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/ignores/route.js +18 -7
  32. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/lookup/route.js +26 -32
  33. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/lookup/route.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/pair/route.js +27 -33
  35. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/pair/route.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/route.js +26 -32
  37. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/route.js.nft.json +1 -1
  38. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/routes/[route_id]/route.js +28 -12
  39. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/routes/route.js +28 -12
  40. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/status/route.js +26 -32
  41. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/status/route.js.nft.json +1 -1
  42. package/.next/standalone/.next/server/app/api/v1/bridges/kinds/route.js +9 -2
  43. package/.next/standalone/.next/server/app/api/v1/bridges/kinds/route.js.nft.json +1 -1
  44. package/.next/standalone/.next/server/app/api/v1/bridges/route.js +19 -7
  45. package/.next/standalone/.next/server/app/api/v1/bridges/route.js.nft.json +1 -1
  46. package/.next/standalone/.next/server/app/api/v1/dashboard/metrics/route.js +17 -5
  47. package/.next/standalone/.next/server/app/api/v1/dashboard/pricing/route.js +7 -0
  48. package/.next/standalone/.next/server/app/api/v1/documents/search/route.js +7 -0
  49. package/.next/standalone/.next/server/app/api/v1/documents/settings/route.js +7 -0
  50. package/.next/standalone/.next/server/app/api/v1/env-sync/allowlist/route.js +7 -0
  51. package/.next/standalone/.next/server/app/api/v1/env-sync/route.js +7 -0
  52. package/.next/standalone/.next/server/app/api/v1/events/route.js +26 -32
  53. package/.next/standalone/.next/server/app/api/v1/events/route.js.nft.json +1 -1
  54. package/.next/standalone/.next/server/app/api/v1/extension/browser/activate/route.js +7 -0
  55. package/.next/standalone/.next/server/app/api/v1/extension/browser/foreground/route.js +7 -0
  56. package/.next/standalone/.next/server/app/api/v1/extension/browser/history/route.js +7 -0
  57. package/.next/standalone/.next/server/app/api/v1/extension/browser/poll/route.js +7 -0
  58. package/.next/standalone/.next/server/app/api/v1/extension/browser/progress/route.js +7 -0
  59. package/.next/standalone/.next/server/app/api/v1/extension/browser/result/route.js +7 -0
  60. package/.next/standalone/.next/server/app/api/v1/extension/browser/retry/route.js +7 -0
  61. package/.next/standalone/.next/server/app/api/v1/extension/browser/status/route.js +7 -0
  62. package/.next/standalone/.next/server/app/api/v1/extension/browser/tabs/route.js +7 -0
  63. package/.next/standalone/.next/server/app/api/v1/harnesses/[id]/route.js +7 -0
  64. package/.next/standalone/.next/server/app/api/v1/harnesses/default/route.js +7 -0
  65. package/.next/standalone/.next/server/app/api/v1/harnesses/route.js +7 -0
  66. package/.next/standalone/.next/server/app/api/v1/health/route.js +17 -5
  67. package/.next/standalone/.next/server/app/api/v1/integrations/[name]/route.js +7 -0
  68. package/.next/standalone/.next/server/app/api/v1/integrations/gmail/oauth/callback/route.js +7 -0
  69. package/.next/standalone/.next/server/app/api/v1/integrations/gmail/oauth/start/route.js +7 -0
  70. package/.next/standalone/.next/server/app/api/v1/integrations/gmail/oauth/status/route.js +7 -0
  71. package/.next/standalone/.next/server/app/api/v1/integrations/outlook/oauth/callback/route.js +7 -0
  72. package/.next/standalone/.next/server/app/api/v1/integrations/outlook/oauth/start/route.js +7 -0
  73. package/.next/standalone/.next/server/app/api/v1/integrations/outlook/oauth/status/route.js +7 -0
  74. package/.next/standalone/.next/server/app/api/v1/integrations/route.js +7 -0
  75. package/.next/standalone/.next/server/app/api/v1/lifecycle/adoption/route.js +17 -5
  76. package/.next/standalone/.next/server/app/api/v1/maps/embed/route.js +7 -0
  77. package/.next/standalone/.next/server/app/api/v1/mcp-servers/[name]/route.js +17 -5
  78. package/.next/standalone/.next/server/app/api/v1/mcp-servers/route.js +17 -5
  79. package/.next/standalone/.next/server/app/api/v1/memory/[namespace]/[key]/route.js +7 -0
  80. package/.next/standalone/.next/server/app/api/v1/memory/policy/route.js +7 -0
  81. package/.next/standalone/.next/server/app/api/v1/memory/route.js +7 -0
  82. package/.next/standalone/.next/server/app/api/v1/memory/search/route.js +7 -0
  83. package/.next/standalone/.next/server/app/api/v1/models/[name]/compact-threads/route.js +7 -0
  84. package/.next/standalone/.next/server/app/api/v1/models/[name]/route.js +7 -0
  85. package/.next/standalone/.next/server/app/api/v1/models/route.js +7 -0
  86. package/.next/standalone/.next/server/app/api/v1/pending-actions/[id]/approve/route.js +27 -12
  87. package/.next/standalone/.next/server/app/api/v1/profile/location/route.js +7 -0
  88. package/.next/standalone/.next/server/app/api/v1/profile/route.js +7 -0
  89. package/.next/standalone/.next/server/app/api/v1/providers/[provider]/models/route.js +7 -0
  90. package/.next/standalone/.next/server/app/api/v1/providers/[provider]/probe/route.js +7 -0
  91. package/.next/standalone/.next/server/app/api/v1/providers/github-copilot/auth/route.js +7 -0
  92. package/.next/standalone/.next/server/app/api/v1/providers/route.js +7 -0
  93. package/.next/standalone/.next/server/app/api/v1/proxy-config/route.js +7 -0
  94. package/.next/standalone/.next/server/app/api/v1/redaction/init/route.js +7 -0
  95. package/.next/standalone/.next/server/app/api/v1/redaction/route.js +7 -0
  96. package/.next/standalone/.next/server/app/api/v1/scheduled-tasks/[id]/route.js +17 -5
  97. package/.next/standalone/.next/server/app/api/v1/scheduled-tasks/route.js +7 -0
  98. package/.next/standalone/.next/server/app/api/v1/security/idle-timeout/route.js +7 -0
  99. package/.next/standalone/.next/server/app/api/v1/security/pin/route.js +7 -0
  100. package/.next/standalone/.next/server/app/api/v1/security/state/route.js +7 -0
  101. package/.next/standalone/.next/server/app/api/v1/security/unlock/route.js +7 -0
  102. package/.next/standalone/.next/server/app/api/v1/security/verify-pin/route.js +7 -0
  103. package/.next/standalone/.next/server/app/api/v1/skills/[id]/route.js +7 -0
  104. package/.next/standalone/.next/server/app/api/v1/skills/repos/[id]/route.js +7 -0
  105. package/.next/standalone/.next/server/app/api/v1/skills/repos/route.js +7 -0
  106. package/.next/standalone/.next/server/app/api/v1/skills/route.js +7 -0
  107. package/.next/standalone/.next/server/app/api/v1/tasks/[agent_id]/route.js +7 -0
  108. package/.next/standalone/.next/server/app/api/v1/tasks/route.js +7 -0
  109. package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/activity/route.js +7 -0
  110. package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/context-pin/route.js +12 -6
  111. package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/route.js +7 -0
  112. package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/run/route.js +0 -1
  113. package/.next/standalone/.next/server/app/api/v1/threads/route.js +17 -5
  114. package/.next/standalone/.next/server/app/api/v1/update/apply/route.js.nft.json +1 -1
  115. package/.next/standalone/.next/server/app/api/v1/voice/transcribe/route.js +17 -5
  116. package/.next/standalone/.next/server/app/api/v1/voice/tts/route.js +17 -5
  117. package/.next/standalone/.next/server/app/api/v1/watchers/[id]/route.js +17 -5
  118. package/.next/standalone/.next/server/app/page.js +322 -275
  119. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  120. package/.next/standalone/.next/server/app/setup/page.js +8 -0
  121. package/.next/standalone/.next/server/app/setup/page_client-reference-manifest.js +1 -1
  122. package/.next/standalone/.next/server/app-paths-manifest.json +4 -4
  123. package/.next/standalone/.next/server/chunks/1388.js +106 -36
  124. package/.next/standalone/.next/server/chunks/2068.js +47 -10
  125. package/.next/standalone/.next/server/chunks/210.js +6 -6
  126. package/.next/standalone/.next/server/chunks/2447.js +10 -5
  127. package/.next/standalone/.next/server/chunks/2471.js +17 -17
  128. package/.next/standalone/.next/server/chunks/3042.js +10 -8
  129. package/.next/standalone/.next/server/chunks/{4540.js → 309.js} +104 -38
  130. package/.next/standalone/.next/server/chunks/{2350.js → 3272.js} +104 -37
  131. package/.next/standalone/.next/server/chunks/3399.js +525 -525
  132. package/.next/standalone/.next/server/chunks/3524.js +150 -67
  133. package/.next/standalone/.next/server/chunks/4189.js +1 -1
  134. package/.next/standalone/.next/server/chunks/4508.js +2 -2
  135. package/.next/standalone/.next/server/chunks/46.js +73 -28
  136. package/.next/standalone/.next/server/chunks/5908.js +63 -42
  137. package/.next/standalone/.next/server/chunks/6142.js +10 -2
  138. package/.next/standalone/.next/server/chunks/6649.js +1 -1
  139. package/.next/standalone/.next/server/chunks/8461.js +10 -8
  140. package/.next/standalone/.next/server/chunks/9032.js +5 -5
  141. package/.next/standalone/.next/server/chunks/9164.js +435 -176
  142. package/.next/standalone/.next/server/chunks/9304.js +51 -12
  143. package/.next/standalone/.next/server/chunks/{423.js → 9590.js} +66 -21
  144. package/.next/standalone/.next/server/middleware-build-manifest.js +2 -2
  145. package/.next/standalone/.next/server/middleware.js +71 -18
  146. package/.next/standalone/.next/server/pages/404.html +2 -2
  147. package/.next/standalone/.next/server/pages/500.html +1 -1
  148. package/.next/standalone/.next/server/preview-props.json +3 -3
  149. package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
  150. package/.next/standalone/.next/static/chunks/app/{page-213a623b37226452.js → page-04748847c6ff866f.js} +171 -132
  151. package/.next/standalone/.next/static/css/{312542a5725ba0d5.css → 2669e8744cc8c212.css} +2 -2
  152. package/.next/standalone/docs/adoption/changesets.json +49 -0
  153. package/.next/standalone/package.json +1 -1
  154. package/CHANGELOG.md +19 -0
  155. package/app/api/v1/threads/[thread_id]/run/route.ts +0 -1
  156. package/components/chat/MessageBubble.tsx +12 -0
  157. package/components/integrations/NetworkSection.tsx +2 -2
  158. package/lib/agents/citation-checker.test.ts +40 -2
  159. package/lib/agents/citation-checker.ts +56 -11
  160. package/lib/agents/context-boundary.ts +5 -6
  161. package/lib/agents/instruction-edits.test.ts +120 -0
  162. package/lib/agents/instruction-edits.ts +65 -42
  163. package/lib/agents/prepare/request.ts +0 -2
  164. package/lib/agents/prepare/system-prompt.test.ts +10 -0
  165. package/lib/agents/prepare/system-prompt.ts +8 -2
  166. package/lib/agents/proposals.test.ts +76 -0
  167. package/lib/agents/proposals.ts +13 -5
  168. package/lib/agents/run-thread.auto-boundary.test.ts +125 -5
  169. package/lib/agents/run-thread.ts +24 -9
  170. package/lib/agents/sqlite-checkpoint-saver.test.ts +91 -0
  171. package/lib/agents/warm-summary-background.ts +22 -2
  172. package/lib/agents/warm-topics.test.ts +155 -1
  173. package/lib/agents/warm-topics.ts +27 -7
  174. package/lib/bridges/dispatcher.ts +1 -0
  175. package/lib/bridges/message-role.test.ts +81 -0
  176. package/lib/bridges/message-role.ts +23 -10
  177. package/lib/bridges/types.ts +6 -0
  178. package/lib/bridges/whatsapp-link.test.ts +32 -0
  179. package/lib/bridges/whatsapp-link.ts +21 -0
  180. package/lib/bridges/whatsapp.inbound.test.ts +239 -0
  181. package/lib/bridges/whatsapp.sender.test.ts +80 -1
  182. package/lib/bridges/whatsapp.ts +76 -38
  183. package/lib/pricing/snapshot.test.ts +70 -0
  184. package/lib/pricing/snapshot.ts +10 -2
  185. package/lib/proxy/dispatcher.test.ts +119 -1
  186. package/lib/proxy/dispatcher.ts +22 -9
  187. package/lib/stores/agent-configs.ts +10 -5
  188. package/lib/stores/bridges.test.ts +81 -0
  189. package/lib/stores/bridges.ts +9 -8
  190. package/lib/stores/proxy-config.test.ts +120 -0
  191. package/lib/stores/proxy-config.ts +22 -4
  192. package/lib/stores/threads.ts +12 -8
  193. package/lib/tools/communications/calendar.test.ts +43 -2
  194. package/lib/tools/communications/calendar.ts +2 -0
  195. package/lib/tools/communications/gmail.test.ts +51 -1
  196. package/lib/tools/communications/gmail.ts +44 -2
  197. package/lib/tools/communications/outlook-calendar.ts +2 -0
  198. package/lib/tools/communications/outlook.test.ts +33 -2
  199. package/lib/tools/communications/outlook.ts +2 -0
  200. package/lib/tools/filesystem/edit-match.test.ts +133 -0
  201. package/lib/tools/filesystem/edit-match.ts +7 -2
  202. package/lib/tools/support/result-refs.test.ts +131 -0
  203. package/lib/tools/support/result-refs.ts +41 -9
  204. package/lib/utils/source-link.test.ts +51 -0
  205. package/lib/utils/source-link.ts +45 -0
  206. package/package.json +1 -1
  207. /package/.next/standalone/.next/static/{bMRK5y3ruT7c3WtBfCQOG → hwujW4hR1b-r3k5WdMxZO}/_buildManifest.js +0 -0
  208. /package/.next/standalone/.next/static/{bMRK5y3ruT7c3WtBfCQOG → hwujW4hR1b-r3k5WdMxZO}/_ssgManifest.js +0 -0
@@ -324,3 +324,73 @@ describe("pricing snapshot refresh", () => {
324
324
  expect(modelRate?.output_per_1m_usd).toBe(0.28);
325
325
  });
326
326
  });
327
+
328
+ describe("pricing snapshot edge cases", () => {
329
+ beforeEach(() => {
330
+ vi.resetModules();
331
+ readFileMock.mockReset();
332
+ mkdirMock.mockReset();
333
+ writeFileMock.mockReset();
334
+ vi.unstubAllGlobals();
335
+ });
336
+
337
+ afterEach(() => {
338
+ vi.unstubAllGlobals();
339
+ });
340
+
341
+ it("refreshes a snapshot stamped in the future instead of trusting it forever", async () => {
342
+ readFileMock.mockResolvedValueOnce(JSON.stringify({
343
+ generated_at: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(),
344
+ disclaimer: "skewed",
345
+ ttl_days: 3,
346
+ sources: [],
347
+ }));
348
+ vi.stubGlobal("fetch", vi.fn(async () => new Response("input $1 / 1M tokens output $2 / 1M tokens", { status: 200 })));
349
+
350
+ const { refreshPricingSnapshot } = await import("./snapshot");
351
+ const res = await refreshPricingSnapshot({ providers: ["cohere"] });
352
+
353
+ expect(res).toMatchObject({ refreshed: true, reason: "stale" });
354
+ });
355
+
356
+ it("treats a corrupt snapshot file as missing", async () => {
357
+ readFileMock.mockResolvedValueOnce("{not json");
358
+ vi.stubGlobal("fetch", vi.fn(async () => new Response("input $1 / 1M tokens output $2 / 1M tokens", { status: 200 })));
359
+
360
+ const { refreshPricingSnapshot } = await import("./snapshot");
361
+ const res = await refreshPricingSnapshot({ providers: ["cohere"] });
362
+
363
+ expect(res).toMatchObject({ refreshed: true, reason: "missing" });
364
+ });
365
+
366
+ it("parses each source's search results independently and skips Google's own links", async () => {
367
+ readFileMock.mockRejectedValueOnce(new Error("ENOENT"));
368
+ const link = (u: string) => `<a href="/url?q=${encodeURIComponent(u)}&amp;sa=U">x</a>`;
369
+ const anthropicSearch = "<html>" + "x".repeat(4000)
370
+ + link("https://www.google.com/intl/en/policies/")
371
+ + Array.from({ length: 9 }, (_, i) => link(`https://a${i}.example.com/pricing`)).join("")
372
+ + "</html>";
373
+ const cohereSearch = `<html>${link("https://c.example.com/pricing")}</html>`;
374
+ const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
375
+ const url = String(input);
376
+ if (url.startsWith("https://www.google.com/search")) {
377
+ if (url.includes("Anthropic")) return new Response(anthropicSearch, { status: 200 });
378
+ // Only Cohere's first query has results, so a leaked regex cursor cannot be masked by a retry.
379
+ return new Response(url.includes("1M") ? cohereSearch : "<html></html>", { status: 200 });
380
+ }
381
+ if (/^https:\/\/(a0|c)\.example\.com\//.test(url)) {
382
+ return new Response("input $3 / 1M tokens output $15 / 1M tokens", { status: 200 });
383
+ }
384
+ return new Response("not found", { status: 404 });
385
+ });
386
+ vi.stubGlobal("fetch", fetchMock);
387
+
388
+ const { refreshPricingSnapshot } = await import("./snapshot");
389
+ const res = await refreshPricingSnapshot({ force: true, providers: ["anthropic", "cohere"] });
390
+ const byId = new Map(res.snapshot.sources.map((s) => [s.id, s]));
391
+
392
+ expect(byId.get("anthropic")).toMatchObject({ ok: true, resolved_url: "https://a0.example.com/pricing" });
393
+ expect(byId.get("cohere")).toMatchObject({ ok: true, resolved_url: "https://c.example.com/pricing" });
394
+ expect(fetchMock.mock.calls.map(([u]) => String(u))).not.toContain("https://www.google.com/intl/en/policies/");
395
+ });
396
+ });
@@ -85,6 +85,7 @@ const PRICE_LINE_RE = new RegExp(
85
85
  );
86
86
 
87
87
  const GOOGLE_RESULT_RE = /href="\/url\?q=([^"&]+)[^"]*"/gi;
88
+ const GOOGLE_OWN_HOST_RE = /^(?:www|accounts|support|maps|policies|webcache)?\.?google\.[a-z.]+$/i;
88
89
 
89
90
  type FetchAttempt = {
90
91
  url: string;
@@ -168,7 +169,8 @@ export async function refreshPricingSnapshot(opts?: { force?: boolean; ttlDays?:
168
169
  const existing = await readPricingSnapshot();
169
170
  if (!force && existing) {
170
171
  const ageDays = (Date.now() - Date.parse(existing.generated_at)) / (24 * 60 * 60 * 1000);
171
- if (Number.isFinite(ageDays) && ageDays < ttlDays) {
172
+ // A future generated_at (clock skew, copied snapshot) must not pin the cache as fresh.
173
+ if (Number.isFinite(ageDays) && ageDays >= 0 && ageDays < ttlDays) {
172
174
  return { refreshed: false, reason: "fresh", snapshot: existing };
173
175
  }
174
176
  }
@@ -332,6 +334,8 @@ function parseGoogleResultLinks(html: string): string[] {
332
334
  const seen = new Set<string>();
333
335
 
334
336
  let m: RegExpExecArray | null;
337
+ // Shared /g regex: an early break leaves lastIndex mid-string for the next page.
338
+ GOOGLE_RESULT_RE.lastIndex = 0;
335
339
  while ((m = GOOGLE_RESULT_RE.exec(html)) !== null) {
336
340
  const raw = m[1] ?? "";
337
341
  let url = "";
@@ -341,7 +345,11 @@ function parseGoogleResultLinks(html: string): string[] {
341
345
  continue;
342
346
  }
343
347
  if (!/^https?:\/\//i.test(url)) continue;
344
- if (/\/google\./i.test(url)) continue;
348
+ try {
349
+ if (GOOGLE_OWN_HOST_RE.test(new URL(url).hostname)) continue;
350
+ } catch {
351
+ continue;
352
+ }
345
353
  if (seen.has(url)) continue;
346
354
  seen.add(url);
347
355
  out.push(url);
@@ -31,7 +31,7 @@ vi.mock("node:child_process", async () => {
31
31
 
32
32
  import { exec } from "node:child_process";
33
33
  import { readFileSync } from "node:fs";
34
- import { getProxyConfigRaw } from "@/lib/stores/proxy-config";
34
+ import { getProxyConfigRaw, type ProxyConfigRaw } from "@/lib/stores/proxy-config";
35
35
  import { extractSystemKeychainCAs } from "@/lib/proxy/keychain";
36
36
 
37
37
  const mockedGetProxyConfigRaw = vi.mocked(getProxyConfigRaw);
@@ -262,3 +262,121 @@ describe("applyProxyConfigFromDb — env override", () => {
262
262
  expect(mockedExtract).not.toHaveBeenCalled();
263
263
  });
264
264
  });
265
+
266
+ describe("applyProxyConfigFromDb — manual mode", () => {
267
+ const originalEnv = { ...process.env };
268
+
269
+ function manual(overrides: Partial<ProxyConfigRaw> = {}): ProxyConfigRaw {
270
+ return {
271
+ mode: "manual",
272
+ scheme: "http",
273
+ host: "proxy.corp",
274
+ port: 3128,
275
+ username: null,
276
+ password: null,
277
+ no_proxy: null,
278
+ ca_bundle: null,
279
+ updated_at: "2026-05-22T00:00:00.000Z",
280
+ ...overrides,
281
+ };
282
+ }
283
+
284
+ beforeEach(() => {
285
+ for (const key of ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy", "NO_PROXY", "no_proxy"]) delete process.env[key];
286
+ vi.resetModules();
287
+ vi.clearAllMocks();
288
+ });
289
+
290
+ afterEach(async () => {
291
+ mockedGetProxyConfigRaw.mockReturnValue(null);
292
+ const { applyProxyConfigFromDb } = await import("./dispatcher");
293
+ await applyProxyConfigFromDb();
294
+ process.env = { ...originalEnv };
295
+ });
296
+
297
+ it("percent-encodes credentials in the proxy URL and redacts the password in the result", async () => {
298
+ mockedGetProxyConfigRaw.mockReturnValue(manual({ username: "dom\\user", password: "p@ss:w/rd" }));
299
+ const { applyProxyConfigFromDb } = await import("./dispatcher");
300
+ const r = await applyProxyConfigFromDb();
301
+ expect(r.source).toBe("manual");
302
+ expect(process.env.HTTPS_PROXY).toBe("http://dom%5Cuser:p%40ss%3Aw%2Frd@proxy.corp:3128");
303
+ expect(r.proxyUrl).toBe("http://dom%5Cuser:***@proxy.corp:3128/");
304
+ expect(r.proxyUrl).not.toContain("p%40ss");
305
+ });
306
+
307
+ it("brackets an IPv6 proxy host so the URL is parseable", async () => {
308
+ mockedGetProxyConfigRaw.mockReturnValue(manual({ host: "fd00::1" }));
309
+ const { applyProxyConfigFromDb } = await import("./dispatcher");
310
+ const r = await applyProxyConfigFromDb();
311
+ expect(process.env.HTTPS_PROXY).toBe("http://[fd00::1]:3128");
312
+ expect(r.proxyUrl).toBe("http://[fd00::1]:3128/");
313
+ });
314
+
315
+ it("always bypasses loopback, merged with the user's no_proxy list", async () => {
316
+ mockedGetProxyConfigRaw.mockReturnValue(manual({ no_proxy: " corp.internal, .svc " }));
317
+ const { applyProxyConfigFromDb } = await import("./dispatcher");
318
+ await applyProxyConfigFromDb();
319
+ const entries = (process.env.NO_PROXY ?? "").split(",");
320
+ expect(entries).toEqual(expect.arrayContaining(["corp.internal", ".svc", "localhost", "127.0.0.1", "::1"]));
321
+ expect(new Set(entries).size).toBe(entries.length);
322
+ });
323
+
324
+ it("still reaches a local server (e.g. Ollama) directly when the proxy is unreachable", async () => {
325
+ const { createServer } = await import("node:http");
326
+ const server = createServer((_req, res) => res.end("local-ok"));
327
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
328
+ const { port } = server.address() as { port: number };
329
+ try {
330
+ // Port 9 (discard) on loopback refuses connections, so any request
331
+ // routed through this "proxy" fails.
332
+ mockedGetProxyConfigRaw.mockReturnValue(manual({ host: "127.0.0.1", port: 9 }));
333
+ const { applyProxyConfigFromDb } = await import("./dispatcher");
334
+ await applyProxyConfigFromDb();
335
+ for (const host of ["127.0.0.1", "localhost"]) {
336
+ const res = await fetch(`http://${host}:${port}/`);
337
+ expect(await res.text()).toBe("local-ok");
338
+ }
339
+ } finally {
340
+ await new Promise<void>((resolve) => server.close(() => resolve()));
341
+ }
342
+ });
343
+
344
+ it("keeps loopback direct even when a lowercase no_proxy is inherited", async () => {
345
+ // undici reads lowercase no_proxy before NO_PROXY.
346
+ process.env.no_proxy = "corp.internal";
347
+ const { createServer } = await import("node:http");
348
+ const server = createServer((_req, res) => res.end("local-ok"));
349
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
350
+ const { port } = server.address() as { port: number };
351
+ try {
352
+ mockedGetProxyConfigRaw.mockReturnValue(manual({ host: "127.0.0.1", port: 9 }));
353
+ const { applyProxyConfigFromDb } = await import("./dispatcher");
354
+ await applyProxyConfigFromDb();
355
+ const res = await fetch(`http://127.0.0.1:${port}/`);
356
+ expect(await res.text()).toBe("local-ok");
357
+ } finally {
358
+ await new Promise<void>((resolve) => server.close(() => resolve()));
359
+ }
360
+ });
361
+
362
+ it("reverts to no proxy when host or port is missing", async () => {
363
+ mockedGetProxyConfigRaw.mockReturnValue(manual({ port: null }));
364
+ const { applyProxyConfigFromDb } = await import("./dispatcher");
365
+ const r = await applyProxyConfigFromDb();
366
+ expect(r.source).toBe("off");
367
+ expect(r.note).toMatch(/missing host\/port/);
368
+ expect(process.env.HTTPS_PROXY).toBeUndefined();
369
+ });
370
+
371
+ it("clears the proxy env when switched back to off", async () => {
372
+ mockedGetProxyConfigRaw.mockReturnValue(manual());
373
+ const { applyProxyConfigFromDb } = await import("./dispatcher");
374
+ await applyProxyConfigFromDb();
375
+ expect(process.env.HTTPS_PROXY).toBeDefined();
376
+ mockedGetProxyConfigRaw.mockReturnValue({ ...manual(), mode: "off" });
377
+ const r = await applyProxyConfigFromDb();
378
+ expect(r.source).toBe("off");
379
+ expect(process.env.HTTPS_PROXY).toBeUndefined();
380
+ expect(process.env.NO_PROXY).toBeUndefined();
381
+ });
382
+ });
@@ -215,7 +215,18 @@ function buildProxyUrl(
215
215
  const auth = username
216
216
  ? `${encodeURIComponent(username)}${password ? `:${encodeURIComponent(password)}` : ""}@`
217
217
  : "";
218
- return `${scheme}://${auth}${host}:${port}`;
218
+ const urlHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
219
+ return `${scheme}://${auth}${urlHost}:${port}`;
220
+ }
221
+
222
+ // undici's EnvHttpProxyAgent has no implicit loopback exemption, so without
223
+ // these a configured proxy would also receive local traffic (Ollama, the
224
+ // WhatsApp bridge sidecar, the app's own loopback API).
225
+ const LOOPBACK_NO_PROXY = ["localhost", "127.0.0.1", "::1"];
226
+
227
+ function mergeNoProxy(noProxy: string | null): string {
228
+ const entries = (noProxy ?? "").split(/[,\s]+/).map((entry) => entry.trim()).filter(Boolean);
229
+ return [...new Set([...entries, ...LOOPBACK_NO_PROXY])].join(",");
219
230
  }
220
231
 
221
232
  function installProxy(url: string, noProxy: string | null, caBundle: string | null): void {
@@ -224,22 +235,24 @@ function installProxy(url: string, noProxy: string | null, caBundle: string | nu
224
235
  // We still treat the boot-time env snapshot as authoritative for the
225
236
  // "env beats DB" rule — this mutation only happens after we've
226
237
  // confirmed the user did NOT set HTTP_PROXY at launch.
238
+ const merged = mergeNoProxy(noProxy);
227
239
  process.env.HTTP_PROXY = url;
228
240
  process.env.HTTPS_PROXY = url;
229
- if (noProxy && noProxy.trim().length > 0) {
230
- process.env.NO_PROXY = noProxy.trim();
231
- } else {
232
- delete process.env.NO_PROXY;
233
- }
241
+ process.env.NO_PROXY = merged;
234
242
  // ADR-0012: when the user pasted a corporate root CA, inject it on
235
243
  // both TLS legs — the proxy hop (proxyTls) for https-scheme proxies
236
244
  // signed by the corporate root, and the upstream tunnel (requestTls)
237
245
  // for proxies that MITM outbound HTTPS with the same internal CA.
238
246
  // Same blob is correct: the corporate trust chain typically signs
239
247
  // both ends.
240
- const opts = caBundle
241
- ? { requestTls: { ca: caBundle }, proxyTls: { ca: caBundle } }
242
- : {};
248
+ // Pass the values explicitly too: undici prefers lowercase http_proxy /
249
+ // no_proxy from the environment over the uppercase ones set above.
250
+ const opts = {
251
+ httpProxy: url,
252
+ httpsProxy: url,
253
+ noProxy: merged,
254
+ ...(caBundle ? { requestTls: { ca: caBundle }, proxyTls: { ca: caBundle } } : {}),
255
+ };
243
256
  setGlobalDispatcher(new EnvHttpProxyAgent(opts));
244
257
  }
245
258
 
@@ -253,12 +253,17 @@ function upsertAgentConfigTransaction(input: UpsertAgentInput): AgentConfigRow {
253
253
  const db = getDb();
254
254
  const existing = getAgentConfig(input.id);
255
255
  const created_at = existing?.created_at ?? t;
256
- const mbti = input.adaptive_mbti ?? toMbti(existing?.adaptive_mbti) ?? "INTJ";
256
+ const existingMbti = toMbti(existing?.adaptive_mbti);
257
+ const mbti = input.adaptive_mbti ?? existingMbti ?? "INTJ";
257
258
  const preset = MBTI_PRESETS[mbti];
258
- const strength = input.adaptive_persona_strength ?? preset.strength;
259
- const empathy = input.adaptive_empathy ?? preset.empathy;
260
- const expressiveness = input.adaptive_expressiveness ?? preset.expressiveness;
261
- const verbosity = input.adaptive_verbosity ?? preset.verbosity;
259
+ // Omitted sliders keep their tuned values; they only fall back to the
260
+ // preset when the type changes or the agent is new. Otherwise every
261
+ // partial update (approved proposals, tool edits) reset them.
262
+ const keepTuned = existing !== null && existing !== undefined && mbti === existingMbti;
263
+ const strength = input.adaptive_persona_strength ?? (keepTuned ? existing.adaptive_persona_strength : preset.strength);
264
+ const empathy = input.adaptive_empathy ?? (keepTuned ? existing.adaptive_empathy : preset.empathy);
265
+ const expressiveness = input.adaptive_expressiveness ?? (keepTuned ? existing.adaptive_expressiveness : preset.expressiveness);
266
+ const verbosity = input.adaptive_verbosity ?? (keepTuned ? existing.adaptive_verbosity : preset.verbosity);
262
267
  if (input.is_default) db.prepare("UPDATE agent_configs SET is_default=0").run();
263
268
  // harness_id: explicit `undefined` means "keep existing"; explicit `null`
264
269
  // means "use the global default". Empty string is normalised to null too,
@@ -0,0 +1,81 @@
1
+ import { afterAll, beforeEach, describe, expect, it } from "vitest";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ const tmpRoot = mkdtempSync(join(tmpdir(), "jarela-test-bridges-"));
7
+ process.env.JARELA_DB_DIR = tmpRoot;
8
+
9
+ const store = await import("./bridges");
10
+ const { getDb } = await import("@/lib/db");
11
+
12
+ afterAll(() => {
13
+ try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* */ }
14
+ });
15
+
16
+ describe("bridges store", () => {
17
+ beforeEach(() => {
18
+ const db = getDb();
19
+ db.exec("DELETE FROM bridge_routes; DELETE FROM bridge_ignores; DELETE FROM bridges;");
20
+ });
21
+
22
+ it("keeps each chat's watermark monotonic and isolated from other chats", () => {
23
+ const b = store.createBridge({ kind: "whatsapp", name: "WA" });
24
+ store.createRoute({ bridge_id: b.id, remote_jid: "a@s.whatsapp.net", agent_id: "agent-1" });
25
+ store.createRoute({ bridge_id: b.id, remote_jid: "b@s.whatsapp.net", agent_id: "agent-2" });
26
+
27
+ store.bumpRouteLastSeenTs(b.id, "a@s.whatsapp.net", 2_000);
28
+ store.bumpRouteLastSeenTs(b.id, "a@s.whatsapp.net", 1_000);
29
+ store.bumpRouteLastSeenTs(b.id, "a@s.whatsapp.net", Number.NaN);
30
+ store.bumpRouteLastSeenTs(b.id, "a@s.whatsapp.net", -5);
31
+ store.bumpRouteLastSeenTs(b.id, "unrouted@s.whatsapp.net", 9_000);
32
+
33
+ expect(store.getRouteLastSeenTsMap(b.id)).toEqual(new Map([["a@s.whatsapp.net", 2_000]]));
34
+ });
35
+
36
+ it("scopes watermarks to their bridge", () => {
37
+ const one = store.createBridge({ kind: "whatsapp", name: "one" });
38
+ const two = store.createBridge({ kind: "whatsapp", name: "two" });
39
+ store.createRoute({ bridge_id: one.id, remote_jid: "x@s.whatsapp.net", agent_id: "agent-3" });
40
+ store.createRoute({ bridge_id: two.id, remote_jid: "x@s.whatsapp.net", agent_id: "agent-4" });
41
+ store.bumpRouteLastSeenTs(one.id, "x@s.whatsapp.net", 5_000);
42
+ expect(store.getRouteLastSeenTsMap(two.id).size).toBe(0);
43
+ });
44
+
45
+ it("deletes a bridge together with its routes and ignores", () => {
46
+ const b = store.createBridge({ kind: "whatsapp", name: "WA" });
47
+ const route = store.createRoute({ bridge_id: b.id, remote_jid: "*", agent_id: "agent-5" });
48
+ store.addIgnore({ bridge_id: b.id, remote_jid: "spam@s.whatsapp.net" });
49
+
50
+ expect(store.deleteBridge(b.id)).toBe(true);
51
+ expect(store.getRoute(route.id)).toBeNull();
52
+ expect(store.isIgnored(b.id, "spam@s.whatsapp.net")).toBe(false);
53
+ expect(store.deleteBridge(b.id)).toBe(false);
54
+ });
55
+
56
+ it("rejects a duplicate ignore entry and removes only the matching chat", () => {
57
+ const b = store.createBridge({ kind: "whatsapp", name: "WA" });
58
+ store.addIgnore({ bridge_id: b.id, remote_jid: "a@s.whatsapp.net", label: "A" });
59
+ store.addIgnore({ bridge_id: b.id, remote_jid: "b@s.whatsapp.net" });
60
+ expect(() => store.addIgnore({ bridge_id: b.id, remote_jid: "a@s.whatsapp.net" })).toThrow(/UNIQUE/);
61
+
62
+ expect(store.removeIgnore(b.id, "a@s.whatsapp.net")).toBe(true);
63
+ expect(store.removeIgnore(b.id, "a@s.whatsapp.net")).toBe(false);
64
+ expect(store.listIgnores(b.id).map((i) => i.remote_jid)).toEqual(["b@s.whatsapp.net"]);
65
+ });
66
+
67
+ it("applies partial updates without clobbering nullable fields", () => {
68
+ const b = store.createBridge({ kind: "whatsapp", name: "WA" });
69
+ store.updateBridge(b.id, { last_error: "boom", paired_id: "me" });
70
+ const updated = store.updateBridge(b.id, { status: "connected", forward_group_profile_updates: 0 });
71
+ expect(updated).toMatchObject({ last_error: "boom", paired_id: "me", status: "connected", forward_group_profile_updates: 0 });
72
+ expect(store.updateBridge(b.id, { last_error: null })?.last_error).toBeNull();
73
+ expect(store.getBridge(b.id)).toMatchObject({ last_error: null, paired_id: "me", forward_group_participants_updates: 1 });
74
+ expect(store.updateBridge("missing", { name: "x" })).toBeNull();
75
+
76
+ const route = store.createRoute({ bridge_id: b.id, remote_jid: "a@s.whatsapp.net", agent_id: "agent-6", label: "A", silent_mode: true });
77
+ expect(store.updateRoute(route.id, { respond_to: "user" })).toMatchObject({ label: "A", silent_mode: 1, respond_to: "user" });
78
+ expect(store.updateRoute(route.id, { silent_mode: false, label: null })).toMatchObject({ label: null, silent_mode: 0 });
79
+ expect(store.findRoute(b.id, "a@s.whatsapp.net")).toMatchObject({ silent_mode: 0, respond_to: "user", label: null });
80
+ });
81
+ });
@@ -263,14 +263,15 @@ export function bumpRouteLastSeenTs(bridgeId: string, remoteJid: string, ts: num
263
263
  .run(ts, bridgeId, remoteJid, ts);
264
264
  }
265
265
 
266
- // Snapshot the highest watermark across every route on a bridge. The
267
- // adapter uses this on reconnect to short-circuit very-old appends before
268
- // it has loaded individual route rows.
269
- export function getMaxRouteLastSeenTs(bridgeId: string): number {
270
- const row = getDb()
271
- .prepare("SELECT MAX(last_seen_ts) AS m FROM bridge_routes WHERE bridge_id=?")
272
- .get(bridgeId) as { m: number | null } | undefined;
273
- return row?.m ?? 0;
266
+ // Per-chat watermarks for every explicitly routed chat on a bridge, loaded
267
+ // once per upsert batch. Each chat is gated only by its own progress: a
268
+ // bridge-wide maximum would drop chat B's offline backlog as soon as a newer
269
+ // message from chat A had been delivered.
270
+ export function getRouteLastSeenTsMap(bridgeId: string): Map<string, number> {
271
+ const rows = getDb()
272
+ .prepare("SELECT remote_jid, last_seen_ts FROM bridge_routes WHERE bridge_id=? AND last_seen_ts IS NOT NULL")
273
+ .all(bridgeId) as Array<{ remote_jid: string; last_seen_ts: number }>;
274
+ return new Map(rows.map((r) => [r.remote_jid, r.last_seen_ts]));
274
275
  }
275
276
 
276
277
  // ---------------------------------------------------------------------------
@@ -0,0 +1,120 @@
1
+ import { afterAll, beforeEach, describe, expect, it } from "vitest";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ const tmpRoot = mkdtempSync(join(tmpdir(), "jarela-test-proxy-config-"));
7
+ process.env.JARELA_DB_DIR = tmpRoot;
8
+
9
+ const store = await import("./proxy-config");
10
+ const { getDb } = await import("@/lib/db");
11
+ const { SECRET_MASK } = await import("./integrations");
12
+
13
+ afterAll(() => {
14
+ try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* */ }
15
+ });
16
+
17
+ const PEM = "-----BEGIN CERTIFICATE-----\nMIIBfake\n-----END CERTIFICATE-----\n";
18
+
19
+ function storedPasswordColumn(): string | null {
20
+ const row = getDb().prepare("SELECT password FROM proxy_config WHERE id = 1").get() as { password: string | null } | undefined;
21
+ return row?.password ?? null;
22
+ }
23
+
24
+ describe("proxy-config store", () => {
25
+ beforeEach(() => {
26
+ getDb().exec("DELETE FROM proxy_config");
27
+ });
28
+
29
+ it("returns the default off status before anything is saved", () => {
30
+ expect(store.getProxyConfigStatus()).toMatchObject({ mode: "off", scheme: "http", host: null, password: null });
31
+ expect(store.getProxyConfigRaw()).toBeNull();
32
+ expect(store.deleteProxyConfig()).toBe(false);
33
+ });
34
+
35
+ it("encrypts the password at rest, masks it in status and decrypts it for the dispatcher", () => {
36
+ const saved = store.saveProxyConfig({ mode: "manual", host: "proxy.corp", port: 3128, username: "u", password: "p@ss:word" });
37
+ expect(saved).toMatchObject({ mode: "manual", host: "proxy.corp", port: 3128, password: SECRET_MASK });
38
+ expect(storedPasswordColumn()).not.toContain("p@ss:word");
39
+ expect(store.getProxyConfigRaw()?.password).toBe("p@ss:word");
40
+ });
41
+
42
+ it("keeps the existing password when the UI echoes the mask back, without re-encrypting it", () => {
43
+ store.saveProxyConfig({ mode: "manual", host: "proxy.corp", port: 3128, username: "u", password: "secret" });
44
+ const before = storedPasswordColumn();
45
+ store.saveProxyConfig({ mode: "manual", host: "proxy2.corp", port: 8080, username: "u", password: SECRET_MASK });
46
+ expect(storedPasswordColumn()).toBe(before);
47
+ expect(store.getProxyConfigRaw()).toMatchObject({ host: "proxy2.corp", port: 8080, password: "secret" });
48
+ });
49
+
50
+ it("never stores the mask itself as a password when none existed", () => {
51
+ store.saveProxyConfig({ mode: "manual", host: "proxy.corp", port: 3128, password: SECRET_MASK });
52
+ expect(store.getProxyConfigRaw()?.password).toBeNull();
53
+ expect(store.getProxyConfigStatus().password).toBeNull();
54
+ });
55
+
56
+ it("clears the password when an empty string or null is sent", () => {
57
+ store.saveProxyConfig({ mode: "manual", host: "proxy.corp", port: 3128, password: "secret" });
58
+ store.saveProxyConfig({ mode: "manual", host: "proxy.corp", port: 3128, password: "" });
59
+ expect(store.getProxyConfigRaw()?.password).toBeNull();
60
+ store.saveProxyConfig({ mode: "manual", host: "proxy.corp", port: 3128, password: "secret" });
61
+ store.saveProxyConfig({ mode: "manual", host: "proxy.corp", port: 3128, password: null });
62
+ expect(store.getProxyConfigRaw()?.password).toBeNull();
63
+ });
64
+
65
+ it("trims host, username and no_proxy and stores blanks as null", () => {
66
+ store.saveProxyConfig({ mode: "manual", host: " proxy.corp ", port: 3128, username: " ", no_proxy: " " });
67
+ expect(store.getProxyConfigRaw()).toMatchObject({ host: "proxy.corp", username: null, no_proxy: null });
68
+ });
69
+
70
+ it.each([
71
+ ["a URL with a scheme", "http://proxy.corp"],
72
+ ["a host with a port", "proxy.corp:8080"],
73
+ ["a hex-only host with a port", "dead.beef:3128"],
74
+ ["a host with a path", "proxy.corp/pac"],
75
+ ["a host with credentials", "user:pw@proxy.corp"],
76
+ ["a host with spaces", "proxy corp"],
77
+ ])("rejects %s in manual mode instead of building a broken proxy URL", (_label, host) => {
78
+ const result = store.saveProxyConfig({ mode: "manual", host, port: 3128 });
79
+ expect(result).toHaveProperty("error");
80
+ expect((result as { error: string }).error).toMatch(/host/i);
81
+ expect(store.getProxyConfigRaw()).toBeNull();
82
+ });
83
+
84
+ it.each([
85
+ ["fd00::1", "fd00::1"],
86
+ ["[fd00::1]", "fd00::1"],
87
+ ["10.0.0.5", "10.0.0.5"],
88
+ ["proxy-1.corp.example", "proxy-1.corp.example"],
89
+ ])("accepts host %s and stores it as %s", (input, stored) => {
90
+ const result = store.saveProxyConfig({ mode: "manual", host: input, port: 3128 });
91
+ expect(result).not.toHaveProperty("error");
92
+ expect(store.getProxyConfigRaw()?.host).toBe(stored);
93
+ });
94
+
95
+ it.each([0, -1, 65536, 3128.5, Number.NaN, Number.POSITIVE_INFINITY])("rejects port %s", (port) => {
96
+ expect(store.saveProxyConfig({ mode: "manual", host: "proxy.corp", port })).toHaveProperty("error");
97
+ });
98
+
99
+ it("requires host and port only in manual mode", () => {
100
+ expect(store.saveProxyConfig({ mode: "manual", port: 3128 })).toHaveProperty("error");
101
+ expect(store.saveProxyConfig({ mode: "manual", host: "proxy.corp" })).toHaveProperty("error");
102
+ expect(store.saveProxyConfig({ mode: "system" })).toMatchObject({ mode: "system" });
103
+ expect(store.saveProxyConfig({ mode: "off" })).toMatchObject({ mode: "off" });
104
+ });
105
+
106
+ it("rejects an invalid mode and a non-PEM CA bundle, and clears a blank bundle", () => {
107
+ expect(store.saveProxyConfig({ mode: "auto" as never })).toHaveProperty("error");
108
+ expect(store.saveProxyConfig({ mode: "off", ca_bundle: "not a cert" })).toHaveProperty("error");
109
+ store.saveProxyConfig({ mode: "off", ca_bundle: PEM });
110
+ expect(store.getProxyConfigRaw()?.ca_bundle).toBe(PEM);
111
+ store.saveProxyConfig({ mode: "off", ca_bundle: " " });
112
+ expect(store.getProxyConfigRaw()?.ca_bundle).toBeNull();
113
+ });
114
+
115
+ it("normalises an unknown persisted scheme to http", () => {
116
+ store.saveProxyConfig({ mode: "manual", host: "proxy.corp", port: 3128 });
117
+ getDb().prepare("UPDATE proxy_config SET scheme = 'socks5' WHERE id = 1").run();
118
+ expect(store.getProxyConfigRaw()).toMatchObject({ mode: "manual", scheme: "http" });
119
+ });
120
+ });
@@ -15,6 +15,7 @@
15
15
  // existing password" so the UI can echo back the masked form without
16
16
  // blanking the secret.
17
17
 
18
+ import { isIPv6 } from "node:net";
18
19
  import { getDb } from "@/lib/db";
19
20
  import { decryptIfNeeded, encrypt } from "@/lib/crypto/envelope";
20
21
  import { SECRET_MASK } from "@/lib/stores/integrations";
@@ -122,11 +123,18 @@ export function saveProxyConfig(input: ProxyConfigInput): ProxyConfigStatus | {
122
123
  const scheme: ProxyScheme = input.scheme === "https" ? "https" : "http";
123
124
 
124
125
  // Manual mode requires host + port. system / off don't.
126
+ let host = nullable(input.host);
125
127
  if (input.mode === "manual") {
126
- if (!input.host || !input.host.trim()) return { error: "host is required in manual mode" };
127
- if (input.port == null || !Number.isFinite(input.port) || input.port <= 0 || input.port > 65535) {
128
- return { error: "port must be between 1 and 65535" };
128
+ if (!host) return { error: "host is required in manual mode" };
129
+ const normalisedHost = normaliseProxyHost(host);
130
+ if (!normalisedHost) {
131
+ return { error: "host must be a hostname or IP address, without scheme, port, path or credentials" };
129
132
  }
133
+ host = normalisedHost;
134
+ if (input.port == null) return { error: "port must be between 1 and 65535" };
135
+ }
136
+ if (input.port != null && (!Number.isInteger(input.port) || input.port <= 0 || input.port > 65535)) {
137
+ return { error: "port must be between 1 and 65535" };
130
138
  }
131
139
 
132
140
  // CA bundle: empty/null clears; non-empty must look like PEM.
@@ -167,7 +175,7 @@ export function saveProxyConfig(input: ProxyConfigInput): ProxyConfigStatus | {
167
175
  ).run(
168
176
  input.mode,
169
177
  scheme,
170
- nullable(input.host),
178
+ host,
171
179
  input.port ?? null,
172
180
  nullable(input.username),
173
181
  storedPassword,
@@ -207,6 +215,16 @@ function normaliseScheme(s: string | null | undefined): ProxyScheme {
207
215
  return s === "https" ? "https" : "http";
208
216
  }
209
217
 
218
+ // The dispatcher builds `${scheme}://${auth}${host}:${port}`, so a pasted URL
219
+ // or host:port would produce an unusable proxy URL. IPv6 is stored unbracketed;
220
+ // the dispatcher adds the brackets.
221
+ function normaliseProxyHost(host: string): string | null {
222
+ const unbracketed = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
223
+ if (/^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$/.test(unbracketed)) return unbracketed;
224
+ if (isIPv6(unbracketed)) return unbracketed;
225
+ return null;
226
+ }
227
+
210
228
  function nullable(v: string | null | undefined): string | null {
211
229
  if (v == null) return null;
212
230
  const trimmed = v.trim();
@@ -157,11 +157,15 @@ export function getThreadMessageBySeq(thread_id: string, seq: number): MessageRo
157
157
  return (getDb().prepare(MSG_COLS_SQL + " WHERE thread_id=? AND rowid=?").get(thread_id, seq) as unknown as MessageRow | undefined) ?? null;
158
158
  }
159
159
 
160
- export function countThreadMessagesFromSeq(thread_id: string, seq: number): number {
161
- const row = getDb().prepare("SELECT COUNT(*) AS n FROM messages WHERE thread_id=? AND rowid>=?").get(thread_id, seq) as { n: number } | undefined;
160
+
161
+ // Rows that size compaction can prune: automation-channel rows are kept
162
+ // (see pruneThreadMessages), so they must not count toward its trigger.
163
+ export function countForegroundThreadMessages(thread_id: string, fromSeq = 0): number {
164
+ const row = getDb()
165
+ .prepare("SELECT COUNT(*) AS n FROM messages WHERE thread_id=? AND rowid>=? AND " + foregroundCategoryGuardSql())
166
+ .get(thread_id, fromSeq, ...FOREGROUND_EXCLUDED_CATEGORIES) as { n: number } | undefined;
162
167
  return row?.n ?? 0;
163
168
  }
164
-
165
169
  export function isValidThreadContextCursor(thread_id: string, seq: number): boolean {
166
170
  if (getThreadMessageBySeq(thread_id, seq)) return true;
167
171
  return getThread(thread_id)?.hot_since_seq === seq;
@@ -733,11 +737,11 @@ export function setThreadContextPin(thread_id: string, hot_since: string | null,
733
737
  .run(hot_since, hot_since === null ? null : hot_since_seq!, thread_id);
734
738
  }
735
739
 
736
- // Called by moveThreadContextBoundary whenever the user explicitly moves the
737
- // boundary (drag, or manual /compact) — never by the auto-detector's own
738
- // commit path, which calls setThreadContextPin directly. `untilMessageCount`
739
- // is a one-time write; the read side (lib/agents/run-thread.ts) just compares
740
- // against it, no per-turn write needed.
740
+ // Armed by moveThreadContextBoundary whenever the user explicitly moves the
741
+ // boundary (drag, or manual /compact), and by the auto-detector's hot-turn
742
+ // cooldown when a compaction pass ends without a commit (a commit writes the
743
+ // lock in the same transaction). `untilMessageCount` is a one-time write; the
744
+ // read side (lib/agents/run-thread.ts) just compares against it.
741
745
  export function setAutoBoundaryLock(thread_id: string, untilMessageCount: number): void {
742
746
  getDb()
743
747
  .prepare("UPDATE threads SET auto_boundary_locked_until_msg_count=? WHERE thread_id=?")