mercury-agent 0.24.3-beta.0 → 0.24.3-beta.1

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.
@@ -2286,12 +2286,18 @@ The review of the seven 2026-10-02 fixes landed follow-ups on `dev`. Three baked
2286
2286
 
2287
2287
  ## Plan unattended-1003 / S6
2288
2288
 
2289
- The WhatsApp media download now closes its socket when a bound fires, through a Baileys patch (`patches/`). Host-only, nothing baked: a **sync** (which runs `bun install` because `package.json` and `bun.lock` changed) **and a restart**. No image rebuild.
2289
+ The WhatsApp media download now closes its socket when a bound fires, through a Baileys patch (`patches/`). *(Superseded 2026-10-07: the patch never reached npm-installed hosts, so it was removed and the media `fetch` is now owned by `fetchDecryptedMedia` — `docs/debug/moderate/2026-10-07-baileys-patch-never-reaches-an-npm-installed-host.md`. The greps below for `signal: options.signal` in `node_modules` now print 0 by design.)* Host-only, nothing baked: a **sync** (which runs `bun install` because `package.json` and `bun.lock` changed) **and a restart**. No image rebuild.
2290
2290
 
2291
2291
  - [x] *(Verified 2026-10-05 on the WSL box, dev `6ddb895`, base `mercury-agent:0.24.2-beta.2` = `63637858e06b`, derived `mercury-agent-ext:b2acae430168` (built 13:36:22 IDT, 482 s), restart 13:28:17 IDT: the sync ran `bun install` and the grep prints `1`.)* MANUAL-VERIFY(2026-10-04): After the sync, `grep -c -F "signal: options.signal" ~/mercury-src/node_modules/@whiskeysockets/baileys/lib/Utils/messages-media.js` prints 1. A 0 means `bun install` did not run or did not apply the patch, and the fix is not deployed even though the code is.
2292
2292
  - [x] *(Verified 2026-10-05 on the WSL box, dev `6ddb895`, base `mercury-agent:0.24.2-beta.2` = `63637858e06b`, derived `mercury-agent-ext:b2acae430168` (built 13:36:22 IDT, 482 s), restart 13:28:17 IDT. Rehearsed, not waited for: the deployed `downloadWhatsAppMedia` from `~/mercury-src`, with production bounds and its patched `node_modules`, against a local raw-TCP server. Open stall: `bound fired while opening` at 30.0 s, `download_failed` to `onSkipped`, server saw the socket close 2 ms later. Body stall after 64 KB: `read bound fired` (idle) at 30.0 s, `download_failed`, socket closed 1 ms later. The live-journal half is still worth a glance the next time a real download stalls.)* MANUAL-VERIFY(2026-10-04): The next time the journal shows `WhatsApp media read bound fired` or `bound fired while opening`, `ss -tnp | grep -c <bun pid>` taken a few seconds later shows no connection to the WhatsApp media host left open for that download, and the user was told the file could not be downloaded.
