Skip to content

Review: the fork against Evolution API 2.3.7 - #2

Open
AlmogCohen wants to merge 179 commits into
release/2.3.7from
fork/2.3.7
Open

AlmogCohen wants to merge 179 commits into
release/2.3.7from
fork/2.3.7

Conversation

@AlmogCohen

@AlmogCohen AlmogCohen commented Sep 29, 2026 •

Copy link
Copy Markdown
Owner

The whole fork as one reviewable diff: fork/2.3.7 against the untouched Evolution API 2.3.7 release (release/2.3.7, at the 2.3.7 tag). This PR is a review view and will not be merged.

Unofficial fork, not endorsed by Evolution Foundation. The license and NOTICE are kept, and the user interface and brand assets are unchanged.

🤖 Generated with Claude Code

AlmogCohen and others added 30 commits September 27, 2026 13:55
Adds FORK.md (why the fork exists, the red-then-green rule, the base) and
NOTICE (Evolution Foundation's attribution from upstream main, plus the
modification notice Apache 2.0 section 4 asks for).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2.3.7 ships 7.0.0-rc.9, which is inside the range of CVE-2026-48063
(message and history-sync spoofing, rc.1 to rc11, GHSA-qvv5-jq5g-4cgg).
rc13 is also the first to learn @lid to phone mappings from the history
sync. Pinned exactly: an RC is not a range.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…Baileys

Evolution has no test suite, and test/ has been in .gitignore since the first
commit. This adds one: vitest 4.1.11 runs the TypeScript source directly, the
real BaileysStartupService is driven through Baileys' real event buffer, and
Prisma and the server module are replaced by in-memory fakes.

- Baileys resolves from the installed package (package.json pins it), and
  deep imports reach the same build. BAILEYS_DIR points the suite at another
  build; BAILEYS_EXPECT fails the run when the resolved version differs.
- Tests start from a production configuration (the minimal profile:
  only the instance stored, local cache, WARN and above). A test that needs
  storage asks for the stored profile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Typecheck and the vitest suite on the pinned Baileys, on every push to a
