@circuitwall/jarela 1.2.0 → 1.3.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 (90) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +2 -2
  3. package/.next/standalone/.next/prerender-manifest.json +3 -3
  4. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  5. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  6. package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
  7. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  11. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  12. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  13. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  14. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  15. package/.next/standalone/.next/server/app/_not-found.rsc +1 -1
  16. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  17. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  18. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  19. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  20. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  21. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  22. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/chats/route.js +3 -3
  23. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/chats/route.js.nft.json +1 -1
  24. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/lookup/route.js +3 -3
  25. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/lookup/route.js.nft.json +1 -1
  26. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/pair/route.js +3 -3
  27. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/pair/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/route.js +3 -3
  29. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/status/route.js +3 -3
  31. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/status/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/api/v1/builtin-tools/route.js +218 -7
  33. package/.next/standalone/.next/server/app/api/v1/builtin-tools/route.js.map +1 -1
  34. package/.next/standalone/.next/server/app/api/v1/events/route.js +3 -3
  35. package/.next/standalone/.next/server/app/api/v1/events/route.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/api/v1/extension/agents/route.js +8 -1
  37. package/.next/standalone/.next/server/app/api/v1/extension/agents/route.js.map +1 -1
  38. package/.next/standalone/.next/server/app/api/v1/extension/fill/route.js +8 -1
  39. package/.next/standalone/.next/server/app/api/v1/extension/fill/route.js.map +1 -1
  40. package/.next/standalone/.next/server/app/api/v1/extension/refine/route.js +8 -1
  41. package/.next/standalone/.next/server/app/api/v1/extension/refine/route.js.map +1 -1
  42. package/.next/standalone/.next/server/app/api/v1/extension/turn/route.js +8 -1
  43. package/.next/standalone/.next/server/app/api/v1/extension/turn/route.js.map +1 -1
  44. package/.next/standalone/.next/server/app/api/v1/extensions/route.js +2 -2
  45. package/.next/standalone/.next/server/app/api/v1/extensions/tools/[name]/secrets/route.js +2 -2
  46. package/.next/standalone/.next/server/app/api/v1/tools/route.js +2 -2
  47. package/.next/standalone/.next/server/app/page.js +0 -16
  48. package/.next/standalone/.next/server/app/page.js.map +1 -1
  49. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  50. package/.next/standalone/.next/server/app/setup/page_client-reference-manifest.js +1 -1
  51. package/.next/standalone/.next/server/chunks/210.js +1 -1
  52. package/.next/standalone/.next/server/chunks/239.js +5335 -5230
  53. package/.next/standalone/.next/server/chunks/239.js.map +1 -1
  54. package/.next/standalone/.next/server/chunks/{1683.js → 241.js} +210 -36
  55. package/.next/standalone/.next/server/chunks/241.js.map +1 -0
  56. package/.next/standalone/.next/server/chunks/{8135.js → 2539.js} +218 -36
  57. package/.next/standalone/.next/server/chunks/2539.js.map +1 -0
  58. package/.next/standalone/.next/server/chunks/4631.js +218 -7
  59. package/.next/standalone/.next/server/chunks/4631.js.map +1 -1
  60. package/.next/standalone/.next/server/chunks/8866.js +13389 -13073
  61. package/.next/standalone/.next/server/chunks/8866.js.map +1 -1
  62. package/.next/standalone/.next/server/chunks/9032.js +1 -1
  63. package/.next/standalone/.next/server/chunks/9032.js.map +1 -1
  64. package/.next/standalone/.next/server/middleware-build-manifest.js +2 -2
  65. package/.next/standalone/.next/server/pages/404.html +1 -1
  66. package/.next/standalone/.next/server/pages/500.html +1 -1
  67. package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
  68. package/.next/standalone/.next/static/chunks/app/{page-62e0d5f2404b403b.js → page-2ab710949b62a638.js} +1 -17
  69. package/.next/standalone/.next/static/chunks/app/page-2ab710949b62a638.js.map +1 -0
  70. package/.next/standalone/package.json +1 -1
  71. package/CHANGELOG.md +74 -0
  72. package/components/ui/BootScreen.tsx +0 -10
  73. package/lib/agents/agent-turn.ts +9 -0
  74. package/lib/agents/prepare/request.ts +9 -0
  75. package/lib/agents/run-thread.ts +9 -1
  76. package/lib/api/extension-turn.ts +7 -0
  77. package/lib/bridges/attachment-store.test.ts +440 -0
  78. package/lib/bridges/attachment-store.ts +184 -0
  79. package/lib/bridges/whatsapp.ts +50 -32
  80. package/lib/tools/async-results-tool.ts +114 -0
  81. package/lib/tools/async-results.test.ts +481 -0
  82. package/lib/tools/async-results.ts +165 -0
  83. package/lib/tools/builtins.ts +1 -0
  84. package/lib/tools/wallclock.ts +114 -8
  85. package/package.json +1 -1
  86. package/.next/standalone/.next/server/chunks/1683.js.map +0 -1
  87. package/.next/standalone/.next/server/chunks/8135.js.map +0 -1
  88. package/.next/standalone/.next/static/chunks/app/page-62e0d5f2404b403b.js.map +0 -1
  89. /package/.next/standalone/.next/static/{2xWP8843jbntFGKLnHK6R → ZKy7LJ3KXj2TIyKOg_fBH}/_buildManifest.js +0 -0
  90. /package/.next/standalone/.next/static/{2xWP8843jbntFGKLnHK6R → ZKy7LJ3KXj2TIyKOg_fBH}/_ssgManifest.js +0 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@circuitwall/jarela",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Jarela — local chat interface for LangGraph agents (multi-provider, single-process, SQLite-backed).",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Andrew Ge Wu",
