@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.
- package/.next/standalone/.next/BUILD_ID +1 -1
- 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/_global-error.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_index.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 +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- 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/_not-found.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/chats/route.js +3 -3
- 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]/lookup/route.js +3 -3
- 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 +3 -3
- 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 +3 -3
- 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]/status/route.js +3 -3
- 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/builtin-tools/route.js +218 -7
- package/.next/standalone/.next/server/app/api/v1/builtin-tools/route.js.map +1 -1
- package/.next/standalone/.next/server/app/api/v1/events/route.js +3 -3
- package/.next/standalone/.next/server/app/api/v1/events/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/v1/extension/agents/route.js +8 -1
- package/.next/standalone/.next/server/app/api/v1/extension/agents/route.js.map +1 -1
- package/.next/standalone/.next/server/app/api/v1/extension/fill/route.js +8 -1
- package/.next/standalone/.next/server/app/api/v1/extension/fill/route.js.map +1 -1
- package/.next/standalone/.next/server/app/api/v1/extension/refine/route.js +8 -1
- package/.next/standalone/.next/server/app/api/v1/extension/refine/route.js.map +1 -1
- package/.next/standalone/.next/server/app/api/v1/extension/turn/route.js +8 -1
- package/.next/standalone/.next/server/app/api/v1/extension/turn/route.js.map +1 -1
- package/.next/standalone/.next/server/app/api/v1/extensions/route.js +2 -2
- package/.next/standalone/.next/server/app/api/v1/extensions/tools/[name]/secrets/route.js +2 -2
- package/.next/standalone/.next/server/app/api/v1/tools/route.js +2 -2
- package/.next/standalone/.next/server/app/page.js +0 -16
- package/.next/standalone/.next/server/app/page.js.map +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/setup/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/chunks/210.js +1 -1
- package/.next/standalone/.next/server/chunks/239.js +5335 -5230
- package/.next/standalone/.next/server/chunks/239.js.map +1 -1
- package/.next/standalone/.next/server/chunks/{1683.js → 241.js} +210 -36
- package/.next/standalone/.next/server/chunks/241.js.map +1 -0
- package/.next/standalone/.next/server/chunks/{8135.js → 2539.js} +218 -36
- package/.next/standalone/.next/server/chunks/2539.js.map +1 -0
- package/.next/standalone/.next/server/chunks/4631.js +218 -7
- package/.next/standalone/.next/server/chunks/4631.js.map +1 -1
- package/.next/standalone/.next/server/chunks/8866.js +13389 -13073
- package/.next/standalone/.next/server/chunks/8866.js.map +1 -1
- package/.next/standalone/.next/server/chunks/9032.js +1 -1
- package/.next/standalone/.next/server/chunks/9032.js.map +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +2 -2
- package/.next/standalone/.next/server/pages/404.html +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/static/chunks/app/{page-62e0d5f2404b403b.js → page-2ab710949b62a638.js} +1 -17
- package/.next/standalone/.next/static/chunks/app/page-2ab710949b62a638.js.map +1 -0
- package/.next/standalone/package.json +1 -1
- package/CHANGELOG.md +74 -0
- package/components/ui/BootScreen.tsx +0 -10
- package/lib/agents/agent-turn.ts +9 -0
- package/lib/agents/prepare/request.ts +9 -0
- package/lib/agents/run-thread.ts +9 -1
- package/lib/api/extension-turn.ts +7 -0
- package/lib/bridges/attachment-store.test.ts +440 -0
- package/lib/bridges/attachment-store.ts +184 -0
- package/lib/bridges/whatsapp.ts +50 -32
- package/lib/tools/async-results-tool.ts +114 -0
- package/lib/tools/async-results.test.ts +481 -0
- package/lib/tools/async-results.ts +165 -0
- package/lib/tools/builtins.ts +1 -0
- package/lib/tools/wallclock.ts +114 -8
- package/package.json +1 -1
- package/.next/standalone/.next/server/chunks/1683.js.map +0 -1
- package/.next/standalone/.next/server/chunks/8135.js.map +0 -1
- package/.next/standalone/.next/static/chunks/app/page-62e0d5f2404b403b.js.map +0 -1
- /package/.next/standalone/.next/static/{2xWP8843jbntFGKLnHK6R → ZKy7LJ3KXj2TIyKOg_fBH}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{2xWP8843jbntFGKLnHK6R → ZKy7LJ3KXj2TIyKOg_fBH}/_ssgManifest.js +0 -0
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
|
|
package/lib/agents/agent-turn.ts
CHANGED
|
@@ -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
|
package/lib/agents/run-thread.ts
CHANGED
|
@@ -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
|
|
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
|
+
|