Root causes, each reproduced against the live server or endpoint: - Server-only JIDs decoded as "@s.whatsapp.net" and pairing replies copied them into `to`, so the device was never registered and every login returned 401. Decoder/encoder fixed, replies use s.whatsapp.net, ADV hosted prefixes follow whatsmeow. The link wizard now waits for the restart, logs in, uploads prekeys and retries once with a fresh device on 401/403. - Closed sockets made every receive loop spin until its deadline (the 7 minute CPU burn). Loops stop on a closed stream. - whatsapp_send had no image argument and send_image was a stub. Added WaMedia (byte-exact against 8 wa-go golden vectors), media_conn upload, image send and the DeviceSentMessage copy for the user's other devices. - Phone contacts never reached the client: added app-state sync (WaAppState, WaReceiver, LTHash and MAC checks verified against a whatsmeow-built oracle) and the historySyncConfig declaration at registration. - Contact lookup: FTS5 trigram plus fuzzy ranking, ambiguity is reported and never guessed, global search lists contacts. Send caps (10/min, 200/day) are enforced and survive unlink. Failover and agent: - Every failed backend candidate is printed the moment it fails. - muse runs with low reasoning effort, an empty preset and one stable workspace (36.5s down to 2.8s on the real prompt); Ctrl-C no longer counts as a backend failure; CLI backends use the circuit breaker; opencode runs are capped at 30s. - Orphan tool results are dropped before sending (devplace HTTP 400, reproduced); compaction no longer starts its window on a tool result. - Terminal art pasted as a prompt is refused before any model call. Tests: whatsapp_test, muse_flags_test, llm_history_test; full suite 40 steps (agent.e2e reads live tmux panes and is flaky, green on re-run). Research archived under research/whatsapp-automation. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
649 lines
41 KiB
Markdown
649 lines
41 KiB
Markdown
# whatsapp (taicpp): research result and build specification
|
|
|
|
`whatsmeow` is an open source Go library for the WhatsApp Web multidevice API
|
|
(author Tulir Asokan, module `go.mau.fi/whatsmeow`, license MPL-2.0). It speaks
|
|
the WhatsApp protocol directly over one WebSocket, with no browser involved:
|
|
QR and pair-code linking, Noise handshake, Signal encryption, send and receive,
|
|
media upload, and app-state (contact) sync. detz does not link or ship it: the
|
|
native C++ module `detz::wa` is transpiled from `wa-go` (MIT) with `whatsmeow`
|
|
as the behavior reference (section 3), and the test oracle
|
|
`tests/whatsapp_data/appstate_oracle.go` imports its Go packages to generate
|
|
byte-exact vectors. Any comment saying "ported from whatsmeow" means the logic
|
|
or byte layout was copied from upstream and must stay compatible with it. Full
|
|
comparison: section 2.1.
|
|
|
|
Status: **built and integrated; the offline core is verified, the live link
|
|
is verified through QR display, message send is built but unverified** (it
|
|
needs a phone-linked account, which development does not have). What is in the
|
|
tree now (`src/whatsapp/`, namespace `detz::wa`):
|
|
|
|
| Piece | Files | State |
|
|
|---|---|---|
|
|
| `WaCrypto` (X25519, AES-GCM/CTR/CBC, HKDF, PBKDF2, SHA, XEdDSA) | `wa_crypto.*` | verified: RFC vectors + XEdDSA roundtrips |
|
|
| `WaBinary` (binary-XML codec, token dict v3) | `wa_binary.*`, `wa_tokens_data.h`, `wa_node.h` | verified: wa-go codec battery 19/19 |
|
|
| `WaProto` (protobuf + the needed messages) | `wa_proto.*` | verified: ClientPayload byte-exact |
|
|
| `WaNoise` (Noise XX + transport) | `wa_noise.*` | verified: golden transcript **and live server** |
|
|
| `WaSignal` (X3DH + Double Ratchet) | `wa_signal.*` | verified: golden session 4/4 byte-exact |
|
|
| `WaDevice` (identity + ClientPayload) | `wa_device.*` | verified via the handshake |
|
|
| `WaSocket` (libcurl WebSocket) | `wa_socket.*` | verified live (connect + frames) |
|
|
| `WaClient` (connect, pair, login, send) | `wa_client.*` | connect/QR live-verified; pair-complete + send built, unverified |
|
|
| `WaStore` (vault SQLite) | `wa_store.*` | verified: round-trips device + contacts |
|
|
| `WaQr` (terminal QR) | `wa_qr.*` | verified: renders a scannable code |
|
|
| `WaWizard` (`--whatsapp-link`) | `wa_wizard.*` | live-verified through QR display + device persist |
|
|
| agent tools `whatsapp_send`, `whatsapp_contacts` | `src/agent/detz_tools.cpp`, `tools_ops.cpp` | wired; contacts verified, send unverified |
|
|
|
|
Verified against the live server (no phone needed): `detz --whatsapp-link`
|
|
connects, completes the Noise handshake, receives the `pair-device` node, renders
|
|
the QR, and saves the device. A phone scanning that QR would link it; the
|
|
pair-success handler (ADV HMAC + account-signature verify, device signing,
|
|
`pair-device-sign`) is ported faithfully from whatsmeow but cannot be exercised
|
|
in-tree. `whatsapp_send` performs a real usync + prekey-fetch + Signal-encrypt +
|
|
send-node sequence; it is faithful to whatsmeow but unverified without a live
|
|
linked account. Unit test: `build/whatsapp_test` (16/16), vectors in
|
|
`tests/whatsapp_data/`. Dependencies added: none at runtime (OpenSSL was already
|
|
linked for the static build; added to the default link), plus two vendored
|
|
libraries, `third_party/ed25519` (zlib) and `third_party/qrcodegen` (MIT).
|
|
|
|
Remaining to make send fully work: own-device fan-out (DeviceSentMessage),
|
|
media upload for `send_image` (`WaMedia`), prekey upload after login, retry
|
|
receipts, and a live test with a sacrificial number.
|
|
|
|
Original research and plan below (kept as written; some "not built yet" lines
|
|
are now superseded by the table above).
|
|
|
|
---
|
|
|
|
This file states what was found, what was
|
|
decided, and the exact plan to build a native C++ WhatsApp client into detz.
|
|
Research archive: [`../../research/whatsapp-automation/`](../../research/whatsapp-automation/)
|
|
(sources and three conclusions). Every number below was measured on 2026-10-27
|
|
on this machine unless a line says otherwise.
|
|
|
|
## 1. Goal
|
|
|
|
One sentence must work in detz:
|
|
|
|
> "Create screenshot and explain what is genius about it and send to Niggel on WhatsApp"
|
|
|
|
The flow breaks into four steps. Three of them already exist:
|
|
|
|
| Step | Owner | Exists |
|
|
|---|---|---|
|
|
| take a screenshot | `shell` tool (`grim`, `scrot`, `import`) | yes |
|
|
| explain it | `describe_image` tool (src/agent/tools_core.cpp) | yes |
|
|
| resolve "Niggel" to a WhatsApp JID | `WaContacts` (this spec) | no |
|
|
| send text plus image | `whatsapp_send` tool over `WaClient` (this spec) | no |
|
|
|
|
The missing part is one module: a WhatsApp companion device written in C++.
|
|
|
|
## 2. Answers to the questions asked
|
|
|
|
### 2.1 Best Python libraries for a normal phone number
|
|
|
|
There is no Python library that speaks the current WhatsApp protocol itself.
|
|
Measured on the cloned repositories:
|
|
|
|
| Library | Language of the protocol code | How it works | Verdict |
|
|
|---|---|---|---|
|
|
| neonize (Apache-2.0, 759 stars, 62 commits in 90 days) | **Go** (whatsmeow) | 4362 lines of Go export 93 C functions (`//export`), 14918 lines of Python call them through `ctypes` | best "Python" option, but it is whatsmeow with a Python skin |
|
|
| whatsfly | Go (whatsmeow) | same idea, PyPI 0.0.1, linux 32 bit only | early, not usable |
|
|
| whatsapp-mcp (lharries, MIT) | Go (whatsmeow) | Go bridge with HTTP `/api/send`, Python MCP server with `send_message`, `send_file` | usable shortcut, see section 10 |
|
|
| yowsup (GPL-3.0) | Python | old phone-registration protocol (`e1.whatsapp.net:443`), last commit 2021-12-14, README warns that registration gives an instant ban | dead, do not use |
|
|
| pywhatkit, Astra, wa-automation, whalesong | Python | drive WhatsApp Web in a real browser | browser, rejected |
|
|
| Supbot2 | Python | drives the Android app with Appium | needs a phone or emulator, rejected |
|
|
| pywa and other Cloud API clients | Python | official Meta Business Cloud API | needs a business number; the number cannot stay on the normal app, rejected for a personal number |
|
|
|
|
Conclusion: transpiling Python gets us nothing. The real implementations are:
|
|
|
|
| Project | Language | License | Activity (commits in last 90 days) | Size (non-blank, non-test, non-generated) |
|
|
|---|---|---|---|---|
|
|
| whatsmeow (tulir) | Go | MPL-2.0 | 78 | 24,301 lines, plus 107,816 generated protobuf lines |
|
|
| libsignal-protocol-go (tulir), used by whatsmeow | Go | **GPL-3.0** | not measured | 5,416 |
|
|
| wa-go (jfelipesjc) | Go | **MIT** | 17, created 2026-06-26, 1 star | 23,278 (wire 2778, signal 1625, keys 311, media 517, client 14379) |
|
|
| Baileys (WhiskeySockets) | TypeScript | MIT | 7 | 48,898 |
|
|
| whatsapp-rust (oxidezap) | Rust | MIT | at least 100 (API page limit) | about 650,000 including VoIP and plugins |
|
|
| libsignal-protocol-c (signalapp) | C | **GPL-3.0** | last commit 2020-03-27 | 29,456 |
|
|
|
|
No C or C++ library implements the multi-device protocol. Search found only
|
|
WebKit wrappers (WasIstLos, YAWF) and a REST wrapper (Green-API).
|
|
|
|
### 2.2 Is a browser required
|
|
|
|
**No.** whatsmeow, Baileys, wa-go and whatsapp-rust all speak the protocol
|
|
directly over one WebSocket. The phone is needed exactly once, to link the
|
|
device: scan a QR code shown in the terminal, or type an 8-character pairing
|
|
code into WhatsApp > Linked devices. After that the companion runs alone. The
|
|
phone must come online now and then; one directory page reports
|
|
re-authentication after about 20 days (not verified).
|
|
|
|
### 2.3 Can it be done in C++ with what detz already links
|
|
|
|
Yes. detz already links libcurl and OpenSSL:
|
|
|
|
- libcurl 8.18.0 on this machine lists `ws wss` as protocols.
|
|
`curl -H "Origin: https://web.whatsapp.com" wss://web.whatsapp.com/ws/chat`
|
|
answered **`101 Switching Protocols`**, so the transport is proven reachable
|
|
from here with the library we already ship.
|
|
- OpenSSL 3.5.5 covers every primitive except XEdDSA signing (section 5).
|
|
- Protobuf needs no library: we need about 15 messages, and a hand-written
|
|
varint and length-delimited writer and reader is about 200 lines.
|
|
|
|
New dependencies: **none.**
|
|
|
|
## 3. Decision: native C++, transpiled
|
|
|
|
Build `detz::wa` as a native C++ module. Transpile from two sources:
|
|
|
|
1. **wa-go (MIT) is the code source** for wire, Noise, binary codec, keys,
|
|
Signal (X3DH, Double Ratchet), media crypto and pairing. Reasons: MIT, small
|
|
(the whole Signal layer is 1625 lines against 3585 for the libsignal-go
|
|
subset), layered exactly like detz wants (`internal/wire`, `signal`, `keys`,
|
|
`media`), and it ships **788 KB of golden vectors captured from real Baileys**
|
|
(`testdata/traces/connect_pair`, `testdata/signal/session_ab.json`,
|
|
`testdata/media/media_vectors.json`, `testdata/paircode/vectors.json`). Those
|
|
become our C++ test fixtures as they are.
|
|
2. **whatsmeow (MPL-2.0) is the behaviour reference**: it is the most used and
|
|
most maintained (78 commits in 90 days). When wa-go and whatsmeow disagree,
|
|
whatsmeow wins. The current client version, the server certificate key and
|
|
the node shapes are read from whatsmeow at build time of each release.
|
|
|
|
Do **not** transpile libsignal-go or libsignal-protocol-c: both are GPL-3.0,
|
|
and detz is commercial. MPL-2.0 is file-level: a C++ file transpiled from a
|
|
whatsmeow file keeps the MPL notice and its source stays available. Copying
|
|
code from wa-go, or reading whatsmeow to learn a value, does not trigger this.
|
|
This is a fact about the licenses, not legal advice.
|
|
|
|
## 4. Module layout
|
|
|
|
Namespace `detz::wa`, sources in `src/whatsapp/`, one class per `.h`/`.cpp`
|
|
pair, as the rest of taicpp.
|
|
|
|
| Class | Responsibility | Transpiled from (wa-go / whatsmeow) |
|
|
|---|---|---|
|
|
| `WaConstants` | URL, origin, Noise pattern, header, dict version, cert key, client version, all caps (section 8) | `socket/constants.go`, `store/clientpayload.go` |
|
|
| `WaProtoWriter`, `WaProtoReader` | protobuf wire format: varint, zigzag not needed, length-delimited, fixed32 | none, hand-written |
|
|
| `WaMessages` | the ~15 protobuf messages as plain structs with `encode()`/`decode()` | field numbers from whatsmeow `proto/*.proto` |
|
|
| `WaCrypto` | static: X25519, AES-256-GCM, AES-256-CTR, AES-256-CBC, HKDF-SHA256, HMAC-SHA256, SHA-256, SHA-512, PBKDF2-SHA256, random, over OpenSSL EVP | `util/*util`, wa-go `keys` |
|
|
| `WaXeddsa` | XEdDSA sign and verify over Curve25519 keys | libsignal-go `ecc/SignCurve25519.go` logic, wa-go `keys` (section 5) |
|
|
| `WaSocket` | libcurl WebSocket, 3-byte big-endian frame length, `WA` header on first frame, 16 MiB frame cap | wa-go `wire/framing.go`, `ws` |
|
|
| `WaNoise` | Noise XX handshake and the post-handshake read/write ciphers | wa-go `wire/noise.go`, whatsmeow `socket/noisehandshake.go`, `handshake.go` |
|
|
| `WaNode`, `WaBinaryCodec`, `WaTokens` | binary XMPP-like nodes, token dictionary v3 (236 single-byte, about 1030 double-byte tokens), nibble/hex packing, JID encoding | wa-go `wire/codec.go`, `token.go`, `node.go` |
|
|
| `WaStore` | SQLite tables: device identity, signed prekey, prekeys, sessions, identities, contacts, aliases | wa-go `store`, whatsmeow `store/sqlstore` |
|
|
| `WaPairing` | QR pairing (`pair-device`, ref rotation, `pair-success` with ADV signature check) and phone pairing code | wa-go `client` pairing, whatsmeow `pair.go`, `pair-code.go` |
|
|
| `WaSignal` | X3DH, Double Ratchet, `pkmsg`/`msg` encrypt and decrypt, session records | wa-go `internal/signal` (x3dh, ratchet, session, message) |
|
|
| `WaPrekeys` | upload 50 prekeys plus signed prekey after pairing, refill below threshold | whatsmeow `prekeys.go` |
|
|
| `WaUsync` | device list of a recipient (`usync` iq), "is this number on WhatsApp" | whatsmeow `user.go` (subset) |
|
|
| `WaMedia` | media key expansion (`WhatsApp Image Keys`), AES-CBC plus HMAC, `media_conn` iq, HTTPS upload to `/mms/image/<token>` | wa-go `media`, whatsmeow `upload.go`, `mediaconn.go` |
|
|
| `WaSender` | 1:1 send: device fan-out, encrypt per device, own other devices get a `DeviceSentMessage`, `<message><participants>` node, `phash` check | whatsmeow `send.go` (DM path only) |
|
|
| `WaReceiver` | decrypt incoming 1:1 text, send receipts and acks | whatsmeow `message.go`, `receipt.go` (subset) |
|
|
| `WaClient` | owns the above; connect, login, keepalive, reconnect, event loop on one thread | whatsmeow `client.go`, `keepalive.go`, `connectionevents.go` |
|
|
| `WaContacts` | name to JID: user aliases first, then contacts learned from push names, then fuzzy match with `detz::FuzzyMatcher` (floor 0.3) | none, detz design |
|
|
| `WaTools` | agent tools `whatsapp_send`, `whatsapp_contacts`, `whatsapp_alias` | none, detz design |
|
|
|
|
Out of scope for v1, listed so nobody assumes otherwise: groups (sender keys),
|
|
history sync, calls, status, newsletters,
|
|
receiving media, editing, reactions.
|
|
|
|
## 5. Protocol walkthrough and crypto mapping
|
|
|
|
Order of one session, with the OpenSSL call for each primitive.
|
|
|
|
1. **Connect.** WebSocket to `wss://web.whatsapp.com/ws/chat`, header
|
|
`Origin: https://web.whatsapp.com`. Every frame is a 3-byte big-endian
|
|
length plus payload; the first frame is prefixed with
|
|
`'W','A',6,3` (magic 6, dictionary version 3). Frame cap `1 << 24`.
|
|
2. **Noise XX handshake.** Pattern string
|
|
`Noise_XX_25519_AESGCM_SHA256\0\0\0\0` (32 bytes, used as the initial hash
|
|
as is). Send `HandshakeMessage.ClientHello{ephemeral}`; receive
|
|
`ServerHello{ephemeral, static (encrypted), payload (encrypted cert)}`;
|
|
mix X25519 shared secrets with HKDF-SHA256 into AES-256-GCM keys, counter IV;
|
|
verify the certificate chain against the pinned key `WACertPubKey` in
|
|
whatsmeow `handshake.go`; send `ClientFinish{static (encrypted noise key),
|
|
payload (encrypted ClientPayload)}`. Then split into a read and a write key.
|
|
The proto also defines post-quantum modes (`XXKEM`, `IKKEM`, `WA_PQ`);
|
|
whatsmeow does not use them today, so neither do we.
|
|
3. **ClientPayload.** Register (unpaired: `devicePairingData` with registration
|
|
id, identity public key, signed prekey and its signature, build hash = MD5 of
|
|
the version string) or login (paired: `username` = phone number, `device` =
|
|
device id). The client version is a constant
|
|
(`2.3000.1050728964` in whatsmeow on 2026-10-27). A stale version gives
|
|
connect failure **405 client outdated**; whatsmeow can fetch the live value
|
|
from `web.whatsapp.com` (`"client_revision":N`).
|
|
4. **Pairing.** QR: the server sends `pair-device` with refs; the QR string is
|
|
`https://wa.me/settings/linked_devices#ref,noisePub,identityPub,advSecret,clientType`
|
|
(keys base64), shown in the terminal, refs
|
|
rotate. Phone code: `PairPhone` sends a `link_code_companion_reg` iq; the
|
|
8-character code derives an AES-256-CTR key with **PBKDF2-SHA256, 131072
|
|
(2<<16) iterations**. On `pair-success` verify the account signature
|
|
(XEdDSA), sign the device identity (XEdDSA), send `pair-device-sign`, store
|
|
the JID, reconnect and log in.
|
|
5. **Prekeys.** Upload signed prekey plus a batch of 50 one-time prekeys (iq
|
|
`xmlns="encrypt"`, whatsmeow `WantedPreKeyCount`). Refill when the server
|
|
reports fewer than 5 (`MinPreKeyCount`).
|
|
6. **Send to a contact.** `usync` iq for the recipient's device list (and our
|
|
own); fetch prekey bundles for devices without a session; X3DH, Double
|
|
Ratchet encrypt `Message{conversation}` or `Message{imageMessage}` padded;
|
|
send `<message to type="text|media"><participants><to jid><enc type="pkmsg|msg"
|
|
v="2">` and compare the returned `phash`. Addresses can be phone JIDs
|
|
(`@s.whatsapp.net`) or LID JIDs (`@lid`); whatsmeow `send.go` mentions LID 45
|
|
times, so LID handling is part of the send path, not an extra.
|
|
7. **Image.** Random 32-byte media key; HKDF-SHA256 with info
|
|
`WhatsApp Image Keys` to 112 bytes (IV, cipher key, MAC key, ref key);
|
|
AES-256-CBC, HMAC-SHA256 truncated to 10 bytes appended; `media_conn` iq gives
|
|
hosts and auth; HTTPS POST to `https://<host>/mms/image/<token>`; put
|
|
`url`, `directPath`, `mediaKey`, `fileSha256`, `fileEncSha256`,
|
|
`fileLength`, `mimetype`, `caption` into `ImageMessage`.
|
|
|
|
| Primitive | Where | OpenSSL 3.5 |
|
|
|---|---|---|
|
|
| X25519 | Noise, X3DH, ratchet | `EVP_PKEY_X25519`, `EVP_PKEY_derive` |
|
|
| AES-256-GCM | Noise transport | `EVP_aes_256_gcm` |
|
|
| AES-256-CTR | pairing code | `EVP_aes_256_ctr` |
|
|
| AES-256-CBC | Signal message keys, media | `EVP_aes_256_cbc` |
|
|
| HKDF-SHA256 | Noise, ratchet chains, media keys | `EVP_KDF "HKDF"` |
|
|
| HMAC-SHA256 | ratchet, media MAC | `EVP_MAC "HMAC"` |
|
|
| PBKDF2-SHA256 | pairing code | `PKCS5_PBKDF2_HMAC` |
|
|
| SHA-256, SHA-512, MD5 | everywhere, XEdDSA, build hash | `EVP_Digest` |
|
|
| **XEdDSA sign** | signed prekey, pairing device signature | **not in OpenSSL** |
|
|
| XEdDSA verify | pairing account signature | Montgomery u to Edwards y, then `EVP_PKEY_ED25519` verify (to be proven) |
|
|
|
|
XEdDSA needs Edwards25519 scalar and point arithmetic, which OpenSSL does not
|
|
expose. The algorithm is 60 lines in libsignal-go `ecc/SignCurve25519.go`
|
|
(clamped scalar a, A = aB with sign bit forced to 0, r = SHA-512 of a
|
|
prefix, a, message and 64 random bytes, R = rB, s = r + SHA-512(R,A,M) a mod L).
|
|
The arithmetic underneath (filippo.io/edwards25519, BSD-3-Clause) is what has to
|
|
be ported or replaced. That is the one hard crypto item, see section 6.
|
|
|
|
## 6. Build versus deep research, decided up front
|
|
|
|
**Build** means mechanical transpile with a known oracle. **Deep research** means
|
|
the answer is not written down anywhere we have read, and has to be
|
|
established first, with an archive entry, before any code.
|
|
|
|
| Item | Kind | Oracle or open question |
|
|
|---|---|---|
|
|
| protobuf writer/reader, message structs | build | field numbers in whatsmeow `.proto`; wa-go `client_payload.json` trace |
|
|
| `WaCrypto` wrappers | build | RFC test vectors; wa-go `media_vectors.json` |
|
|
| framing, Noise, binary codec, tokens | build | wa-go `traces/connect_pair` (`noise.json`, `frames_raw.jsonl`, `nodes.jsonl`), `codec_battery/nodes.jsonl` |
|
|
| Signal X3DH, ratchet, session | build | wa-go `testdata/signal/session_ab.json` |
|
|
| pairing code crypto | build | wa-go `testdata/paircode/vectors.json` |
|
|
| media encrypt, upload | build | wa-go `media_vectors.json`, then one live send |
|
|
| usync, prekey upload, send node shape | build | whatsmeow source, then one live send |
|
|
| **XEdDSA sign** | **deep research** | port the Edwards25519 field/scalar/point subset (BSD-3, from filippo.io/edwards25519) or find a permissive C implementation; verify against wa-go `keys/testdata/verify_sig.mjs` style vectors |
|
|
| **current client version and 405 policy** | **deep research** | how often whatsmeow bumps `waVersion`; whether to fetch `client_revision` at start (adds one HTTPS call, kills the treadmill) |
|
|
| **LID addressing** | **deep research** | when the server hands out `@lid`, how sessions map phone JID to LID; read whatsmeow `send.go` and `store` LID tables |
|
|
| **ban behaviour** | **deep research, measured only with a sacrificial number** | no maintainer publishes heuristics; one vendor article names account age, message rate and reports |
|
|
| contact names without app-state | build | push names in incoming messages plus user aliases; app-state sync is v2 |
|
|
|
|
## 7. User flow (the customer view)
|
|
|
|
Two commands, nothing else to configure:
|
|
|
|
```
|
|
detz --whatsapp-link # shows a QR in the terminal, scan in WhatsApp > Linked devices
|
|
detz --whatsapp-link 31612345678 # prints an 8-character code to type on the phone instead
|
|
```
|
|
|
|
Then, in any detz session:
|
|
|
|
```
|
|
> remember that Niggel is 31687654321 on WhatsApp
|
|
> create a screenshot, explain what is genius about it and send it to Niggel on WhatsApp
|
|
```
|
|
|
|
The first line calls `whatsapp_alias`. The second runs `shell` (screenshot),
|
|
`describe_image`, then `whatsapp_send{to:"Niggel", text, image}`. `whatsapp_send`
|
|
resolves the name through `WaContacts`; when the best match is not exact, the
|
|
tool result names what it resolved to, the same note pattern as `@agent`
|
|
mentions. `detz --whatsapp-unlink` logs the device out and deletes its rows.
|
|
|
|
State lives in the profile's vault (SQLite, the existing `store`), not in a new
|
|
file. The link step is the only step that touches the phone.
|
|
|
|
Agent tools:
|
|
|
|
| Tool | Arguments | Result |
|
|
|---|---|---|
|
|
| `whatsapp_send` | `to` (name, alias or number), `text`, optional `image` (path) | message id, resolved JID and name, `phash` match |
|
|
| `whatsapp_contacts` | optional `query` | known names and JIDs |
|
|
| `whatsapp_alias` | `name`, `number` | stored alias, after a usync check that the number is on WhatsApp |
|
|
|
|
## 8. Caps (all static, per CLAUDE.md "everything gets a hard cap")
|
|
|
|
| Constant | Value | Why |
|
|
|---|---|---|
|
|
| `kWaFrameMax` | 16 MiB (`1 << 24`) | protocol limit |
|
|
| `kWaImageMax` | 16 MiB | upload cap; refuse larger, loud |
|
|
| `kWaSendPerMinute` | 10 | ban exposure; the agent must not burst |
|
|
| `kWaSendPerDay` | 200 | same |
|
|
| `kWaRecipientsPerSend` | 1 | v1 is 1:1 only |
|
|
| `kWaPrekeyBatch` | 50 | whatsmeow `WantedPreKeyCount` |
|
|
| `kWaConnectTimeout` | 20 s | handshake plus login |
|
|
| `kWaPairTimeout` | 120 s | QR or code wait |
|
|
| `kWaReconnectFloor` | 5 s, doubling to 300 s | never hammer the server |
|
|
| `kWaContactsMax` | 5000 | contact and alias rows per profile |
|
|
|
|
These are judgments, not measurements. They become measured once the live
|
|
tests below run.
|
|
|
|
## 9. Tests
|
|
|
|
Unit (`tests/whatsapp_test.cpp`, `make test`):
|
|
|
|
- every golden vector from wa-go `testdata/` (copied with its MIT notice into
|
|
`tests/whatsapp_data/`): Noise transcript, raw frames, decoded nodes, codec
|
|
battery, ClientPayload bytes, Signal session A to B, pairing code, media.
|
|
- XEdDSA: sign then verify round trip, plus verify of a signature made by
|
|
libsignal (vector to be produced once, archived).
|
|
- protobuf: encode equals the bytes in the traces, decode of every trace.
|
|
|
|
Integration (`tests/whatsapp.py`, real binary, real WhatsApp, per AGENTS.md):
|
|
|
|
- needs a sacrificial number linked once (`WA_TEST_PEER` names a second number);
|
|
- cases: link state survives restart, send text, send image, receive the
|
|
reply text, `405` handling with a forced old version;
|
|
- verification reads vault rows (message id, session rows), never CLI text.
|
|
|
|
## 10. The shortcut for the day-after-tomorrow promise
|
|
|
|
The native module is roughly the size measured in section 2.1, so it does
|
|
not ship in two days. What can work now, with no C++:
|
|
|
|
1. clone `lharries/whatsapp-mcp`, **bump its whatsmeow** (`go.mod` pins
|
|
`v0.0.0-20250318233852`, 19 months old; a stale whatsmeow risks the 405
|
|
client-outdated failure), run `whatsapp-bridge` once and scan the QR;
|
|
2. add the MCP server to detz (`mcp_add`, stdio transport,
|
|
`uv --directory .../whatsapp-mcp-server run main.py`);
|
|
3. the sentence then runs as `shell` + `describe_image` + `send_file`.
|
|
|
|
This path was **not run end to end**: it needs a linked phone, and a local
|
|
probe (an unpaired whatsmeow connect that prints QR events, which proves the
|
|
full handshake without a phone) was refused by this session's permission
|
|
classifier because it executes downloaded code. To run it yourself:
|
|
|
|
```
|
|
mkdir probe && cd probe && go mod init probe
|
|
go get go.mau.fi/whatsmeow@latest modernc.org/sqlite
|
|
# main.go: sqlstore.New("sqlite", "file:probe.db"), NewDevice, NewClient,
|
|
# GetQRChannel, Connect, print each event; expect "code" events within seconds
|
|
go run .
|
|
```
|
|
|
|
## 11. Phases
|
|
|
|
| Phase | Content | Exit test |
|
|
|---|---|---|
|
|
| 0 | MCP shortcut (section 10) | the sentence works once, live |
|
|
| 1 | `WaProto*`, `WaCrypto`, `WaXeddsa`, `WaSocket`, `WaNoise`, codec, tokens | all wire and crypto golden vectors green; live: QR events from the real server |
|
|
| 2 | `WaStore`, `WaPairing`, `WaPrekeys`, login | live: link by QR and by code, restart stays logged in |
|
|
| 3 | `WaSignal`, `WaUsync`, `WaSender`, `WaReceiver` | Signal vectors green; live: text to a peer and its reply decrypted |
|
|
| 4 | `WaMedia`, `WaContacts`, `WaTools`, CLI flags | live: the full sentence, then the MCP shortcut is removed |
|
|
|
|
Size guide (measured Go source to transpile, non-blank, non-comment): whatsmeow
|
|
files on the 1:1 path (handshake, pair, pair-code, prekeys, send, message,
|
|
receipt, upload, mediaconn, user, request, keepalive, client,
|
|
connectionevents, qrchan, retry, socket, binary, keys, crypto utils) total
|
|
8,616 lines, and only part of send, message and user is needed; wa-go wire,
|
|
keys, signal and media total 5,231. The C++ size is not measured until it exists.
|
|
|
|
## 12. Risks and gaps
|
|
|
|
- **Terms of Service.** Every unofficial client breaks WhatsApp's terms; every
|
|
project README and the one vendor article found say a number can be banned.
|
|
Use a sacrificial number for tests. The sanctioned path (Business Cloud API)
|
|
cannot use a personal number.
|
|
- **Protocol drift.** whatsmeow changes about 6 times a week (78 commits in
|
|
90 days). We follow whatsmeow; each release re-checks the version constant,
|
|
the cert key and the token dictionary.
|
|
- **wa-go is young** (created 2026-06-26, one author, 1 star). Its golden
|
|
vectors come from Baileys, which keeps them honest, but its code is not
|
|
battle tested. whatsmeow stays the referee.
|
|
- **Unverified here:** the full handshake (probe refused), XEdDSA verify via
|
|
OpenSSL Ed25519, the 20-day re-auth claim, the 405 behaviour.
|
|
|
|
## 13. Robustness against hostile input (incident WA-2026-10-27, and the standing rule)
|
|
|
|
Every byte this module parses comes off a network socket we do not control.
|
|
That makes the wire parsers (`WaBinary`, `WaProto`, and the Noise/Signal frame
|
|
readers) a security and stability boundary, not ordinary code. This section
|
|
records a crash that got through, exactly why, how it was fixed, and the rule
|
|
and test that keep it from ever coming back. **We do not accept regressions
|
|
here.**
|
|
|
|
### 13.1 What happened
|
|
|
|
Running `detz --whatsapp-link` on a real terminal, the wizard connected, showed
|
|
the QR, and after the code auto-refreshed it aborted:
|
|
|
|
```
|
|
terminate called after throwing an instance of 'std::length_error'
|
|
what(): cannot create std::vector larger than max_size()
|
|
Aborted (core dumped)
|
|
```
|
|
|
|
A core dump, not a handled error. The process died mid-session instead of
|
|
logging a bad frame and carrying on.
|
|
|
|
### 13.2 Root cause
|
|
|
|
Two independent faults lined up:
|
|
|
|
1. **An overflowable bounds check.** Both the protobuf reader and the binary-node
|
|
decoder guarded a length-delimited field with `pos + len > size`. `pos` is a
|
|
`size_t` and `len` was read straight from the wire (up to 2^32-1, or a
|
|
4-byte length sign-extended into a negative `int`). `pos + len` then wrapped
|
|
around past `SIZE_MAX`, computed a small sum, and the check passed. The code
|
|
went on to `vector::assign(begin, begin + len)` with a garbage `len`, and the
|
|
standard library threw `std::length_error` from inside `vector`.
|
|
2. **An unguarded top-level loop.** `WaClient::dispatch` routed nodes straight
|
|
into `handle_pair_success` and the other handlers with no `try/catch`. The
|
|
decode path in `recv_node` was wrapped, but the handlers that re-parse
|
|
sub-messages (the ADV identity in pairing, for instance) were not, so a
|
|
`std::length_error` raised there propagated out of `main` and called
|
|
`std::terminate`.
|
|
|
|
Either fault alone is survivable. Together they turn one malformed frame into a
|
|
process abort. This is the classic C++ failure: an arithmetic overflow in a
|
|
size computation silently defeats a bounds check, and an uncaught exception is
|
|
not a logged error but a dead process.
|
|
|
|
### 13.3 The fix (commit f16c5c6)
|
|
|
|
- **Overflow-safe length checks everywhere bytes are sliced.** The comparison is
|
|
now `len > size - pos` (never `pos + len > size`). Because `pos <= size` is an
|
|
invariant at every call site, the subtraction cannot wrap, so no attacker
|
|
value can slip through. `src/whatsapp/wa_proto.cpp` (`ProtoReader::next`) and
|
|
`src/whatsapp/wa_binary.cpp` (`Decoder::need`).
|
|
- **Lengths are read unsigned and range-checked.** The binary decoder reads
|
|
4-byte lengths into a `uint32_t` (never a sign-extendable `int`), rejects any
|
|
value larger than the whole frame, and `read_bytes` rejects negatives. A bad
|
|
length now raises `WireError`, a `std::exception` we catch, long before any
|
|
allocation.
|
|
- **Dispatch can no longer abort the process.** `WaClient::dispatch` wraps all
|
|
node routing in `try/catch(const std::exception&)`; a bad node is logged
|
|
(`dropped node <tag>: <reason>`) and the client keeps running.
|
|
|
|
### 13.4 The standing rule (project principle)
|
|
|
|
The goal is to make C++ safe to use here, which means a hostile or buggy peer
|
|
can waste our time but can never crash us or corrupt us. Three invariants, in
|
|
order of importance:
|
|
|
|
1. **No size arithmetic that can overflow a bounds check.** Never write
|
|
`pos + len <= size`. Write `len <= size - pos` against an established
|
|
`pos <= size`. Read lengths into an unsigned type wide enough to hold them,
|
|
and range-check before you allocate or slice. Treat every length, count and
|
|
index off the wire as adversarial.
|
|
2. **No unchecked allocation from an attacker-controlled count.** A length field
|
|
is a claim, not a fact. Validate it against the bytes actually present before
|
|
`reserve`, `resize`, `assign`, or a range constructor. A caller must never be
|
|
able to make us ask for a 4 GB vector.
|
|
3. **No exception may escape a top-level loop.** Any loop that reads and
|
|
dispatches untrusted input (`recv_node`, `dispatch`, the send and pairing
|
|
waits) wraps the per-item work in `try/catch(const std::exception&)` and
|
|
turns a failure into a logged, skipped item. An uncaught `std::exception` in
|
|
this module is a bug even if the parse that raised it was "impossible".
|
|
|
|
These apply to any code that reads untrusted bytes, not just WhatsApp. When a
|
|
new wire format is added (CAP, MCP, a new media type), it inherits this rule.
|
|
|
|
### 13.5 How we stop the regression
|
|
|
|
Prose does not prevent regressions; a failing test does. `tests/whatsapp_test.cpp`
|
|
has a `test_hostile` section (run by `build/whatsapp_test`, step `whatsapp.unit`
|
|
in `tests/run_all.py`, and `make test`) that feeds the parsers exactly the shapes
|
|
that used to crash and asserts they throw a *handled* exception and specifically
|
|
**not** `std::length_error`:
|
|
|
|
- a binary node whose content length claims 0xFFFFFFFF in an 11-byte buffer;
|
|
- a protobuf field whose length varint is ~4 billion with no payload;
|
|
- truncated and short frames;
|
|
- 2000 random byte strings through both `WaBinary` and `ProtoReader` (surviving
|
|
the loop is the proof: a single unchecked allocation would abort the run).
|
|
|
|
Each case `catch`es `std::length_error` and fails the test if it is thrown, so
|
|
if anyone reintroduces a `pos + len` check or an unguarded slice, this test goes
|
|
red. If you add a new parser, add its hostile cases here in the same pass; a
|
|
parser with no adversarial test is treated as unfinished.
|
|
|
|
### 13.6 Follow-up: the first fix was not enough, and how we proved it
|
|
|
|
Section 13.3's fix stopped the abort but was declared "verified" on a
|
|
hostile-input test that never exercised the live decode path. That was
|
|
overconfident: a later run still logged `dropped node <iq>: cannot create
|
|
std::vector larger than max_size()`. The crash was gone (the `try/catch` held),
|
|
but a real frame was still being mis-handled and dropped.
|
|
|
|
The cure was to stop reasoning and use a tool. Building the parsers under
|
|
AddressSanitizer + UndefinedBehaviorSanitizer + `_GLIBCXX_ASSERTIONS`
|
|
([`../tools/san-whatsapp.sh`](../tools/san-whatsapp.sh)) immediately found a
|
|
second, independent undefined-behavior bug that two manual reviews had missed:
|
|
a protobuf varint loop that shifted by up to 70 bits (shift >= 64 is UB) on
|
|
malformed input. It is now fixed and guarded by a deterministic test case and
|
|
the sanitizer net. Two more defenses were added:
|
|
|
|
- **Frame capture.** `WaClient` records the exact bytes of any frame that fails
|
|
to decode or dispatch to `<home>/whatsapp/last-error.log` (bounded). The next
|
|
time the still-unreproduced live `<iq>` failure happens, the precise input is
|
|
on disk and becomes a one-paste regression test and a deterministic fix.
|
|
- **The sanitizer net is now the standing requirement**, not a one-off. See the
|
|
project-wide playbook, [cpp-safety.md](cpp-safety.md), which this incident
|
|
produced: overflow-safe checked-reader parsing, cheap always-on release
|
|
hardening, the ASan+UBSan test net, and capture.
|
|
|
|
The honest open item: the exact frame the user hit is still unreproduced
|
|
in-session (the server did not resend it within a 160 s sanitizer run). The
|
|
capture hook exists precisely so that gap closes on its next occurrence rather
|
|
than by more guessing.
|
|
|
|
### 13.7 Root cause, found and fixed (the capture hook paid off)
|
|
|
|
The capture hook from 13.6 did its job: the next `--whatsapp-link` run recorded
|
|
the exact 726-byte frame to `<home>/whatsapp/last-error.log`, and replaying it
|
|
made the bug deterministic in minutes. The verdict overturns every earlier
|
|
guess: **it was never a parser bug.** The frame decoded perfectly (it was a
|
|
normal `pair-success` with a `device-identity`, `device`, `biz`, and
|
|
`encryption-metadata`). The `std::vector larger than max_size()` was thrown by
|
|
`WaDevice::serialize()`, in `WaClient::handle_pair_success` saving the newly
|
|
paired device:
|
|
|
|
```cpp
|
|
put_blob(b, Bytes((jid ? *jid : "").begin(), (jid ? *jid : "").end()));
|
|
```
|
|
|
|
`(jid ? *jid : "")` builds a **fresh temporary `std::string` for each
|
|
evaluation**, so `.begin()` and `.end()` point into two different, already
|
|
destroyed objects. The range constructor computed a garbage length and the
|
|
vector threw. It only misfired once `jid` was set, which is why it appeared
|
|
only after the phone scanned, and why the initial unpaired save and every
|
|
parser test passed. The fix binds the value to a named `const std::string`
|
|
before taking iterators. Verified by replaying the captured frame end to end
|
|
(serialize, save, encode the response: no throw) and by a paired-device
|
|
serialize round-trip test (`tests/whatsapp_test.cpp` `test_device`) that runs
|
|
under the sanitizer net.
|
|
|
|
The lesson is in [cpp-safety.md](cpp-safety.md): this was a C++ object-lifetime
|
|
bug, a different class from the earlier size-overflow, and it slipped past the
|
|
parser-focused tests and the first sanitizer runs because those never exercised
|
|
`serialize()` with a set optional. Two defenses were strengthened as a result:
|
|
the capture hook (which turned an unreproducible failure into a deterministic
|
|
one), and the rule that a round-trip test must cover the populated case, not
|
|
just the empty one. The honest open item from 13.6 is now closed.
|
|
|
|
## 14. App-state sync: the contact list (`WaAppState`, `WaReceiver`)
|
|
|
|
Status: implemented and verified offline. The live path needs a re-linked
|
|
phone: nothing here has been run against a phone that holds contacts.
|
|
|
|
Why: the phone's address book reaches a linked companion only through app-state
|
|
sync (collection `critical_unblock_low`, index `["contact", "<jid>"]`). Without
|
|
it `wa_contact` stays empty and names cannot be resolved.
|
|
|
|
| Piece | Files | What it does |
|
|
|---|---|---|
|
|
| Key receipt | `wa_receiver.*`, `wa_proto.*` (`parse_protocol_message`), `WaClient::handle_message` | Decrypts `<enc>` from our own account (pkmsg via X3DH responder, msg via session), unpads, stores each APP_STATE_SYNC_KEY_SHARE key in `wa_appstate_key`. Only the phone's own messages are decrypted; other senders are acked and ignored as before. |
|
|
| Key request | `WaClient::request_app_state_keys`, `send_peer_message`, `encode_app_state_key_request` | Peer message to the primary phone (category `peer`), at most `kWaAppStateKeyIdsMax` ids, one request per key id per `kWaAppStateKeyRequestInterval` (24 h), stored in `wa_appstate_keyreq`. |
|
|
| Key expansion, MACs, values | `wa_appstate.*` (`WaAppStateKeys`, `WaAppStateCrypto`) | HKDF-SHA256 `WhatsApp Mutation Keys` to 160 bytes; value MAC HMAC-SHA512 truncated to 32; index, snapshot and patch MACs HMAC-SHA256; AES-256-CBC values. `WaCrypto::hmac_sha512` added. |
|
|
| LTHash | `wa_appstate.*` (`WaLtHash`) | 128-byte state, HKDF-SHA256 info `WhatsApp Patch Integrity` (whatsmeow, not BLAKE2b; see research conclusion `appstate-lthash-hkdf-not-blake2b`). |
|
|
| Patch fetch and apply | `WaAppStateSync::sync` | iq `w:sync:app:state`, `return_snapshot` at version 0, `version=N` otherwise; external blobs via `WaMedia::download` (`md-app-state`); snapshot MAC, patch MAC, index and value MACs checked before anything is stored. |
|
|
| Contacts | `WaAppStateSync::apply_contact` | SET stores jid = index[1], name = fullName else firstName; empty name is counted and skipped; REMOVE deletes the row. |
|
|
| Login hook | `WaClient::login` calls `sync_app_state` after prekeys | Never fails the login (errors are logged). If a key is missing, requests it, waits up to `kWaAppStateKeyWait` s for a key share, then syncs once more. |
|
|
|
|
Caps (`wa_consts.h`): `kWaAppStatePatchMax` 50 patches per sync,
|
|
`kWaAppStateMutationMax` 10000 mutations, `kWaAppStateTimeout` 30 s per iq,
|
|
`kWaAppStateBlobMax` 16 MiB per external blob, `kWaAppStateKeyIdsMax` 16 key
|
|
ids per request, `kWaAppStateKeyWait` 20 s. A cap stops the sync loudly
|
|
(`capped` in the report, a log line) and the rest waits for the next login.
|
|
|
|
Failure rule: a patch or snapshot that fails a MAC or hash check is not stored,
|
|
the sync stops, and the collection version stays at the last verified state.
|
|
This skips the bad patch and everything after it, because each later patch
|
|
depends on the hash chain.
|
|
|
|
Tables: `wa_appstate_key`, `wa_appstate_version`, `wa_appstate_mac` (value MAC
|
|
per index MAC, needed to undo a SET or REMOVE), `wa_appstate_keyreq`. `clear_device`
|
|
empties them, so a new link syncs from version 0.
|
|
|
|
Verification (offline, `build/whatsapp_test`, section `appstate`):
|
|
|
|
- expanded keys equal whatsmeow `expandAppStateKeys` bytes;
|
|
- LTHash add/remove cancel and are order-free;
|
|
- a snapshot plus one patch (SET, REMOVE, in-patch replacement, unnamed SET, a
|
|
non-contact SET) decodes to the oracle's final hash and version, and the
|
|
contact table matches the oracle's expected names;
|
|
- a re-sync at version 2 changes nothing;
|
|
- a tampered patch is rejected and not stored;
|
|
- a missing key stores nothing and reports the key id;
|
|
- the key share and key request bytes match whatsmeow's Message encoding;
|
|
- a pkmsg built with `WaSession` decrypts through `WaReceiver` to the key share
|
|
Message, and a corrupted pkmsg is refused.
|
|
|
|
The oracle is `tests/whatsapp_data/appstate_vectors.json`, generated by
|
|
`tests/whatsapp_data/appstate_oracle.go` (a Go program using whatsmeow's own
|
|
`lthash`, `hkdfutil` and protobuf packages). It is not run by `make test`.
|
|
|
|
Not verified: the live server's answers to the iq, key share timing, the media
|
|
host for external blobs, and whether a REMOVE of an unknown index (whatsmeow
|
|
only warns) causes trouble. Known gap: a SET with an empty name keeps any older
|
|
name the row had; the spec says skip, not delete.
|
|
|
|
|
|
|
|
## 15. Incident WA-2026-10-27b: link ended in 401, no picture, no contacts
|
|
|
|
Everything here was measured on 2026-10-27/28 against the live server and the user's phone.
|
|
|
|
| Symptom | Root cause | Fix | Evidence |
|
|
|---|---|---|---|
|
|
| `Linked, but the login confirmation did not complete`; later login `failure 401` | The decoder printed a server-only JID (empty user) as `@s.whatsapp.net`; the pairing replies copied that into `to`, so the server refused `pair-device-sign` and never registered the device | decoder returns the bare server name; encoder writes bare server names as JID pairs; replies use `WaConsts::kServerJid` as whatsmeow does | captured `pair-success` decoded (`from=@s.whatsapp.net`), live login probe 401 before, `logged in` after re-link; test `server-only JID round-trips` |
|
|
| wizard never reported the real login result | `run_until` returned at once because `paired_event_` stayed set | `run_until` is stateless; `WaClient::await_pair_restart` + `WaClient::login`; one fresh pairing after a 401/403 (`kWaLinkRetries`) | wizard source |
|
|
| `user 7m` CPU while linking | a closed socket made every receive return instantly and the loops spun until their deadline | `WaSocket` clears `connected_` on close, every loop checks `WaClient::socket_open()` | probe after the 401: 1.7 s, 0.01 s CPU (was 20 s of spinning) |
|
|
| PNG could not be sent | the `whatsapp_send` tool had no image argument and `send_image` was a stub | `WaMedia` (HKDF `WhatsApp Image Keys`, AES-CBC, 10-byte HMAC, `media_conn`, HTTPS upload), `send_image`, DeviceSentMessage copy to our other devices, tool argument `image` | 8/8 wa-go golden vectors byte-exact; live: image to own number acknowledged by the server |
|
|
| Nigel not found | the phone address book reaches a companion only through app-state sync, which did not exist; and the phone only pushes history and keys to a companion that declares `DeviceProps.historySyncConfig`, which registration omitted | section 14 (`WaAppState`, `WaReceiver`); `WaDeviceProps` now sends `historySyncConfig` (field 5, whatsmeow values); peer key request encrypted to the phone's LID identity | unit tests; the live address book needs a link made with this build |
|
|
| name lookups were exact or one fuzzy guess | contact table only, one `FuzzyMatcher::Best` | `WaStore::search_contacts`: FTS5 trigram candidates + fuzzy rank; ambiguity (two scores within 0.08) is reported, never guessed; the global `search` tool lists matching contacts | tests `contact search: ...` |
|
|
| no send limits | caps were specified, not enforced | `wa_send_log`, `kWaSendPerMinute` 10 and `kWaSendPerDay` 200 enforced in `whatsapp_send` | test `send log counts sends in the window` |
|
|
|
|
Gaps: the contact sync and the address-book key share are not verified against a phone yet; a newly linked device
|
|
must be created with this build for the phone to send history. Set `DETZ_WA_TRACE=1` to log every node's tag and
|
|
attributes when debugging a protocol exchange.
|