2293
2293
  - [x] *(Verified 2026-10-05: `docker build -f Dockerfile.host` from `~/mercury-src` at `6ddb895` exited 0, `COPY patches/` ran before `bun install --production`, and the built image's `node_modules/@whiskeysockets/baileys/lib/Utils/messages-media.js` has `signal: options.signal` (1). Throwaway tag removed.)* MANUAL-VERIFY(2026-10-04): If any box builds `Dockerfile.host`, its next build passes the `bun install --production` step (it now copies `patches/` first; without it the install fails with `Couldn't find patch file`).
2294
2294
 
2295
+ ## baileys-patch-never-reaches-an-npm-installed-host (2026-10-07)
2296
+
2297
+ The media download fetches and decrypts itself instead of through a patched Baileys. Host-only: a sync (`bun install`, since `package.json` and `bun.lock` changed) and a restart; no image rebuild.
2298
+
2299
+ - [ ] MANUAL-VERIFY(2026-10-07): After deploying, send the bot a photo, a voice note and a PDF in a group it answers in, and confirm it describes each one — real WhatsApp media must decrypt through the new path, which the tests only exercise with media they encrypted themselves.
2300
+
2295
2301
  ## Plan unattended-1003 / S7
2296
2302
 
2297
2303
  `pinchtab_nav` and the session injector now ship as files in the pinchtab extension's `bin/`. They reach the box through the installed snapshot, which is refreshed on restart, and from there into the **derived** image, whose hash includes the bin files. So this is a restart, then wait for the journal's `Built derived agent image` line. No base rebuild.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mercury-agent",
3
- "version": "0.24.3-beta.0",
3
+ "version": "0.24.3-beta.1",
4
4
  "description": "Personal AI assistant for chat platforms (WhatsApp, Slack, Discord, Telegram)",
5
5
  "license": "MIT",
6
6
  "author": "Avishai Tsabari",
@@ -151,8 +151,5 @@
151
151
  "ws": ">=8.20.1",
152
152
  "qs": ">=6.15.2",
153
153
  "lodash": ">=4.18.1"
154
- },
155
- "patchedDependencies": {
156
- "@whiskeysockets/baileys@7.0.0-rc14": "patches/@whiskeysockets%2Fbaileys@7.0.0-rc14.patch"
157
154
  }
158
155
  }
@@ -6,10 +6,17 @@
6
6
  * can be reused by other adapters.
7
7
  */
8
8
 
9
+ import { createDecipheriv } from "node:crypto";
9
10
  import path from "node:path";
10
- import type { Readable } from "node:stream";
11
+ import { Readable, Transform } from "node:stream";
12
+ import type { ReadableStream as WebReadableStream } from "node:stream/web";
11
13
  import {
12
- downloadMediaMessage,
14
+ type MediaType as BaileysMediaType,
15
+ DEFAULT_ORIGIN,
16
+ extractMessageContent,
17
+ getContentType,
18
+ getMediaKeys,
19
+ getUrlFromDirectPath,
13
20
  normalizeMessageContent,
14
21
  type proto,
15
22
  type WAMessage,
@@ -35,26 +42,6 @@ import type {
35
42
  MessageAttachment,
36
43
  } from "../types.js";
37
44
 
38
- const silentBaileysLogger: {
39
- level: string;
40
- child: () => typeof silentBaileysLogger;
41
- trace: () => void;
42
- debug: () => void;
43
- info: () => void;
44
- warn: () => void;
45
- error: () => void;
46
- fatal: () => void;
47
- } = {
48
- level: "silent",
49
- child: () => silentBaileysLogger,
50
- trace: () => undefined,
51
- debug: () => undefined,
52
- info: () => undefined,
53
- warn: () => undefined,
54
- error: () => undefined,
55
- fatal: () => undefined,
56
- };
57
-
58
45
  /**
59
46
  * Media info extracted from a WhatsApp message.
60
47
  * Used internally before download.
@@ -81,8 +68,8 @@ export interface MediaDownloadOptions {
81
68
  */
82
69
  onSkipped?: (info: DroppedAttachment) => void;
83
70
  /**
84
- * The byte stream for a message. Default: Baileys' `downloadMediaMessage`
85
- * over the socket, in stream mode. Injected only so the write legs below
71
+ * The byte stream for a message. Default: `fetchDecryptedMedia` — our
72
+ * `fetch` of the media URL, decrypted with Baileys' media keys. Injected only so the write legs below
86
73
  * can be exercised without a socket; production passes nothing.
87
74
  */
88
75
  download?: (
@@ -100,18 +87,23 @@ export interface MediaDownloadOptions {
100
87
  export interface MediaDownloadContext {
101
88
  /** Aborted on every exit but a clean end; this is what closes the socket. */
102
89
  signal: AbortSignal;
103
- /** Baileys' re-upload request, under the re-upload bound instead of the open one. */
90
+ /**
91
+ * The phone re-upload, for an injected download that asks for one (production
92
+ * never does; see `WHATSAPP_REUPLOAD_BOUND_MS`), under the re-upload bound.
93
+ */
104
94
  reuploadRequest: (msg: WAMessage) => Promise<WAMessage>;
105
95
  }
106
96
 
107
97
  // The time bounds (`MediaReadBounds`, `MEDIA_READ_BOUNDS`) live in
108
98
  // `core/media.ts`, shared with the URL legs so the two readers cannot drift.
109
- // Baileys' `fetch` carries no timeout, so without them a stalled connection
99
+ // The media `fetch` carries no timeout, so without them a stalled connection
110
100
  // holds the turn forever; here the idle bound is also what ends a body whose
111
101
  // connection died mid-download, because `pipe()` never tells the decrypt
112
102
  // Transform that its source errored (see `readStreamCapped`). A fired bound
113
- // closes the socket only through the request's `AbortSignal`, which Baileys
114
- // passes to `fetch` by our patch (`patches/@whiskeysockets%2Fbaileys@*.patch`).
103
+ // closes the socket only through the request's `AbortSignal`, which is why
104
+ // `fetchDecryptedMedia` owns the `fetch`: Baileys' `getHttpStream` takes no
105
+ // signal, and the dependency patch that once threaded one in never reached an
106
+ // npm-installed host (`patchedDependencies` applies to the root project only).
115
107
 
116
108
  /**
117
109
  * The bound on the phone re-upload Baileys requests when a media URL has
@@ -124,10 +116,12 @@ export interface MediaDownloadContext {
124
116
  * plain server round trip. Past it the turn reports `download_failed`, which
125
117
  * is the right answer for a phone that is offline.
126
118
  *
127
- * Unreachable on Baileys 7.0.0-rc14: it requests the re-upload only when
128
- * `error.status` is 404/410, and its own fetch error is a Boom carrying the
129
- * code in `output.statusCode`. `tests/whatsapp-media-socket-close.test.ts`
130
- * pins that fact, so a Baileys bump that makes the path live goes red there.
119
+ * Unreachable in production: `fetchDecryptedMedia` never requests a
120
+ * re-upload, which keeps the behaviour Baileys 7.0.0-rc14 had (it asked only
121
+ * when `error.status` was 404/410, and its own fetch error carried the code in
122
+ * `output.statusCode`, so never). An expired URL is `download_failed`.
123
+ * Making it live is a behaviour change that needs a real old quoted message
124
+ * to verify against; `tests/whatsapp-media-socket-close.test.ts` pins the 410.
131
125
  */
132
126
  export const WHATSAPP_REUPLOAD_BOUND_MS = 2 * 60_000;
133
127
 
@@ -173,8 +167,8 @@ function destroyWithSource(stream: Readable): void {
173
167
  * The returned promise is itself the race: whichever of end, error, the cap or
174
168
  * a bound comes first settles it, and every exit clears both timers.
175
169
  *
176
- * Baileys' stream is a decrypt `Transform` that the HTTP body is `pipe`d
177
- * into. Destroying the Transform alone only unpipes the body, and Bun's fetch
170
+ * The download's stream is a decrypt `Transform` that the HTTP body is
171
+ * `pipe`d into (`fetchDecryptedMedia`, the shape Baileys' own download had). Destroying the Transform alone only unpipes the body, and Bun's fetch
178
172
  * keeps buffering a body nobody reads (measured 2026-10-02: 591 MB RSS for a
179
173
  * 512 MB body, against 54 MB when the source is destroyed too). The source is
180
174
  * not exposed, so it is taken from the `unpipe` event every exit triggers:
@@ -188,13 +182,13 @@ function destroyWithSource(stream: Readable): void {
188
182
  * - **No early listener.** The destination of a `pipe()` holds no reference
189
183
  * to its source (the closures in `pipe` do), so the source cannot be
190
184
  * reached until it unpipes. That is safe only because the listener below is
191
- * attached synchronously on entry, and Baileys' path from
192
- * `fetched.pipe(output)` to our caller is promise resolutions only: `pipe`
185
+ * attached synchronously on entry, and `fetchDecryptedMedia`'s path from
186
+ * `body.pipe(decrypt)` to our caller is promise resolutions only: `pipe`
193
187
  * starts the flow with `process.nextTick`, which runs after the microtask
194
188
  * queue drains, so no chunk and no decrypt error can reach the Transform
195
189
  * before this listener exists — even with the body already buffered. A
196
- * macrotask inserted between Baileys' `pipe` and its `return` would open
197
- * that window; `tests/whatsapp-media-capped-read.test.ts` pins the shape.
190
+ * macrotask inserted between that `pipe` and its `return` would open that
191
+ * window; `tests/whatsapp-media-capped-read.test.ts` pins the shape.
198
192
  * - **A dead body is silent.** When the connection resets mid-body, Bun's
199
193
  * `Readable.fromWeb` source closes errored and emits no `'error'`, and
200
194
  * `pipe()` forwards neither, so the Transform simply stops receiving. No
@@ -290,7 +284,7 @@ export function readStreamCapped(
290
284
  }
291
285
 
292
286
  /**
293
- * The bound on Baileys' open: the media fetch up to its response headers, under
287
+ * The bound on the download's open: the media fetch up to its response headers, under
294
288
  * the idle bound, and a phone re-upload in between, under its own bound. One
295
289
  * timer, re-armed as the phase changes; `rejected` settles when it fires.
296
290
  */
@@ -342,6 +336,119 @@ function openBound(
342
336
  };
343
337
  }
344
338
 
339
+ /** WhatsApp media is AES-256-CBC in 16-byte blocks, followed by a 10-byte MAC. */
340
+ const AES_BLOCK = 16;
341
+
342
+ /** Thrown for a media URL that did not answer 2xx; the caller reports `download_failed`. */
343
+ export class MediaFetchError extends Error {
344
+ override readonly name = "MediaFetchError";
345
+ constructor(readonly status: number) {
346
+ super(`WhatsApp media fetch failed: HTTP ${status}`);
347
+ }
348
+ }
349
+
350
+ /**
351
+ * Decrypt WhatsApp's media encryption as a stream: feed whole AES blocks to
352
+ * the decipher and hold back the remainder, which at the end is the 10-byte
353
+ * MAC and is dropped. The same arithmetic as Baileys' `downloadEncryptedContent`
354
+ * (no range support: we always fetch the whole file).
355
+ */
356
+ function decryptMediaStream(cipherKey: Uint8Array, iv: Uint8Array): Transform {
357
+ const decipher = createDecipheriv("aes-256-cbc", cipherKey, iv);
358
+ let remainder: Buffer = Buffer.alloc(0);
359
+ return new Transform({
360
+ transform(chunk: Buffer, _encoding, callback) {
361
+ const data = remainder.length ? Buffer.concat([remainder, chunk]) : chunk;
362
+ const whole = Math.floor(data.length / AES_BLOCK) * AES_BLOCK;
363
+ remainder = data.subarray(whole);
364
+ try {
365
+ callback(null, decipher.update(data.subarray(0, whole)));
366
+ } catch (error) {
367
+ callback(error as Error);
368
+ }
369
+ },
370
+ flush(callback) {
371
+ try {
372
+ callback(null, decipher.final());
373
+ } catch (error) {
374
+ callback(error as Error);
375
+ }
376
+ },
377
+ });
378
+ }
379
+
380
+ /** `new URL(url).host`, or undefined for a missing or unparsable URL. */
381
+ function hostOf(url: string | null | undefined): string | undefined {
382
+ if (!url) return undefined;
383
+ try {
384
+ return new URL(url).host;
385
+ } catch {
386
+ return undefined;
387
+ }
388
+ }
389
+
390
+ /**
391
+ * The production download: the media URL, fetched by us so the request
392
+ * carries `context.signal` (the only thing that closes its socket), and piped
393
+ * through the decrypt. Baileys supplies what it is the authority on — which
394
+ * content is the media, the URL from its `directPath`, the keys — and none of
395
+ * the I/O. Mirrors Baileys 7.0.0-rc14's `downloadMediaMessage` except that it
396
+ * never requests a phone re-upload (see `WHATSAPP_REUPLOAD_BOUND_MS`).
397
+ */
398
+ async function fetchDecryptedMedia(
399
+ msg: WAMessage,
400
+ _sock: WASocket,
401
+ context: MediaDownloadContext,
402
+ ): Promise<Readable> {
403
+ const content = extractMessageContent(msg.message);
404
+ const contentType = content ? getContentType(content) : undefined;
405
+ const media = (content && contentType ? content[contentType] : undefined) as
406
+ | {
407
+ url?: string | null;
408
+ directPath?: string | null;
409
+ thumbnailDirectPath?: string | null;
410
+ mediaKey?: Uint8Array | null;
411
+ }
412
+ | undefined;
413
+ if (
414
+ !contentType ||
415
+ !media ||
416
+ typeof media !== "object" ||
417
+ (!("url" in media) && !("thumbnailDirectPath" in media))
418
+ ) {
419
+ throw new Error(`"${contentType}" message is not a media message`);
420
+ }
421
+ // Baileys' keying: the HKDF info string comes from the content type, and a
422
+ // wrong one decrypts to garbage silently (CBC does not authenticate).
423
+ let mediaType = contentType.replace("Message", "") as BaileysMediaType;
424
+ let directPath = media.directPath;
425
+ if ("thumbnailDirectPath" in media && !("url" in media)) {
426
+ directPath = media.thumbnailDirectPath;
427
+ mediaType = "thumbnail-link";
428
+ }
429
+ const url = directPath
430
+ ? getUrlFromDirectPath(directPath, hostOf(media.url))
431
+ : media.url;
432
+ if (!url) throw new Error("No media URL or directPath in the message");
433
+
434
+ const { cipherKey, iv } = await getMediaKeys(media.mediaKey, mediaType);
435
+ const response = await fetch(url, {
436
+ signal: context.signal,
437
+ headers: { Origin: DEFAULT_ORIGIN },
438
+ });
439
+ if (!response.ok || !response.body) {
440
+ // A non-2xx body is never read; `downloadBytes`' abort closes the socket.
441
+ response.body?.cancel().catch((error: unknown) =>
442
+ logger.debug("WhatsApp media non-2xx body cancel failed", {
443
+ error: error instanceof Error ? error.message : String(error),
444
+ }),
445
+ );
446
+ throw new MediaFetchError(response.status);
447
+ }
448
+ const body = Readable.fromWeb(response.body as WebReadableStream);
449
+ return body.pipe(decryptMediaStream(cipherKey, iv));
450
+ }
451
+
345
452
  async function downloadBytes(
346
453
  msg: WAMessage,
347
454
  sock: WASocket,
@@ -352,7 +459,7 @@ async function downloadBytes(
352
459
  bounds.idleMs,
353
460
  options.reuploadBoundMs ?? WHATSAPP_REUPLOAD_BOUND_MS,
354
461
  );
355
- // Destroying the stream Baileys returns, or its HTTP source, releases the
462
+ // Destroying the download's stream, or its HTTP source, releases the
356
463
  // reader but leaves the socket draining the body (measured on Bun 1.3.14:
357
464
  // still receiving after the destroy, closed within 1 ms of an abort). So
358
465
  // every exit aborts; after a clean end the abort is a no-op. The abort can
@@ -375,18 +482,11 @@ async function downloadBytes(
375
482
  };
376
483
  try {
377
484
  open.arm("open");
378
- const opening = options.download
379
- ? options.download(msg, sock, context)
380
- : (downloadMediaMessage(
381
- msg,
382
- "stream",
383
- { options: { signal: controller.signal } },
384
- {
385
- // biome-ignore lint/suspicious/noExplicitAny: Baileys logger type is complex
386
- logger: silentBaileysLogger as any,
387
- reuploadRequest: context.reuploadRequest,
388
- },
389
- ) as Promise<Readable>);
485
+ const opening = (options.download ?? fetchDecryptedMedia)(
486
+ msg,
487
+ sock,
488
+ context,
489
+ );
390
490
  // A stream that arrives after the bound fired is torn down on arrival.
391
491
  opening.then(
392
492
  (stream) => {