fork/ branch and on pull requests. A weekly job runs the same suite
against the newest Baileys release. The upstream workflows are left as they
are: none of them triggers on fork/ branches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AGENTS.md gains a first section for this fork: every change starts with a
failing test committed alone, then the fix; how the harness runs; profiles;
what to assert. CLAUDE.md points at it. These are this fork's rules, not
Evolution's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each of the three auth stores (prisma, redis-db, provider-files) saves a
real app-state sync key, is opened again as after a restart, and decodes a
real encrypted contact patch with the key it reads back. Fails today: the
stores revive the key with AppStateSyncKeyData.create(), which leaves
keyData a base64 string, so the patch is skipped and no name comes back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After a JSON round trip an app-state sync key's keyData is a base64
string. AppStateSyncKeyData.create() keeps it a string, so Baileys derives
the wrong keys and skips every app-state patch after a restart. fromObject()
decodes it back to bytes, as Baileys' own multi-file store does. Applied to
the prisma, redis-db and provider-files stores.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Baileys emits groups.update, which every transport upper-cases to
GROUPS_UPDATE before comparing it with the subscription. /webhook/set (and
every /<transport>/set) accepts only GROUP_UPDATE, and the global env
config is keyed GROUP_UPDATE, so a subscription to group updates never
matches. Through the real WebhookController to a local HTTP destination:
subscribed as GROUP_UPDATE (the spelling /webhook/set accepts) or GROUPS_UPDATE, per instance
and global, the event must arrive, and other events must be unaffected.
The same checks run through the websocket, rabbitmq, nats, sqs, kafka and
pusher controllers with recording clients. Fails today for GROUP_UPDATE,
per instance in all seven transports and global in webhook, rabbitmq,
nats, kafka and pusher.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The event is groups.update (GROUPS_UPDATE once upper-cased), but the
/<transport>/set schemas store GROUP_UPDATE and the global env configs are
keyed GROUP_UPDATE (except SQS's), so group updates reached no subscriber.
A shared isSubscribed() in event.controller.ts accepts both spellings and
replaces the identical comparisons in the webhook, websocket, rabbitmq,
sqs, nats, kafka and pusher controllers, per instance and global. No stored
value is renamed, so existing subscriptions keep working.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Baileys calls the socket's getMessage to answer a retry request for a
message this instance sent once the message has left its recent-message
cache, and relays any truthy answer under the original id. Evolution
answers a database miss with { conversation: '' }. Under the minimal
profile (no messages stored) every lookup misses. The test asks for an
unknown id under both profiles and for a sent message under minimal, and
expects undefined; a message stored under the stored profile must still
come back as stored. Fails today with { conversation: '' }.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A lookup that finds no row used to throw on webMessageInfo[0].message and
be answered by the catch with { conversation: '' }, which Baileys relays
as the retried message under the original id. Return undefined when
nothing is stored, which Baileys reads as "not available" and sends
nothing. A lookup that throws (database error) still gets the old answer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
….upsert

Baileys (rc13+) learns which phone number each private @lid belongs to and
hands the mappings over as lidPnMappings on messaging-history.set and as
lid-mapping.update events. Evolution 2.3.7 reads neither, so a consumer never
learns that an @lid chat is a known phone number.

The test drives history through the production path (Baileys' own
processMessage inside ev.createBufferedFunction, a real signal repository)
and asserts the exact CONTACTS_UPSERT items a consumer reads a mapping from:
{ remoteJid: <phone>, pushName: null, lid: <lid>, phoneNumber: <phone>,
instanceId }.

On the unchanged code all seven cases fail with no mapping item emitted at
all (expected [ ...mappings ], received []), because nothing forwards either
source:
- (a) an @lid-keyed conversation, mapped by phoneNumberToLidMappings or its
  own pnJid
- (b) a phone-keyed conversation whose lidJid is set
- (c) a mapping with no conversation of its own
- (d) the mapping in a later, and in an earlier, history batch than its
  conversation
- (e) a live lid-mapping.update from pnForLidChatAction
- the negative control (no mapping for an unmapped @lid, no unrelated pair
  linked), whose positive half expects the two real pairs

Forwarding the event field alone is not enough: Baileys' event buffer drops
lidPnMappings when it flushes a buffered messaging-history.set (rc14
lib/Utils/event-buffer.js, consolidateEvents). Checked against the spike fix
that recovers mappings by asking the signal repository for each @lid chat or
contact id: (a), (e) and the earlier-batch (d) pass, while (b), (c), the
later-batch (d) and the negative control's unconversationed pair still fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every @lid to phone mapping Baileys learns now reaches the consumer as one
CONTACTS_UPSERT item: { remoteJid: <phone>, pushName: null, lid: <lid>,
phoneNumber: <phone>, instanceId }. Pairs are oriented, normalized to user
jids and de-duplicated within a batch; anything that is not an @lid and a
phone jid is skipped.

- Live mappings: lid-mapping.update is not a bufferable event, so it reaches
  ev.process intact and is forwarded there.
- History mappings: Baileys puts every mapping of a batch on
  messaging-history.set as lidPnMappings (phoneNumberToLidMappings, plus each
  conversation's pnJid or lidJid, lib/Utils/history.js:45-78), but history is
  processed inside ev.createBufferedFunction and the buffer drops the field
  when it consolidates the batch (lib/Utils/event-buffer.js:543-556). In the
  test's buffered path the handler receives lidPnMappings undefined in every
  batch. So eventHandler() now wraps client.ev.emit once per socket and reads
  lidPnMappings where Baileys emits it, before the buffer sees it, queued on
  eventProcessingQueue like every other event. Plain TypeScript, Baileys is
  not patched, and nothing is looked up.

Chosen because it is the only option that covers every case. Rejected:
- Reading lid / phoneNumber off the contacts Baileys builds from history:
  they survive the buffer, but a contact carries only its own conversation's
  lidJid or pnJid (history.js:56-62). A mapping that exists only in
  phoneNumberToLidMappings (history.js:45-49) has no contact, so an @lid
  conversation mapped that way, a mapping with no conversation, and a
  mapping in a later batch than its conversation are all lost.
- Looking the mapping up in the signal repository: getPNForLID is local
  (cache, then the keys store, lib/Signal/lid-mapping.js:199-270), but
  getLIDForPN falls back to a USYNC query on a miss (lid-mapping.js:155-176),
  so a phone-keyed conversation cannot be resolved without the network, and
  the key store has no way to enumerate its entries, so a mapping with no
  conversation id to ask about, or one arriving in a batch with no
  conversations, cannot be found at all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… certain

Each contacts.upsert webhook item now carries saved. It is true only for a
name from an app-state contact action (fullName or firstName), and false for
history names (displayName, chat name or username), push names, a message's
pushName, and a contact action name that is just the username. Every existing
field is unchanged, and saved is not written to the stored row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…R,WARN

Drives each path that handles a message, contact or call with a distinctive
text, phone number and push name, under the minimal and stored profiles,
and asserts none of the three is printed. captureOutput now also catches
direct writes to fd 1 and 2, which is how pino (the logger Evolution hands
Baileys) prints.

Paths that leak on 2.3.7:
- ordinary incoming message and placeholder resend: console.log(messageRaw)
  prints text, sender number and push name; the resend also prints the
  whole message after "Message received from phone"
- on-demand history sync: console.log dumps every message in the batch
- message that failed to decrypt ("No session record"): Baileys' error log
  "failed to decrypt message" prints the key with the sender's JID, and
  Evolution's WARN "Message ignored with messageStubParameters" prints the
  whole message with the JID and push name
- Baileys "error in handling message": prints the stanza (from, notify,
  body) through the pino logger Evolution configures
- incoming call: console.log of the CB:call and CB:ack,class:call stanzas
  prints the caller's JID and push name
- group metadata cache lookup: console.log "Cache request for group" prints
  the group JID, which embeds the creator's number in old-format ids
- group participants update when the lookup fails: ERROR prints the group JID
- raw node through baileysSendNode: console.log prints the whole stanza
- stored profile only: "Original message not found for update" WARN prints
  the key, and "Chat insert record ignored" prints the chat JID
- status update under minimal: a bare console.log "CACHE:" bypasses LOG_LEVEL

Guards that already pass: a history batch and an outgoing text message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A deployment with LOG_LEVEL=ERROR,WARN still printed message text, phone
numbers and push names: bare console.log dumps that ignore LOG_LEVEL, WARN
and ERROR lines that serialised whole messages or keys, and the pino logger
handed to Baileys, which at level error prints message keys, sender JIDs
and whole stanzas.

- Baileys gets makeBaileysLogger (src/utils/log-privacy.ts): the same
  level, but every logged object is reduced to message id, chat type,
  message type, the session-record flag, counts and the error's name and
  message, with anything shaped like a JID or a phone number masked. Used
  for the socket, the signal key store and media downloads.
- Removed console.log(messageRaw); the full payload stays available at
  VERBOSE through the existing logger.verbose.
- On-demand history, "Message received from phone", CB:call and
  CB:ack,class:call, CACHE, group cache lookups and baileysSendNode now go
  through the logger (INFO or VERBOSE) with ids and counts only.
- "Message ignored with messageStubParameters" prints id, chat type and the
  matched decrypt error instead of the whole message.
- "Original message not found for update" prints the message id, not the
  key; "Chat insert record ignored" prints the chat type, not the JID; the
  group participants error drops the group JID.

Webhook payloads and every other behaviour are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A local CDN and local HTTP CONNECT and SOCKS5 proxies on 127.0.0.1, with the
instance's proxy set the way /proxy/set and a connect set it. Downloads via
getBase64FromMediaMessage (the /chat/getBase64FromMediaMessage route and the
S3 storage path) and the messages.upsert webhook base64 path must reach the
CDN through the proxy. On the current code they reach it directly: the socket
gets the proxy, but Baileys downloads with fetch and only a `dispatcher` in
the download options routes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Baileys 7 downloads media with fetch, which ignores the socket's agent and
fetchAgent and routes only through a `dispatcher` in the download options.
Every downloadMediaMessage and downloadContentFromMessage call in the Baileys
service (getBase64FromMediaMessage and its fallback, and the webhook base64
downloads in messages.upsert and after sending) now passes the instance's
proxy as an undici dispatcher (ProxyAgent for http/https, fetch-socks for
socks4/socks5), built by the existing makeProxyAgentUndici. It is the
socket's own fetchAgent when the socket was created with one, so a
proxyscrape exit is the same exit, and it is rebuilt when /proxy/set changes
the proxy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…gh it

Runs Evolution's real connect with makeWASocket replaced by a spy, then runs
Baileys' own getWAUploadToServer with the socket config Evolution built,
against a local HTTPS media host (a test certificate added to the default CAs,
verification on) through local HTTP CONNECT and SOCKS5 proxies. Today the
upload fails on every host: Baileys uploads with http.request under Node and
passes fetchAgent as its agent, and Evolution's fetchAgent is an undici
dispatcher, which http.request refuses with ERR_INVALID_ARG_TYPE.

A connection guard in the helpers refuses any socket not to the loopback
address and records what tried.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Under Node, Baileys uploads media with http.request and passes the socket's
fetchAgent as its agent. Evolution set fetchAgent to an undici dispatcher,
which http.request refuses, so every media send on an instance with a proxy
failed with "Media upload failed on all hosts". fetchAgent is now the same
kind of agent as the websocket's (makeProxyAgent: https-proxy-agent or
socks-proxy-agent), and the undici dispatcher is kept for downloads only.
The proxy is resolved once per connect, so a proxyscrape exit stays one exit
for the socket, uploads and downloads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Runs Evolution's real connect with a proxy set and makeWASocket replaced by a
spy. The proxy maps web.whatsapp.com and raw.githubusercontent.com to local
HTTPS servers, and the connection guard refuses anything else. Today both the
sw.js request (axios) and the fallback to Baileys' version file (fetch) go
straight out, so the guard records web.whatsapp.com:443 and
raw.githubusercontent.com:443 and the proxy sees nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…roxy

createClient asked web.whatsapp.com for its version (and, failing that,
GitHub for Baileys' version file) before resolving the instance's proxy, so
every account's connect was preceded by a request from the server's own IP.
The proxy is now resolved first, the sw.js request (axios) goes through the
socket's proxy agent with axios' own env proxy off, and the Baileys fallback
(fetch) through the undici dispatcher. Without a proxy nothing changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
connectToWhatsapp starts loadProxy() without waiting for it and goes on to
build the socket. With the Proxy row read taking a database round trip
(50ms here), the socket config carries no proxy agent and the version fetch
goes out directly: the connection guard records web.whatsapp.com and
raw.githubusercontent.com, and a connection opened with the socket's agent
goes straight out too. Covered for the first connect after boot and for a
second connect of the same service (restart, reconnect after close), where
loadProxy first sets enabled to false and so drops a proxy that was already
loaded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
connectToWhatsapp now awaits loadProxy() before createClient, so the socket,
the version fetch and media are built from the Proxy row rather than from
whatever the read had reached. Every connect goes through here: the boot
auto-connect, /instance/create, /instance/connect, /instance/restart, the
reconnect after a closed connection and the Chatwoot reconnect. A failed
Proxy read now fails the connect instead of connecting without the proxy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
connectToWhatsapp starts loadSettings() without waiting for it and builds the
socket from localSettings. With the Setting row read taking a database round
trip (50ms here), the first connect after boot gets syncFullHistory and
markOnlineOnConnect undefined, and shouldIgnoreJid keeps groups that the
instance ignores. Two stored rows, since with full history on Evolution keeps
groups even when groupsIgnore is set. The reconnect cases hold on the current
code (loadSettings does not clear before reading) and guard that path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
connectToWhatsapp now awaits loadSettings() before createClient, so the
socket is built from the instance's Setting row (syncFullHistory,
groupsIgnore, readStatus, alwaysOnline, and the wavoip token read right
after) rather than from defaults when the read is slower than the rest of
the connect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… session

keyExists and getAuthKey swallowed every database error and returned
false/null, so opening the Prisma auth store during a database blip read
as "no session": it started fresh creds (initAuthCreds) and saved them
over the linked session once the database answered, unlinking the account.

A failed session read now propagates, so the open (and the connect) fails
and can be retried with the stored creds intact. A genuinely absent row
still starts a fresh session. saveKey keeps its existence check inside its
own try, so a failed check skips the save instead of creating a second row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AlmogCohen and others added 5 commits September 29, 2026 11:44
The Prisma auth store now writes a key through writeFileAtomic
(src/utils/atomic-file.ts): the value goes to a temp file in the same
directory (opened exclusively), is flushed with fsync, and is renamed over
the key file, which POSIX makes atomic; the directory is then flushed, so
the rename survives a power loss (renames that land together share one
directory flush). A failed write removes its temp file, leaves the previous
value and rejects, so keys.set fails as saveCreds does. Writes of the same
file run one at a time in call order, so the last one asked for stays.

A killed writer can leave its temp file behind; the store removes those
when it opens its folder (a temp file of a process that no longer runs, or
this process's own that it is not writing), and leaves one of another
running process alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…marker

The marker next to the session's key files is written in place
(fs.writeFile). A write that fails partway (injected here as a full disk:
half the bytes land, then ENOSPC) rejects as it should, but leaves half a
marker over the whole one, which reads as pending with no instance name.
The previous marker must survive, with no temp file left.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
writeLogoutMarker writes through writeFileAtomic, as the key files do: a
temp file in the same directory, fsync, rename over the marker, fsync of the
directory. A write that fails keeps the previous marker, removes its temp
file and rejects, so the logout or delete still answers 500 as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…laced whole

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
NOTICE said the policy was included in this repository; 2.3.7 does not
carry TRADEMARKS.md, so it now links the upstream file. It also states
that the fork does not change the user interface or its brand assets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ket, a pairing-code link)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
const env = { ...process.env, NODE_PATH: join(REPO, 'node_modules'), CACHE_REDIS_ENABLED: 'false' };
const argv = [WRITER, ...args];
const child = fileSizeBlocks
? spawn('/bin/sh', ['-c', `ulimit -f ${fileSizeBlocks} && exec "$0" "$@"`, process.execPath, ...argv], { cwd: tmp, env })
AlmogCohen and others added 22 commits October 16, 2026 16:12
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…siness name

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ory is named by its verified name

POST /chat/fetchHistory does not exist (404), and an ON_DEMAND history
message with no push name carries its sender's number, not the
verifiedBizName WhatsApp sent with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…in; history names a business by its verified name

A consumer that stored a WhatsApp Business template before it could read
one holds an empty row, and the phone is the only place the text is left.
The endpoint passes count, the anchor key and its time to Baileys'
fetchMessageHistory and answers the request id; the phone's ON_DEMAND
batch arrives as messages.set. A history message with no push name now
carries verifiedBizName before falling back to the sender's number.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
POST /chat/fetchVerifiedName does not exist (404). ESHUV's on-demand
history came back with neither a push name nor verifiedBizName, so the
name WhatsApp certifies has to be asked for (usync business/verified_name).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s for a business

A usync query with a business protocol asking for verified_name (Baileys
rc14 has none), decoding the VerifiedNameCertificate WhatsApp answers;
null when the account has none. ESHUV's re-sent history carried no name,
so Circles asks for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
libsignal prints whole SessionEntry objects with console.info and
console.warn when a session closes, opens or is dropped ("Closing
session:", "Removing old closed session:", "Session already closed").
Node prints them in full: the private ratchet key, root key, chain keys,
base key and the contact's identity key, as <Buffer ...>. They are not
Evolution's logger, so LOG_LEVEL=ERROR,WARN never sees them, and on a busy
account they reach stdout every few seconds.

The test re-keys two real libsignal parties as WhatsApp contacts do (a new
prekey bundle while a session is open) and asserts no key byte, no field
name and no dump is printed, while the one-line "Closing open session in
favor of incoming prekey bundle" still is. quietSignalSessionLogs is added
here as an empty seam so the test fails on behaviour, not on an import.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…G_LEVEL

At startup every console method is wrapped once. A call that carries a
libsignal SessionEntry (an object with _chains and indexInfo, whichever
copy of libsignal made it) prints with the session replaced by
"[signal session]", and only when LOG_LEVEL has the call's level:
console.info is INFO, so "Closing session:", "Opening session:" and
"Removing old closed session:" print nothing at ERROR,WARN, and the rare
"Session already closed" warning prints without the dump. Every other
call, Evolution's own logger and libsignal's one-line "Closing open
session in favor of incoming prekey bundle" included, goes through
untouched. Installed after Sentry, so its console breadcrumbs see the
same lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…for good

On 2026-10-29 a Circles number stayed down for 9 hours: its reconnect
built a WebSocket through a proxy exit that accepted the TCP connection
and never answered CONNECT. Nothing had a clock running: ws arms its
handshakeTimeout only once https-proxy-agent hands it a socket (after the
proxy answers), and Baileys' connectTimeoutMs and keepalive start only
once the WebSocket is open. No close ever came, so the reconnect a close
starts never ran.

The test connects the real Baileys, ws and https-proxy-agent through a
local TCP server that accepts and never writes a byte, and asserts the
socket is ended as timed out (408) 45s in, lets go of the proxy
connection, and reconnects on the backoff, the reconnect held to the
same deadline; and that a pending logout's connect is retried the same
way. It fails on the code as it is: the socket is still connecting.

The fake-socket cases guard what the fix must not do (end a socket whose
WebSocket opened, such as one showing a QR; end a replaced socket, or one
of a deleted instance; end again a socket that closed on its own); they
pass before the fix, as they should.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ket opens

The proxy that never answers now reads what it gets (it saw no EOF while
it never read, so a released connection looked held). It is what tells
whether the abandoned connect lets go of the connection to the proxy:
https-proxy-agent waits for the CONNECT answer with no limit, and an
aborted request does not end that wait.

fakeSocket's WebSocket now opens once the caller has attached to it, as
a socket's does when WhatsApp is reachable; `wsOpens: false` models the
exit that never lets it open, which the deadline cases use. Without it,
every test that waits out the reconnect backoff on a fake socket would
see the deadline end a socket that stands for a healthy one.

Still red on the code as it is: no deadline, no release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nnected

Until the WebSocket opens, nothing could close a socket: ws arms its
handshakeTimeout only once the proxy has answered CONNECT, and Baileys
starts connectTimeoutMs and the keepalive only once the WebSocket is
open. Behind an exit that accepts the connection and never answers, a
socket waited in 'connecting' for good, with no close to reconnect
(2026-10-29: a number down for 9 hours).

createClient now gives each socket 45s (connectTimeoutMs and a margin)
for its WebSocket to open. Past it, the socket is ended as timed out
(408), and its close is handled as any other: the reconnect backoff,
or, while a logout is pending, the next attempt to deliver it. The
deadline goes once the WebSocket opens (Baileys' own timers take over,
so a QR waiting to be scanned is left alone), once the socket closes,
and when the socket is let go of (replaced, or the instance removed).

The HTTP proxy agent now lets go of its connection to the proxy when
the request is given up before the proxy answers CONNECT (an abort
signal on that connection, fired by the request's abort or close).
Without it, every attempt ended at the deadline left a connection to
the silent exit open, one more a minute for as long as the outage
lasted. An https: proxy (TLS to the proxy itself) is not covered; a
SOCKS proxy already gives up after socks' own 30s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y connection

Every attempt fetches the WhatsApp Web version through the proxy before
building the socket. Behind an exit that never answers CONNECT, axios
gives up after 10s with req.destroy(), which (before the agent has a
socket) emits neither 'abort' nor 'close', so the agent's release never
fires and each attempt leaves one connection at the proxy (found by
Astra's review).

The real-path case now runs the real version fetch through the silent
proxy and asserts each attempt holds only its WebSocket's connection
there once the fetch has given up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oxy too

The release listened for the request's 'abort' and 'close'. ws aborts
its handshake with abort(), but axios cancels with destroy(), and before
the agent hands over a socket destroy() emits neither: the version
fetch given up at its 10s kept its connection at the proxy, one per
attempt. The agent now wraps the request's destroy() for the length of
the CONNECT (abort() goes through it too). The comment no longer says
an https: proxy is not covered: the selector never accepted one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and a given-up CONNECT lets go of the proxy

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
POST /chat/streamMediaMessage answers the decrypted file as it arrives
(application/octet-stream, the media's type in x-media-mimetype), with
the first bytes out before WhatsApp has sent the last. A failure before
the first byte answers getBase64FromMediaMessage's 400, reupload and
all; one after it cuts the answer short. Red: there is no such route.

getBase64FromMediaMessage holds the file, its base64 and the JSON
around it, about three times the file per download, on a server that
carries every number: a 200 MB recording cannot come through it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…iaMessage)

The same download as getBase64FromMediaMessage (the instance's proxy,
the phone asked to re-upload an expired file unless reupload: false,
Evolution's own fallback), answered as the decrypted bytes themselves:
application/octet-stream, the media's type in x-media-mimetype. A
failure before the first byte is the same 400, reupload included; one
after it cuts the answer short.

Not Baileys' own stream: downloadEncryptedContent joins the CDN's body
to its decryption with .pipe(), which does not forward errors, so a
download that broke off midway left the stream waiting for good and
the CDN's error unhandled. utils/media-stream.ts does the same
download with pipeline() and checks the MAC WhatsApp appends, so the
stream ends only when the file is whole.

The red test's own wait was 5000 ms, which the file's setTimeout spy
(there to shorten Evolution's 5 s fallback) turned into 0: it now waits
4000 ms. The streamed download is also covered behind an HTTP and a
SOCKS5 proxy (test/proxy/media-download.test.ts).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…umer

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ages.undecryptable

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ups, every retry, and not for an unlisted decrypt error

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, never for peer messages or ignored groups

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…'s with the same id are one trace

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants