@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.
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/app-path-routes-manifest.json +4 -4
- package/.next/standalone/.next/build-manifest.json +2 -2
- package/.next/standalone/.next/prerender-manifest.json +3 -3
- package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +2 -2
- package/.next/standalone/.next/server/app/_not-found.rsc +2 -2
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/api/v1/access/[identity]/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/access/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/agents/[id]/compact/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/agents/[id]/display-filters/route.js +17 -5
- package/.next/standalone/.next/server/app/api/v1/agents/[id]/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/agents/[id]/thread/route.js +17 -5
- package/.next/standalone/.next/server/app/api/v1/agents/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/allowed-sites/[hostname]/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/allowed-sites/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/artifacts/lifecycle/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/chats/route.js +26 -32
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/chats/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/ignores/[remote_jid]/route.js +18 -7
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/ignores/route.js +18 -7
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/lookup/route.js +26 -32
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/lookup/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/pair/route.js +27 -33
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/pair/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/route.js +26 -32
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/routes/[route_id]/route.js +28 -12
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/routes/route.js +28 -12
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/status/route.js +26 -32
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/v1/bridges/kinds/route.js +9 -2
- package/.next/standalone/.next/server/app/api/v1/bridges/kinds/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/v1/bridges/route.js +19 -7
- package/.next/standalone/.next/server/app/api/v1/bridges/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/v1/dashboard/metrics/route.js +17 -5
- package/.next/standalone/.next/server/app/api/v1/dashboard/pricing/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/documents/search/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/documents/settings/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/env-sync/allowlist/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/env-sync/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/events/route.js +26 -32
- package/.next/standalone/.next/server/app/api/v1/events/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/v1/extension/browser/activate/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/extension/browser/foreground/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/extension/browser/history/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/extension/browser/poll/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/extension/browser/progress/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/extension/browser/result/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/extension/browser/retry/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/extension/browser/status/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/extension/browser/tabs/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/harnesses/[id]/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/harnesses/default/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/harnesses/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/health/route.js +17 -5
- package/.next/standalone/.next/server/app/api/v1/integrations/[name]/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/integrations/gmail/oauth/callback/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/integrations/gmail/oauth/start/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/integrations/gmail/oauth/status/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/integrations/outlook/oauth/callback/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/integrations/outlook/oauth/start/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/integrations/outlook/oauth/status/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/integrations/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/lifecycle/adoption/route.js +17 -5
- package/.next/standalone/.next/server/app/api/v1/maps/embed/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/mcp-servers/[name]/route.js +17 -5
- package/.next/standalone/.next/server/app/api/v1/mcp-servers/route.js +17 -5
- package/.next/standalone/.next/server/app/api/v1/memory/[namespace]/[key]/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/memory/policy/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/memory/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/memory/search/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/models/[name]/compact-threads/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/models/[name]/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/models/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/pending-actions/[id]/approve/route.js +27 -12
- package/.next/standalone/.next/server/app/api/v1/profile/location/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/profile/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/providers/[provider]/models/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/providers/[provider]/probe/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/providers/github-copilot/auth/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/providers/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/proxy-config/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/redaction/init/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/redaction/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/scheduled-tasks/[id]/route.js +17 -5
- package/.next/standalone/.next/server/app/api/v1/scheduled-tasks/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/security/idle-timeout/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/security/pin/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/security/state/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/security/unlock/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/security/verify-pin/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/skills/[id]/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/skills/repos/[id]/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/skills/repos/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/skills/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/tasks/[agent_id]/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/tasks/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/activity/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/context-pin/route.js +12 -6
- package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/run/route.js +0 -1
- package/.next/standalone/.next/server/app/api/v1/threads/route.js +17 -5
- package/.next/standalone/.next/server/app/api/v1/update/apply/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/v1/voice/transcribe/route.js +17 -5
- package/.next/standalone/.next/server/app/api/v1/voice/tts/route.js +17 -5
- package/.next/standalone/.next/server/app/api/v1/watchers/[id]/route.js +17 -5
- package/.next/standalone/.next/server/app/page.js +322 -275
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/setup/page.js +8 -0
- package/.next/standalone/.next/server/app/setup/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app-paths-manifest.json +4 -4
- package/.next/standalone/.next/server/chunks/1388.js +106 -36
- package/.next/standalone/.next/server/chunks/2068.js +47 -10
- package/.next/standalone/.next/server/chunks/210.js +6 -6
- package/.next/standalone/.next/server/chunks/2447.js +10 -5
- package/.next/standalone/.next/server/chunks/2471.js +17 -17
- package/.next/standalone/.next/server/chunks/3042.js +10 -8
- package/.next/standalone/.next/server/chunks/{4540.js → 309.js} +104 -38
- package/.next/standalone/.next/server/chunks/{2350.js → 3272.js} +104 -37
- package/.next/standalone/.next/server/chunks/3399.js +525 -525
- package/.next/standalone/.next/server/chunks/3524.js +150 -67
- package/.next/standalone/.next/server/chunks/4189.js +1 -1
- package/.next/standalone/.next/server/chunks/4508.js +2 -2
- package/.next/standalone/.next/server/chunks/46.js +73 -28
- package/.next/standalone/.next/server/chunks/5908.js +63 -42
- package/.next/standalone/.next/server/chunks/6142.js +10 -2
- package/.next/standalone/.next/server/chunks/6649.js +1 -1
- package/.next/standalone/.next/server/chunks/8461.js +10 -8
- package/.next/standalone/.next/server/chunks/9032.js +5 -5
- package/.next/standalone/.next/server/chunks/9164.js +435 -176
- package/.next/standalone/.next/server/chunks/9304.js +51 -12
- package/.next/standalone/.next/server/chunks/{423.js → 9590.js} +66 -21
- package/.next/standalone/.next/server/middleware-build-manifest.js +2 -2
- package/.next/standalone/.next/server/middleware.js +71 -18
- package/.next/standalone/.next/server/pages/404.html +2 -2
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/preview-props.json +3 -3
- package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/static/chunks/app/{page-213a623b37226452.js → page-04748847c6ff866f.js} +171 -132
- package/.next/standalone/.next/static/css/{312542a5725ba0d5.css → 2669e8744cc8c212.css} +2 -2
- package/.next/standalone/docs/adoption/changesets.json +49 -0
- package/.next/standalone/package.json +1 -1
- package/CHANGELOG.md +19 -0
- package/app/api/v1/threads/[thread_id]/run/route.ts +0 -1
- package/components/chat/MessageBubble.tsx +12 -0
- package/components/integrations/NetworkSection.tsx +2 -2
- package/lib/agents/citation-checker.test.ts +40 -2
- package/lib/agents/citation-checker.ts +56 -11
- package/lib/agents/context-boundary.ts +5 -6
- package/lib/agents/instruction-edits.test.ts +120 -0
- package/lib/agents/instruction-edits.ts +65 -42
- package/lib/agents/prepare/request.ts +0 -2
- package/lib/agents/prepare/system-prompt.test.ts +10 -0
- package/lib/agents/prepare/system-prompt.ts +8 -2
- package/lib/agents/proposals.test.ts +76 -0
- package/lib/agents/proposals.ts +13 -5
- package/lib/agents/run-thread.auto-boundary.test.ts +125 -5
- package/lib/agents/run-thread.ts +24 -9
- package/lib/agents/sqlite-checkpoint-saver.test.ts +91 -0
- package/lib/agents/warm-summary-background.ts +22 -2
- package/lib/agents/warm-topics.test.ts +155 -1
- package/lib/agents/warm-topics.ts +27 -7
- package/lib/bridges/dispatcher.ts +1 -0
- package/lib/bridges/message-role.test.ts +81 -0
- package/lib/bridges/message-role.ts +23 -10
- package/lib/bridges/types.ts +6 -0
- package/lib/bridges/whatsapp-link.test.ts +32 -0
- package/lib/bridges/whatsapp-link.ts +21 -0
- package/lib/bridges/whatsapp.inbound.test.ts +239 -0
- package/lib/bridges/whatsapp.sender.test.ts +80 -1
- package/lib/bridges/whatsapp.ts +76 -38
- package/lib/pricing/snapshot.test.ts +70 -0
- package/lib/pricing/snapshot.ts +10 -2
- package/lib/proxy/dispatcher.test.ts +119 -1
- package/lib/proxy/dispatcher.ts +22 -9
- package/lib/stores/agent-configs.ts +10 -5
- package/lib/stores/bridges.test.ts +81 -0
- package/lib/stores/bridges.ts +9 -8
- package/lib/stores/proxy-config.test.ts +120 -0
- package/lib/stores/proxy-config.ts +22 -4
- package/lib/stores/threads.ts +12 -8
- package/lib/tools/communications/calendar.test.ts +43 -2
- package/lib/tools/communications/calendar.ts +2 -0
- package/lib/tools/communications/gmail.test.ts +51 -1
- package/lib/tools/communications/gmail.ts +44 -2
- package/lib/tools/communications/outlook-calendar.ts +2 -0
- package/lib/tools/communications/outlook.test.ts +33 -2
- package/lib/tools/communications/outlook.ts +2 -0
- package/lib/tools/filesystem/edit-match.test.ts +133 -0
- package/lib/tools/filesystem/edit-match.ts +7 -2
- package/lib/tools/support/result-refs.test.ts +131 -0
- package/lib/tools/support/result-refs.ts +41 -9
- package/lib/utils/source-link.test.ts +51 -0
- package/lib/utils/source-link.ts +45 -0
- package/package.json +1 -1
- /package/.next/standalone/.next/static/{bMRK5y3ruT7c3WtBfCQOG → hwujW4hR1b-r3k5WdMxZO}/_buildManifest.js +0 -0
- /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)}&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
|
+
});
|
package/lib/pricing/snapshot.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
});
|
package/lib/proxy/dispatcher.ts
CHANGED
|
@@ -215,7 +215,18 @@ function buildProxyUrl(
|
|
|
215
215
|
const auth = username
|
|
216
216
|
? `${encodeURIComponent(username)}${password ? `:${encodeURIComponent(password)}` : ""}@`
|
|
217
217
|
: "";
|
|
218
|
-
|
|
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
|
-
|
|
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
|
-
|
|
241
|
-
|
|
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
|
|
256
|
+
const existingMbti = toMbti(existing?.adaptive_mbti);
|
|
257
|
+
const mbti = input.adaptive_mbti ?? existingMbti ?? "INTJ";
|
|
257
258
|
const preset = MBTI_PRESETS[mbti];
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
const
|
|
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
|
+
});
|
package/lib/stores/bridges.ts
CHANGED
|
@@ -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
|
-
//
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
.
|
|
273
|
-
|
|
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 (!
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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();
|
package/lib/stores/threads.ts
CHANGED
|
@@ -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
|
-
|
|
161
|
-
|
|
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
|
-
//
|
|
737
|
-
// boundary (drag, or manual /compact)
|
|
738
|
-
//
|
|
739
|
-
// is a one-time write; the
|
|
740
|
-
//
|
|
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=?")
|