package/CHANGELOG.md CHANGED
@@ -7,6 +7,80 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.3.0] - 2026-06-08
11
+
12
+ Two new agent capabilities and a hardening pass on tool wall-clocks.
13
+ Bridge adapters (WhatsApp today) now spill large remote attachments
14
+ to a local store instead of inlining them into the LLM context, and
15
+ the agent picks them up by path through ``file_read``. Long-running
16
+ tool calls can now be fired asynchronously: the LLM gets a tracking
17
+ key back immediately and pulls the result later via a new built-in.
18
+
19
+ ### Added
20
+
21
+ - **Bridge attachment spill store**
22
+ ([#215](https://github.com/CircuitWall/jarela/pull/215)). Inbound
23
+ bridge messages no longer base64-inline every document, voice note,
24
+ audio, or video into the next prompt. Buffers are persisted under
25
+ ``<dataDir>/bridge-attachments/<bridge>/<YYYY-MM-DD>/<id>-<name>``
26
+ with sanitised paths, an SHA-256, and a future-facing
27
+ ``pruneBridgeAttachments({ maxAgeMs })`` helper; the prompt body
28
+ carries a text pointer telling the agent to use ``file_read`` to
29
+ inspect the contents. Images and stickers ≤ 1 MB still inline so
30
+ vision works out of the box.
31
+ - **Async tool execution (``async_run`` wrapper + ``tool_result_get``)**
32
+ ([#216](https://github.com/CircuitWall/jarela/pull/216)). Every
33
+ tool's schema now exposes an optional ``async_run: boolean``. When
34
+ set, the wrapper returns ``{ok, async, key, tool, started_at,
35
+ deadline_ms, hint}`` immediately and runs the work detached; the
36
+ LLM picks the result up via the new built-in
37
+ ``tool_result_get(key, wait_ms?, consume?)``. ``tool_result_list``
38
+ returns summaries without dumping result bodies. In-process store
39
+ with a 10-minute TTL and a 256-entry cap (oldest finished evicted
40
+ first, then oldest pending with a warn).
41
+
42
+ ### Changed
43
+
44
+ - **Hard ceiling on tool ``deadline_ms``**
45
+ ([#216](https://github.com/CircuitWall/jarela/pull/216)). The
46
+ wall-clock budget the LLM can pick is now clamped to 30 minutes by
47
+ default. Values above the ceiling are clamped and a one-line
48
+ ``console.warn`` is emitted naming the tool, the requested value,
49
+ and the ceiling. Operators can raise or lower the cap with the new
50
+ ``JARELA_TOOL_MAX_DEADLINE_MS`` environment variable (integer
51
+ milliseconds). Applies to both sync and ``async_run`` paths.
52
+
53
+ ### Fixed
54
+
55
+ - **E2E menu specs no longer race the boot agent picker**
56
+ ([#217](https://github.com/CircuitWall/jarela/pull/217)). Three
57
+ Playwright specs (``layout``, ``credentials``, ``setup-reorg``)
58
+ were intermittently failing because the BootScreen overlay
59
+ intercepted clicks on the header menu button. A new
60
+ ``waitForAppReady(page)`` helper picks the default agent tile and
61
+ waits for the overlay to detach before the test drives the UI.
62
+
63
+ ### Configuration
64
+
65
+ - ``JARELA_TOOL_MAX_DEADLINE_MS`` — overrides the per-tool
66
+ wall-clock ceiling (default 1800000 ms / 30 min). Set to a smaller
67
+ value to tighten the cap, or larger if a regulated workload genuinely
68
+ needs long synchronous calls.
69
+
70
+ Two follow-up fixes on top of 1.2.0.
71
+
72
+ ### Fixed
73
+
74
+ - **Boot agent picker always shows after login**
75
+ ([#213](https://github.com/CircuitWall/jarela/pull/213)). The picker
76
+ was being skipped in some session states; it now reliably appears so
77
+ the user actively chooses an agent at boot instead of silently
78
+ inheriting one.
79
+ - **Extension UX polish on one-shot turns**
80
+ ([#212](https://github.com/CircuitWall/jarela/pull/212)). Custom
81
+ intent collapses by default, Enter submits, writes are queued, and
82
+ one-shot turns drop the quality gates that didn't apply to them.
83
+
10
84
  ## [1.2.0] - 2026-06-08
11
85
 
12
86
  Security, runtime resilience, and a broad UI consolidation pass.
@@ -150,16 +150,6 @@ export function BootScreen({ agents, agentsLoaded, activeAgentId, onPickAgent, s
150
150
  };
151
151
  }, [activeAgentId, pickedId, agentsLoaded, markStep]);
152
152
 
153
- // Returning users with a saved default skip the manual tile-click.
154
- useEffect(() => {
155
- if (suppressed) return;
156
- if (!agentsLoaded) return;
157
- if (activeAgentId || pickedId) return;
158
- if (!defaultAgent) return;
159
- setPickedId(defaultAgent.id);
160
- onPickAgent(defaultAgent.id);
161
- }, [suppressed, agentsLoaded, activeAgentId, pickedId, defaultAgent, onPickAgent]);
162
-
163
153
  if (done) return null;
164
154
  if (suppressed) return null;
165
155
 
@@ -41,6 +41,14 @@ export interface RunAgentTurnRequest {
41
41
  * the category default.
42
42
  */
43
43
  context_profile_override?: Partial<TurnContextProfile> | null;
44
+
45
+ /**
46
+ * Skip the stall-retry + strict-citation audit wrapper. One-shot
47
+ * callers (browser-extension fill / rewrite) want the raw assistant
48
+ * text without the `↻` separator or pre-retry stall prose that the
49
+ * wrapper would otherwise inject into the streamed content.
50
+ */
51
+ disable_quality_gates?: boolean;
44
52
  }
45
53
 
46
54
  export interface RunAgentTurnResult {
@@ -76,6 +84,7 @@ export async function runAgentTurn(req: RunAgentTurnRequest): Promise<RunAgentTu
76
84
  attachments: req.attachments,
77
85
  user_category: req.user_category ?? null,
78
86
  context_profile: contextProfile,
87
+ disable_quality_gates: req.disable_quality_gates,
79
88
  signal: active.abort.signal,
80
89
  _pinned_model_config_name: pinnedModelConfigName,
81
90
  _skip_persist_message: req.skip_persist_user_message,
@@ -41,6 +41,15 @@ export interface ThreadRunRequest {
41
41
  */
42
42
  context_profile?: TurnContextProfile;
43
43
 
44
+ /**
45
+ * Skip the post-stream stall-retry + strict-citation audit wrapper for
46
+ * this turn. Use for one-shot callers (browser-extension fill / rewrite)
47
+ * that consume `assistantContent` as raw text and would otherwise type
48
+ * the visible `↻` separator and the original stalled prose into the
49
+ * user's input field. Chat callers leave undefined.
50
+ */
51
+ disable_quality_gates?: boolean;
52
+
44
53
  /**
45
54
  * Internal - public callers leave undefined. When set by the submission
46
55
  * path, this freezes the effective model config for the turn so queued
@@ -292,8 +292,16 @@ export async function prepareThreadRun(req: ThreadRunRequest): Promise<PreparedT
292
292
  // Overhead = the assembled system prompt + per-message scaffolding, which
293
293
  // is more accurate than the budget's static overhead allowance.
294
294
  const overheadTokens = estimateTokens(systemPrompt);
295
+ // One-shot callers (extension fill/rewrite) consume `assistantContent` as
296
+ // raw text. The stall-retry wrapper would otherwise leak the `↻` separator
297
+ // and the pre-retry stalled prose into the user's input field, and the
298
+ // strict-citation audit (which lives inside the same wrapper) would do
299
+ // the same with retry continuations. Bypass it entirely for those callers.
300
+ const stream = req.disable_quality_gates
301
+ ? rawStream
302
+ : stallRetryStream(rawStream, req, allowedTools, retriesLeft);
295
303
  return {
296
- stream: stallRetryStream(rawStream, req, allowedTools, retriesLeft),
304
+ stream,
297
305
  thread_id: req.thread_id,
298
306
  context_snapshot: {
299
307
  context_window_tokens: historyWindow.budget.contextWindowTokens,
@@ -91,6 +91,13 @@ async function runExtensionAction(action: z.infer<typeof ExtensionAction>, input
91
91
  message: prompt,
92
92
  user_category: "extension",
93
93
  assistant_category: "extension",
94
+ // The extension types `assistantContent` directly into the user's
95
+ // input field. The stall-retry wrapper and the strict-citation audit
96
+ // would otherwise inject the `↻` separator, the original stalled
97
+ // prose, and audit-retry continuations into that text — pollution
98
+ // the user then has to manually scrub. Both gates are chat
99
+ // affordances; skip them for one-shot writes.
100
+ disable_quality_gates: true,
94
101
  });
95
102
 
96
103
  // Ping the events bus so any open chat view on this thread re-fetches.
@@ -0,0 +1,440 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdtempSync, rmSync, existsSync, statSync, utimesSync, mkdirSync, writeFileSync } from "node:fs";
3
+ import { readFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import path from "node:path";
6
+ import crypto from "node:crypto";
7
+
8
+ const tmpRoot = mkdtempSync(path.join(tmpdir(), "jarela-bridge-att-"));
9
+ process.env.JARELA_DB_DIR = tmpRoot;
10
+
11
+ // Imported lazily so the JARELA_DB_DIR override above is in place before
12
+ // getDataDir() caches its result.
13
+ const {
14
+ saveBridgeAttachment,
15
+ pruneBridgeAttachments,
16
+ shouldInline,
17
+ bridgeAttachmentsRoot,
18
+ DEFAULT_INLINE_LIMIT_BYTES,
19
+ BRIDGE_ATTACHMENTS_DIRNAME,
20
+ INLINE_MIME_PREFIXES,
21
+ } = await import("./attachment-store");
22
+
23
+ beforeEach(() => {
24
+ // Clear any prior attachments between tests.
25
+ try {
26
+ rmSync(bridgeAttachmentsRoot(), { recursive: true, force: true });
27
+ } catch { /* */ }
28
+ });
29
+
30
+ afterEach(() => {
31
+ try {
32
+ rmSync(bridgeAttachmentsRoot(), { recursive: true, force: true });
33
+ } catch { /* */ }
34
+ });
35
+
36
+ describe("shouldInline", () => {
37
+ it("inlines small images", () => {
38
+ expect(shouldInline("image/jpeg", 64_000)).toBe(true);
39
+ expect(shouldInline("image/webp", DEFAULT_INLINE_LIMIT_BYTES)).toBe(true);
40
+ expect(shouldInline("image/png", 0)).toBe(true);
41
+ });
42
+
43
+ it("spills oversized images", () => {
44
+ expect(shouldInline("image/png", DEFAULT_INLINE_LIMIT_BYTES + 1)).toBe(false);
45
+ });
46
+
47
+ it("spills non-image media regardless of size", () => {
48
+ expect(shouldInline("application/pdf", 1024)).toBe(false);
49
+ expect(shouldInline("audio/ogg", 1024)).toBe(false);
50
+ expect(shouldInline("video/mp4", 1024)).toBe(false);
51
+ expect(shouldInline("text/plain", 100)).toBe(false);
52
+ });
53
+
54
+ it("honours a caller-supplied limit override", () => {
55
+ expect(shouldInline("image/jpeg", 5_000, 10_000)).toBe(true);
56
+ expect(shouldInline("image/jpeg", 15_000, 10_000)).toBe(false);
57
+ });
58
+
59
+ it("only inlines media types whose prefix is in the allowlist", () => {
60
+ // Sanity-pin the policy so a future change here is intentional.
61
+ expect(INLINE_MIME_PREFIXES).toEqual(["image/"]);
62
+ });
63
+ });
64
+
65
+ describe("saveBridgeAttachment", () => {
66
+ it("writes the buffer under <data>/bridge-attachments/<bridge>/<date>/", async () => {
67
+ const buf = Buffer.from("hello world");
68
+ const saved = await saveBridgeAttachment({
69
+ bridge_id: "b1",
70
+ filename: "hello.txt",
71
+ media_type: "text/plain",
72
+ message_id: "msg-1",
73
+ buffer: buf,
74
+ });
75
+ expect(existsSync(saved.abs_path)).toBe(true);
76
+ expect(saved.size).toBe(buf.length);
77
+ expect(saved.sha256).toMatch(/^[0-9a-f]{64}$/);
78
+ const onDisk = await readFile(saved.abs_path);
79
+ expect(onDisk.equals(buf)).toBe(true);
80
+ // Path must live under the bridge-specific subtree.
81
+ expect(saved.abs_path.startsWith(path.join(bridgeAttachmentsRoot(), "b1"))).toBe(true);
82
+ });
83
+
84
+ it("sha256 matches an independent hash of the buffer", async () => {
85
+ const buf = crypto.randomBytes(4096);
86
+ const saved = await saveBridgeAttachment({
87
+ bridge_id: "b1",
88
+ filename: "rand.bin",
89
+ media_type: "application/octet-stream",
90
+ buffer: buf,
91
+ });
92
+ const expected = crypto.createHash("sha256").update(buf).digest("hex");
93
+ expect(saved.sha256).toBe(expected);
94
+ });
95
+
96
+ it("uses YYYY-MM-DD format for the day directory", async () => {
97
+ const saved = await saveBridgeAttachment({
98
+ bridge_id: "b1",
99
+ filename: "f.bin",
100
+ media_type: "application/octet-stream",
101
+ message_id: "m",
102
+ buffer: Buffer.from("x"),
103
+ });
104
+ const dayDir = path.basename(path.dirname(saved.abs_path));
105
+ expect(dayDir).toMatch(/^\d{4}-\d{2}-\d{2}$/);
106
+ });
107
+
108
+ it("sanitises path separators and control chars in filenames", async () => {
109
+ const buf = Buffer.from("payload");
110
+ const saved = await saveBridgeAttachment({
111
+ bridge_id: "b1",
112
+ filename: "../../etc/passwd",
113
+ media_type: "text/plain",
114
+ message_id: "msg-evil",
115
+ buffer: buf,
116
+ });
117
+ const rel = path.relative(bridgeAttachmentsRoot(), saved.abs_path);
118
+ expect(rel.startsWith("..")).toBe(false);
119
+ expect(path.basename(saved.abs_path)).not.toContain("/");
120
+ expect(path.basename(saved.abs_path)).not.toContain("\\");
121
+ // The "passwd" leaf survives but it's just a filename, not a traversal.
122
+ expect(saved.abs_path.includes("etc" + path.sep + "passwd")).toBe(false);
123
+ });
124
+
125
+ it("strips Windows-hostile filename characters (* ? \" < > | :)", async () => {
126
+ const saved = await saveBridgeAttachment({
127
+ bridge_id: "b1",
128
+ filename: 'a*b?c"d<e>f|g:h.txt',
129
+ media_type: "text/plain",
130
+ message_id: "m",
131
+ buffer: Buffer.from("x"),
132
+ });
133
+ const base = path.basename(saved.abs_path);
134
+ expect(base).not.toMatch(/[*?"<>|:]/);
135
+ });
136
+
137
+ it("strips ASCII control characters from filenames", async () => {
138
+ const saved = await saveBridgeAttachment({
139
+ bridge_id: "b1",
140
+ filename: "a\x00b\x07c\x1f.bin",
141
+ media_type: "application/octet-stream",
142
+ message_id: "m",
143
+ buffer: Buffer.from("x"),
144
+ });
145
+ const base = path.basename(saved.abs_path);
146
+ // No control chars should leak through.
147
+ expect(/[\x00-\x1f]/.test(base)).toBe(false);
148
+ });
149
+
150
+ it("truncates very long filenames while preserving the extension", async () => {
151
+ const longName = "x".repeat(500) + ".pdf";
152
+ const saved = await saveBridgeAttachment({
153
+ bridge_id: "b1",
154
+ filename: longName,
155
+ media_type: "application/pdf",
156
+ message_id: "m",
157
+ buffer: Buffer.from("x"),
158
+ });
159
+ const base = path.basename(saved.abs_path);
160
+ // <id>-<truncated>; <truncated> portion is ≤ 80 chars.
161
+ const truncated = base.replace(/^m-/, "");
162
+ expect(truncated.length).toBeLessThanOrEqual(80);
163
+ expect(truncated.endsWith(".pdf")).toBe(true);
164
+ });
165
+
166
+ it("sanitises a hostile bridge_id", async () => {
167
+ const buf = Buffer.from("x");
168
+ const saved = await saveBridgeAttachment({
169
+ bridge_id: "../etc",
170
+ filename: "f.bin",
171
+ media_type: "application/octet-stream",
172
+ message_id: "m",
173
+ buffer: buf,
174
+ });
175
+ const rel = path.relative(bridgeAttachmentsRoot(), saved.abs_path);
176
+ expect(rel.startsWith("..")).toBe(false);
177
+ // The traversal segment "../etc" collapses to "___etc" (3 chars replaced).
178
+ const bridgeDir = rel.split(path.sep)[0];
179
+ expect(bridgeDir).not.toContain("..");
180
+ expect(bridgeDir).not.toContain("/");
181
+ });
182
+
183
+ it("sanitises a hostile message_id (path traversal characters)", async () => {
184
+ const saved = await saveBridgeAttachment({
185
+ bridge_id: "b1",
186
+ filename: "f.bin",
187
+ media_type: "application/octet-stream",
188
+ message_id: "../../escape",
189
+ buffer: Buffer.from("x"),
190
+ });
191
+ const rel = path.relative(bridgeAttachmentsRoot(), saved.abs_path);
192
+ expect(rel.startsWith("..")).toBe(false);
193
+ });
194
+
195
+ it("generates a filename when none is given", async () => {
196
+ const saved = await saveBridgeAttachment({
197
+ bridge_id: "b1",
198
+ filename: null,
199
+ media_type: "application/octet-stream",
200
+ buffer: Buffer.from("x"),
201
+ });
202
+ expect(path.basename(saved.abs_path)).toMatch(/attachment$/);
203
+ });
204
+
205
+ it("generates a filename when one is whitespace-only", async () => {
206
+ const saved = await saveBridgeAttachment({
207
+ bridge_id: "b1",
208
+ filename: " \t\n ",
209
+ media_type: "application/octet-stream",
210
+ buffer: Buffer.from("x"),
211
+ });
212
+ expect(path.basename(saved.abs_path)).toMatch(/attachment$/);
213
+ });
214
+
215
+ it("generates a random id when message_id is missing", async () => {
216
+ const a = await saveBridgeAttachment({
217
+ bridge_id: "b1",
218
+ filename: "f.bin",
219
+ media_type: "application/octet-stream",
220
+ buffer: Buffer.from("a"),
221
+ });
222
+ const b = await saveBridgeAttachment({
223
+ bridge_id: "b1",
224
+ filename: "f.bin",
225
+ media_type: "application/octet-stream",
226
+ buffer: Buffer.from("b"),
227
+ });
228
+ expect(path.basename(a.abs_path)).not.toBe(path.basename(b.abs_path));
229
+ });
230
+
231
+ it("is idempotent on (bridge_id, message_id, filename) — re-save overwrites", async () => {
232
+ const first = await saveBridgeAttachment({
233
+ bridge_id: "b1",
234
+ filename: "doc.pdf",
235
+ media_type: "application/pdf",
236
+ message_id: "M1",
237
+ buffer: Buffer.from("first"),
238
+ });
239
+ const second = await saveBridgeAttachment({
240
+ bridge_id: "b1",
241
+ filename: "doc.pdf",
242
+ media_type: "application/pdf",
243
+ message_id: "M1",
244
+ buffer: Buffer.from("second-longer"),
245
+ });
246
+ expect(first.abs_path).toBe(second.abs_path);
247
+ const onDisk = await readFile(first.abs_path);
248
+ expect(onDisk.toString("utf8")).toBe("second-longer");
249
+ expect(second.size).toBe(Buffer.byteLength("second-longer"));
250
+ });
251
+
252
+ it("isolates bridges from each other in separate subtrees", async () => {
253
+ const a = await saveBridgeAttachment({
254
+ bridge_id: "bridge-a",
255
+ filename: "x.bin",
256
+ media_type: "application/octet-stream",
257
+ message_id: "m",
258
+ buffer: Buffer.from("a"),
259
+ });
260
+ const b = await saveBridgeAttachment({
261
+ bridge_id: "bridge-b",
262
+ filename: "x.bin",
263
+ media_type: "application/octet-stream",
264
+ message_id: "m",
265
+ buffer: Buffer.from("b"),
266
+ });
267
+ expect(path.dirname(path.dirname(a.abs_path))).not.toBe(path.dirname(path.dirname(b.abs_path)));
268
+ expect(a.abs_path).not.toBe(b.abs_path);
269
+ });
270
+
271
+ it("handles a zero-byte buffer", async () => {
272
+ const saved = await saveBridgeAttachment({
273
+ bridge_id: "b1",
274
+ filename: "empty.bin",
275
+ media_type: "application/octet-stream",
276
+ message_id: "m",
277
+ buffer: Buffer.alloc(0),
278
+ });
279
+ expect(saved.size).toBe(0);
280
+ expect(existsSync(saved.abs_path)).toBe(true);
281
+ const stat = statSync(saved.abs_path);
282
+ expect(stat.size).toBe(0);
283
+ });
284
+
285
+ it("handles a binary buffer with non-utf8 bytes losslessly", async () => {
286
+ const buf = Buffer.from([0x00, 0xff, 0xfe, 0xfd, 0x80, 0x81]);
287
+ const saved = await saveBridgeAttachment({
288
+ bridge_id: "b1",
289
+ filename: "bin.dat",
290
+ media_type: "application/octet-stream",
291
+ message_id: "m",
292
+ buffer: buf,
293
+ });
294
+ const onDisk = await readFile(saved.abs_path);
295
+ expect(onDisk.equals(buf)).toBe(true);
296
+ });
297
+
298
+ it("survives concurrent saves to the same bridge", async () => {
299
+ const saves = Array.from({ length: 20 }, (_, i) =>
300
+ saveBridgeAttachment({
301
+ bridge_id: "b1",
302
+ filename: `f-${i}.bin`,
303
+ media_type: "application/octet-stream",
304
+ message_id: `m-${i}`,
305
+ buffer: Buffer.from(`payload-${i}`),
306
+ }),
307
+ );
308
+ const results = await Promise.all(saves);
309
+ const paths = new Set(results.map((r) => r.abs_path));
310
+ expect(paths.size).toBe(20);
311
+ for (const r of results) {
312
+ expect(existsSync(r.abs_path)).toBe(true);
313
+ }
314
+ });
315
+
316
+ it("exposes a constants surface used by callers", () => {
317
+ expect(BRIDGE_ATTACHMENTS_DIRNAME).toBe("bridge-attachments");
318
+ expect(DEFAULT_INLINE_LIMIT_BYTES).toBe(1 * 1024 * 1024);
319
+ expect(bridgeAttachmentsRoot().endsWith(BRIDGE_ATTACHMENTS_DIRNAME)).toBe(true);
320
+ });
321
+ });
322
+
323
+ describe("pruneBridgeAttachments", () => {
324
+ it("removes files older than maxAgeMs and keeps fresh ones", async () => {
325
+ const old = await saveBridgeAttachment({
326
+ bridge_id: "b1",
327
+ filename: "old.bin",
328
+ media_type: "application/octet-stream",
329
+ message_id: "old",
330
+ buffer: Buffer.from("aaa"),
331
+ });
332
+ const fresh = await saveBridgeAttachment({
333
+ bridge_id: "b1",
334
+ filename: "fresh.bin",
335
+ media_type: "application/octet-stream",
336
+ message_id: "fresh",
337
+ buffer: Buffer.from("bbb"),
338
+ });
339
+ // Backdate the old file by 2 hours.
340
+ const past = (Date.now() - 2 * 60 * 60 * 1000) / 1000;
341
+ utimesSync(old.abs_path, past, past);
342
+
343
+ const res = await pruneBridgeAttachments({ maxAgeMs: 60 * 60 * 1000 });
344
+ expect(res.removed_files).toBe(1);
345
+ expect(res.freed_bytes).toBe(3); // "aaa".length
346
+ expect(existsSync(old.abs_path)).toBe(false);
347
+ expect(existsSync(fresh.abs_path)).toBe(true);
348
+ });
349
+
350
+ it("removes empty day and bridge directories after purging files", async () => {
351
+ const saved = await saveBridgeAttachment({
352
+ bridge_id: "ghost-bridge",
353
+ filename: "only.bin",
354
+ media_type: "application/octet-stream",
355
+ message_id: "m",
356
+ buffer: Buffer.from("x"),
357
+ });
358
+ const past = (Date.now() - 24 * 60 * 60 * 1000) / 1000;
359
+ utimesSync(saved.abs_path, past, past);
360
+
361
+ const res = await pruneBridgeAttachments({ maxAgeMs: 60 * 60 * 1000 });
362
+ expect(res.removed_files).toBe(1);
363
+ expect(res.removed_dirs).toBeGreaterThanOrEqual(2); // day dir + bridge dir
364
+ expect(existsSync(path.dirname(saved.abs_path))).toBe(false);
365
+ expect(existsSync(path.dirname(path.dirname(saved.abs_path)))).toBe(false);
366
+ });
367
+
368
+ it("keeps the bridge directory if any fresh files remain in any day folder", async () => {
369
+ const stale = await saveBridgeAttachment({
370
+ bridge_id: "mixed",
371
+ filename: "stale.bin",
372
+ media_type: "application/octet-stream",
373
+ message_id: "stale",
374
+ buffer: Buffer.from("x"),
375
+ });
376
+ const fresh = await saveBridgeAttachment({
377
+ bridge_id: "mixed",
378
+ filename: "fresh.bin",
379
+ media_type: "application/octet-stream",
380
+ message_id: "fresh",
381
+ buffer: Buffer.from("y"),
382
+ });
383
+ const past = (Date.now() - 24 * 60 * 60 * 1000) / 1000;
384
+ utimesSync(stale.abs_path, past, past);
385
+
386
+ await pruneBridgeAttachments({ maxAgeMs: 60 * 60 * 1000 });
387
+ expect(existsSync(fresh.abs_path)).toBe(true);
388
+ expect(existsSync(path.dirname(fresh.abs_path))).toBe(true);
389
+ });
390
+
391
+ it("is a no-op when the attachments dir does not exist", async () => {
392
+ const res = await pruneBridgeAttachments({ maxAgeMs: 1 });
393
+ expect(res).toEqual({ removed_files: 0, removed_dirs: 0, freed_bytes: 0 });
394
+ });
395
+
396
+ it("treats maxAgeMs=0 as 'expire everything finished'", async () => {
397
+ await saveBridgeAttachment({
398
+ bridge_id: "b1",
399
+ filename: "a.bin",
400
+ media_type: "application/octet-stream",
401
+ message_id: "a",
402
+ buffer: Buffer.from("a"),
403
+ });
404
+ // Backdate by 1ms so even maxAgeMs=0 expires it.
405
+ const saved = await saveBridgeAttachment({
406
+ bridge_id: "b1",
407
+ filename: "b.bin",
408
+ media_type: "application/octet-stream",
409
+ message_id: "b",
410
+ buffer: Buffer.from("b"),
411
+ });
412
+ const past = (Date.now() - 1000) / 1000;
413
+ utimesSync(saved.abs_path, past, past);
414
+ const res = await pruneBridgeAttachments({ maxAgeMs: 0 });
415
+ expect(res.removed_files).toBeGreaterThanOrEqual(1);
416
+ });
417
+
418
+ it("ignores non-file entries inside day folders", async () => {
419
+ // Manually create a stray subdirectory under a day folder — prune
420
+ // must not crash and must not delete it.
421
+ const saved = await saveBridgeAttachment({
422
+ bridge_id: "b1",
423
+ filename: "f.bin",
424
+ media_type: "application/octet-stream",
425
+ message_id: "m",
426
+ buffer: Buffer.from("x"),
427
+ });
428
+ const dayDir = path.dirname(saved.abs_path);
429
+ const strayDir = path.join(dayDir, "stray-subdir");
430
+ mkdirSync(strayDir);
431
+ writeFileSync(path.join(strayDir, "child.txt"), "x");
432
+ const past = (Date.now() - 24 * 60 * 60 * 1000) / 1000;
433
+ utimesSync(saved.abs_path, past, past);
434
+
435
+ const res = await pruneBridgeAttachments({ maxAgeMs: 60 * 60 * 1000 });
436
+ expect(res.removed_files).toBe(1);
437
+ expect(existsSync(strayDir)).toBe(true);
438
+ });
439
+ });
440
+