diff --git a/.gitignore b/.gitignore
index 24eecf2..fcf7134 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,6 +18,9 @@ npm/platforms/*/bin/
venv/
ENV/
+# Local feature worktrees
+.worktrees/
+
# IDE
.idea/
.vscode/
diff --git a/README.md b/README.md
index 1d877d2..bcfb91a 100644
--- a/README.md
+++ b/README.md
@@ -2,13 +2,13 @@
# WeChat CLI
-**Query your local WeChat data from the command line.**
+**Query local WeChat data and optionally send group text in the background.**
[](https://www.npmjs.com/package/@canghe_ai/wechat-cli)
[](https://opensource.org/licenses/Apache-2.0)
[](https://github.com/freestylefly/wechat-cli)
-Chat history · Contacts · Sessions · Favorites · Statistics · Export
+Chat history · Contacts · Sessions · Favorites · Statistics · Export · Group send
[中文文档](README_CN.md)
@@ -19,9 +19,9 @@ Chat history · Contacts · Sessions · Favorites · Statistics · Export
## ✨ Highlights
- **🚀 Zero-config install** — `npm install -g` and you're done, no Python needed
-- **📦 11 commands** — sessions, history, search, contacts, members, stats, export, favorites, unread, new-messages, init
+- **📦 12 commands** — all existing query commands plus exact-group background text sending with `send`
- **🤖 AI-first** — JSON output by default, designed for LLM agent tool calls
-- **🔒 Fully local** — on-the-fly SQLCipher decryption, data never leaves your machine
+- **🔒 Local queries** — on-the-fly SQLCipher decryption; queried data stays on your machine
- **📊 Rich analytics** — top senders, message type breakdown, 24-hour activity charts
- **📝 Flexible export** — Markdown or plain text, with time range filtering
@@ -141,12 +141,14 @@ sudo codesign --force --sign - --entitlements /dev/stdin /Applications/WeChat.ap
com.apple.security.get-task-allow
+ com.apple.security.cs.disable-library-validation
+
EOF
```
-> **Heads up:** Re-signing WeChat is safe and will **not** cause account issues or bans. However, it may affect WeChat's auto-update mechanism. If you notice any feature not working properly, or want to update WeChat to the latest version, simply re-download and reinstall WeChat from the [official website](https://mac.weixin.qq.com/) — no need to re-run `init`, your existing config and keys will continue to work.
+> Re-signing changes the app signature and may affect auto-update, app features, or account risk controls. Reinstall WeChat from its official site to restore the original app.
### Step 2 — Use It
@@ -154,6 +156,7 @@ EOF
wechat-cli sessions # Recent chats
wechat-cli history "Alice" --limit 20 # Chat messages
wechat-cli search "deadline" --chat "Team" # Search messages
+wechat-cli send "Team" "Hello everyone" # Background group text
```
---
@@ -257,6 +260,35 @@ wechat-cli members "Team Group" # All members (JSON)
wechat-cli members "Team Group" --format text
```
+### `send` — Background Group Text (Experimental)
+
+```bash
+wechat-cli send "Exact Unique Group Name" "Message text"
+wechat-cli send "54597320555@chatroom" $'Line one\nLine two' --timeout 15 --format json
+```
+
+`send` **only sends text messages to group chats**. It does not support direct messages, File Transfer, images, files, voice, video, stickers, or other message types. The group name must be a unique exact match, or a real `@chatroom` ID present in the contact database. Fuzzy names, duplicate group names, forged `@chatroom` IDs, and non-group contacts are rejected before submission.
+
+The send flow is:
+
+1. Strictly verify macOS ARM64, WeChat 4.1.8, process identity, the binary UUID, and pinned function signatures.
+2. Capture the target group's local-message baseline, then load a locally authenticated in-process bridge on the first call.
+3. On WeChat's main thread, the bridge invokes the pinned official message-task builder and submit chain. It does not focus WeChat, use the input box, or simulate keyboard or mouse input.
+4. WeChat performs its normal network submission and writes its own local message record. The CLI never modifies the WeChat database directly; it polls the database read-only, so a confirmed message is visible on both mobile and the current Mac client.
+5. Success is returned only after a post-baseline outgoing text row matches the target, sender, and exact text and has a positive `local_id`, positive `server_id`, text type, and sent status.
+
+The default JSON success result includes `success`, `status`, `request_id`, `group`, `username`, `local_id`, and `server_id`. `status: server_accepted` means the local database contains a server-assigned message ID.
+
+| Exit code | Meaning | Retry guidance |
+|---|---|---|
+| `0` | Server acceptance and Mac-local visibility confirmed | No retry needed |
+| `1` | Target is missing, ambiguous, or not a group | Correct the target, then retry |
+| `2` | Text or `timeout` is invalid | Correct the input, then retry |
+| `3` | Environment, version, permission, or bridge failed before submission | Correct the environment, then retry |
+| `4` | Submission boundary was crossed, but the final result is unknown | **Never retry automatically** |
+
+Submission is irreversible. Exit code `4` / `status: unknown` means the action may have happened but could not be confirmed; inspect the target group manually before deciding what to do. Unsupported builds, signing problems, and missing permissions are rejected before submission whenever they can be identified safely.
+
### `stats` — Chat Statistics
```bash
@@ -327,9 +359,9 @@ The `--type` option (on `history` and `search`):
## 💻 System Requirements
- **macOS** ≥ 26.3.1
-- **WeChat for Mac** ≤ 4.1.8.100
+- **WeChat for Mac** 4.1.8
-> Older macOS versions or newer WeChat versions may not be compatible.
+> `send` additionally validates a pinned WeChat 4.1.8 binary fingerprint. Other WeChat versions may not be compatible.
---
@@ -342,6 +374,8 @@ The `--type` option (on `history` and `search`):
| Windows | ✅ Supported | Reads Weixin.exe process memory |
| Linux | ✅ Supported | Reads /proc/pid/mem, requires root |
+`send` currently supports macOS ARM64 WeChat 4.1.8 and validates a pinned binary fingerprint at runtime.
+
---
## 🔧 How It Works
@@ -351,6 +385,7 @@ WeChat stores chat data in SQLCipher-encrypted SQLite databases locally. WeChat
1. **Extracts keys** — scans WeChat process memory for encryption keys (`init`)
2. **Decrypts on-the-fly** — transparent page-level AES-256-CBC decryption with caching
3. **Queries locally** — all data stays on your machine, no network access
+4. **Optional background send** — `send` submits group text through the running desktop WeChat process; query commands remain read-only
---
@@ -362,11 +397,12 @@ WeChat stores chat data in SQLCipher-encrypted SQLite databases locally. WeChat
## ⚖️ Disclaimer
-This project is a local data query tool for personal use only. Please note:
+This project is for personal management of local WeChat data and opt-in group text sending. Please note:
-- **Read-only** — this tool only reads locally stored data, it does not send, modify, or delete any messages
-- **No cloud transmission** — all data stays on your local machine, nothing is uploaded to any server
-- **No WeChat ecosystem disruption** — this tool does not interfere with WeChat's normal operation, does not automate any actions, and does not violate WeChat's Terms of Service
+- **Read-only queries** — query commands do not modify WeChat databases; `send` creates a real, irreversible external message
+- **Local processing** — queried data is not uploaded to this tool's servers; sent text is submitted normally by the WeChat client
+- **Version and account risk** — `send` supports WeChat 4.1.8 with a pinned binary fingerprint and process loading/re-signing, and may crash, stop working, affect updates, or trigger account controls
+- **Never retry unknown results** — after a timeout or incomplete receipt, the message may already have been sent
- **Use at your own risk** — this project is for personal learning and research purposes only. Users are responsible for ensuring compliance with local laws and regulations
---
diff --git a/README_CN.md b/README_CN.md
index 6483703..ba61d89 100644
--- a/README_CN.md
+++ b/README_CN.md
@@ -2,13 +2,13 @@
# WeChat CLI
-**命令行查询本地微信数据,专为 AI 集成设计。**
+**命令行查询本地微信数据,并按需后台发送群聊文本。**
[](https://www.npmjs.com/package/@canghe_ai/wechat-cli)
[](https://opensource.org/licenses/Apache-2.0)
[](https://github.com/freestylefly/wechat-cli)
-聊天记录 · 联系人 · 会话 · 收藏 · 统计 · 导出
+聊天记录 · 联系人 · 会话 · 收藏 · 统计 · 导出 · 群聊发送
[English](README.md)
@@ -19,9 +19,9 @@
## ✨ 功能亮点
- **🚀 开箱即用** — `npm install -g` 一键安装,无需 Python
-- **📦 11 个命令** — sessions、history、search、contacts、members、stats、export、favorites、unread、new-messages、init
+- **📦 12 个命令** — 原有查询命令之外,增加精确群聊后台文本发送 `send`
- **🤖 AI 优先** — 默认 JSON 输出,专为 LLM Agent 工具调用设计
-- **🔒 全程本地** — SQLCipher 即时解密,数据不出本机
+- **🔒 本地查询** — SQLCipher 即时解密,查询数据不出本机
- **📊 丰富统计** — 发言排行、消息类型分布、24 小时活跃图
- **📝 灵活导出** — Markdown 或纯文本,支持时间范围过滤
@@ -139,12 +139,14 @@ sudo codesign --force --sign - --entitlements /dev/stdin /Applications/WeChat.ap
com.apple.security.get-task-allow
+ com.apple.security.cs.disable-library-validation
+
EOF
```
-> **温馨提示:** 重新签名是安全的,**不会**导致封号或账号异常。但可能影响微信的部分功能或自动更新。如果发现任何功能异常(如搜一搜无法使用),或想更新到微信最新版,直接从[微信官网](https://mac.weixin.qq.com/)重新下载安装即可,**无需重新执行 init**,已有的配置和密钥不受影响。
+> 重新签名会改变应用签名,可能影响自动更新、部分功能或账号风控。需要恢复时,请从微信官网重新安装。
### 第二步 — 开始使用
@@ -152,6 +154,7 @@ EOF
wechat-cli sessions # 最近会话
wechat-cli history "张三" --limit 20 # 聊天记录
wechat-cli search "截止日期" --chat "项目组" # 搜索消息
+wechat-cli send "项目组" "大家好" # 后台发送群文本
```
---
@@ -255,6 +258,35 @@ wechat-cli members "AI交流群" # 成员列表
wechat-cli members "AI交流群" --format text
```
+### `send` — 后台发送群文本(实验性)
+
+```bash
+wechat-cli send "唯一完整群名" "消息文本"
+wechat-cli send "54597320555@chatroom" $'第一行\n第二行' --timeout 15 --format json
+```
+
+`send` **只能发送群聊文本消息**。它不支持私聊、文件传输助手、图片、文件、语音、视频、表情或其他消息类型。群名必须唯一且完全匹配,也可以使用联系人库中真实存在的 `@chatroom` ID;模糊群名、同名群、伪造的 `@chatroom` ID 和非群聊联系人都会在提交前被拒绝。
+
+发送链路如下:
+
+1. 严格校验 macOS ARM64、WeChat 4.1.8、进程身份、二进制 UUID 与关键函数签名。
+2. 记录目标群发送前的本地消息基线,首次调用时按需加载带本地凭据认证的进程内 bridge。
+3. bridge 在微信主线程调用已固定指纹的官方消息任务构建/提交链;不会聚焦微信窗口,也不会操作输入框或模拟键鼠。
+4. 微信客户端负责正常网络提交并写入自己的本地消息库。CLI 不直接修改微信数据库,只以只读方式轮询确认结果,因此成功消息会出现在手机端和当前 Mac 微信中。
+5. 仅当基线之后出现一条目标、发送者和文本完全一致,且同时具有正 `local_id`、正 `server_id`、文本类型和已发送状态的本地记录时,命令才返回成功。
+
+默认 JSON 成功结果包含 `success`、`status`、`request_id`、`group`、`username`、`local_id` 和 `server_id`。`status: server_accepted` 表示本地库已经观察到服务器分配的消息 ID。
+
+| 退出码 | 含义 | 是否可以重试 |
+|---|---|---|
+| `0` | 已确认服务器接受且 Mac 本地消息可见 | 不需要 |
+| `1` | 群目标不存在、不唯一或不是群聊 | 修正目标后可以 |
+| `2` | 文本或 `timeout` 参数无效 | 修正参数后可以 |
+| `3` | 在提交前发现环境、版本、权限或 bridge 不可用 | 修正环境后可以 |
+| `4` | 已进入提交边界,但最终结果无法确认 | **禁止自动重试** |
+
+消息一旦提交便不可撤销。退出码 `4` / `status: unknown` 表示动作可能已经发生但无法确认,必须人工查看目标群后再决定;微信升级、重新签名失败或权限不足会尽量在提交前拒绝发送。
+
### `stats` — 聊天统计
```bash
@@ -325,9 +357,9 @@ wechat-cli new-messages # 后续: 仅返回上次以来的新
## 💻 系统要求
- **macOS** ≥ 26.3.1
-- **微信 Mac 版** ≤ 4.1.8.100
+- **微信 Mac 版** 4.1.8
-> macOS 老版本或更新的微信版本可能不兼容。
+> `send` 会额外校验 WeChat 4.1.8 的固定二进制指纹。其他微信版本可能不兼容。
---
@@ -340,6 +372,8 @@ wechat-cli new-messages # 后续: 仅返回上次以来的新
| Windows | ✅ 支持 | 读取 Weixin.exe 进程内存 |
| Linux | ✅ 支持 | 读取 /proc/pid/mem,需要 root |
+`send` 目前支持 macOS ARM64 WeChat 4.1.8,并在运行时校验固定二进制指纹。
+
---
## 🔧 工作原理
@@ -349,6 +383,7 @@ wechat-cli new-messages # 后续: 仅返回上次以来的新
1. **提取密钥** — 扫描微信进程内存获取加密密钥(`init`)
2. **即时解密** — 透明页级 AES-256-CBC 解密,带缓存
3. **本地查询** — 所有数据留在本机,无需网络访问
+4. **可选后台发送** — `send` 通过当前桌面微信进程提交群文本;查询命令仍只读
---
@@ -360,11 +395,12 @@ wechat-cli new-messages # 后续: 仅返回上次以来的新
## ⚖️ 免责声明
-本项目为个人使用的本地数据查询工具,请注意:
+本项目供个人管理本机微信数据及按需发送群文本,请注意:
-- **只读不写** — 本工具仅读取本地存储的数据,不会发送、修改或删除任何消息
-- **数据不出本机** — 所有数据仅在你本机处理,不会上传至任何云端服务器
-- **不破坏微信生态** — 本工具不会干扰微信正常运行,不会自动化任何操作,不违反微信使用协议
+- **查询只读** — 查询命令不修改微信数据库;`send` 会产生真实、不可撤销的外部消息副作用
+- **本地处理** — 查询数据不上传到本工具的服务器;发送内容会由微信客户端正常提交给微信服务
+- **版本与账号风险** — `send` 支持 WeChat 4.1.8,并依赖固定二进制指纹和重新签名/进程加载,可能崩溃、失效、影响更新或触发账号风控
+- **未知状态不重试** — 超时或回执不完整时消息可能已经发送,调用方必须人工确认
- **风险自担** — 本项目仅供个人学习研究使用,使用者需确保遵守当地法律法规
---
diff --git a/docs/research/wechat-4.1.8.28-local-store-candidates.md b/docs/research/wechat-4.1.8.28-local-store-candidates.md
new file mode 100644
index 0000000..9050e7c
--- /dev/null
+++ b/docs/research/wechat-4.1.8.28-local-store-candidates.md
@@ -0,0 +1,58 @@
+# WeChat 4.1.8.28 Local-Store Boundary Map
+
+This note records only entry points verified against the pinned ARM64 slice.
+It deliberately does not assign function roles to direct field writes or to
+addresses that fall inside larger functions.
+
+## Image identity
+
+- Path: `/private/tmp/wechat-arm64-profile.dylib`
+- SHA-256: `0e8c932461b883a4e4dc90313a14a30ea69a190fedf1083d014259bec56da2e0`
+- UUID: `ABD88EC8-4590-3FDB-AAB5-4E5865B86B2A`
+- Architecture: ARM64
+
+## Active read-only probes
+
+| Label | RVA | Entry bytes | Evidence |
+| --- | ---: | --- | --- |
+| `model_text_finalize` | `0x25634B0` | `00e1049121411491a45ce794a92240a9` | Final text assignment immediately before the verified insert call. The same function loads a pre-existing shared message model into `x8`, sets `x0 = x8 + 0x138`, and copies text from `x9 + 0x510`; the subsequent insert receives that model in `x1`. |
+| `insert` | `0x26E620C` | `e93c0390294942f9490000b420011fd6` | Function prologue or runtime-patch branch. Caller at `0x253B858` sets `x8` to the sret destination, `x0` to the storage/service object, and leaves the shared message model in `x1`. The wrapper calls `0x44CB850(x1)`, then tail-calls `0x26E6264` with `x2` set to the extracted string and `w3 = 1`. |
+| `insert_impl` | `0x26E6264` | `e93c0390294d42f9490000b420011fd6` | Tail-call target of the verified insert wrapper. Its prologue preserves the message model from `x1` and the sret pointer from `x8`. |
+| `mars_submit` | `0x498D2E0` | `ff0305d1fc6f11a9f44f12a9fd7b13a9` | Function entry with a normal ARM64 prologue. Nearby literals identify `mars::mmext`, `MMStartTask`, and `mmstn_manager.cc`. This is the already measured Mars task-submission boundary. |
+
+These four probes are installed together and stay within the capture tool's
+conservative four-breakpoint ARM64 budget. The profiler only reads registers,
+bounded memory, and backtraces. It never evaluates an expression, calls a
+WeChat function, or writes target memory.
+
+The approved `WCPROFILE-20260802-01`, `WCPROFILE-20260802-02`, and
+`WCPROFILE-20260803-01` captures establish the observed transaction as
+`insert -> insert_impl -> mars_submit`. The third capture recorded the exact
+marker in the model passed as `insert` argument `x1`, while the constructor
+probe did not fire at all. `model_text_finalize`, `insert`, or `insert_impl`
+may establish the marker transaction identity. Text-finalize capture tracks
+the model in `x8`; insert capture tracks the shared model pointer in `x1`;
+Mars is correlated by the coroutine thread and marker reachability.
+
+## Rejected candidates
+
+| Candidate | Status | Reason |
+| --- | --- | --- |
+| Model constructors at `0x44C9EA8`, `0x44CAE3C`, and `0x44CB324` | Rejected for action-time capture | The first constructor did not fire during the exact marker send. Static data flow at `0x2562BFC` shows the send coroutine receives the model through an existing shared pointer, so construction may precede the bounded action window. The other variants decode or copy stored records and do not improve action-time identity. |
+| Recipient/client-id/create-time setters | Unresolved and not probed | Static call chains show that some values are assigned with direct field writes. No distinct function entry has been proved for these roles. |
+| `0x26EA4FC` update | Rejected | Its caller at `0x253B018` belongs to the function beginning near `0x253AF94`, while insert's caller at `0x253B858` belongs to the separate function beginning at `0x253B798`. Neither approved marker capture hit this boundary, so it is not part of the observed outgoing insert transaction. |
+| `0x372FD60` response decoder | Rejected pending runtime evidence | It is a real function, but current static evidence only supports a generic decoder/parser role, not the send-response boundary. |
+| `0x202906C` notification | Rejected | The address lies inside the function spanning approximately `0x2028478..0x2029CD4`; it is not a valid entry point. |
+| `0x25CD228` high-level send | Forbidden | Direct calls have a deterministic crash path because the surrounding Owl coroutine context is missing. |
+| `0x3399194` UI coroutine closure | Excluded from this capture | It is a real closure entry, but observing the insert/update transaction does not require triggering or probing UI send code. |
+
+## Next evidence target
+
+The inserted model's first pointer resolves to vtable RVA `0x8A0E138`. In the
+send function beginning at `0x2562BB0`, `0x2562BFC` copies the pre-existing
+shared model pointer into the stack slot later loaded by `0x25634A4`.
+`0x25634B0` then assigns final text through that model's `+0x138` string before
+the call leading to insert. Runtime evidence must now prove that
+`model_text_finalize` register `x8` equals the model observed as insert and
+insert-impl register `x1`. Response decode and conversation notification remain
+unresolved and must not be inferred from the rejected update candidate.
diff --git a/docs/superpowers/plans/2026-08-02-local-visible-send.md b/docs/superpowers/plans/2026-08-02-local-visible-send.md
new file mode 100644
index 0000000..314836a
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-02-local-visible-send.md
@@ -0,0 +1,626 @@
+# Persistent Local-Visible Send Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Keep the proven single `/newsendmsg` submission and add a persistent outgoing WeChat row that appears in the desktop conversation and `wechat-cli history` after restart.
+
+**Architecture:** A transaction adapter coordinates two pinned gateways: `LocalMessageGateway` inserts and updates messages through WeChat's internal message-storage queue, while `MarsGateway` retains the working network task implementation from `feat/wechat-send`. The transaction reuses one client identifier across both gateways, returns success only after the same positive `local_id` has a positive `server_id`, and fails closed while any runtime profile field is unverified.
+
+**Tech Stack:** C++17/Objective-C++, ARM64 assembly trampolines, macOS GCD/Mach APIs, Python 3, pytest, LLDB for read-only profiling.
+
+---
+
+## File map
+
+- `native/include/wechat_bridge.hpp`: Existing bridge request/receipt contract; add internal gateway result types and factories without changing the socket JSON.
+- `native/src/local_visible_adapter.mm`: New queue-independent transaction state machine, tested entirely with fake gateways.
+- `native/src/wechat_adapter.mm`: Restore the proven Mars task implementation, then adapt it to the `MarsGateway` interface.
+- `native/src/wechat_local_store.mm`: New strictly pinned WeChat storage/update gateway; no SQL and no direct database writes.
+- `native/include/wechat_4_1_8_28_profile.hpp`: Generated constants for measured RVAs, entry signatures, ABI offsets, and queue boundary.
+- `native/src/hook_trampolines.S`: Retain the proven request serialization hooks and add only a response hook if the measured virtual response boundary cannot complete directly.
+- `native/build.sh`: Compile the new transaction and local-store translation units and retain hook trampolines.
+- `native/tests/fake_host.mm`: Native fake gateway scenarios and process-level contract checks.
+- `tests/test_native_bridge_host.py`: Red/green tests for local insert, single network submission, acknowledgement update, timeout, and fail-closed behavior.
+- `tools/wechat_profile_capture.py`: Read-only LLDB capture helpers for one user-performed marker send.
+- `tools/validate_wechat_profile.py`: Convert measured capture data into the pinned header only when all invariants agree.
+- `tests/test_wechat_profile_validation.py`: Offline validation tests using sanitized capture fixtures.
+- `docs/research/wechat-4.1.8.28-local-store-profile.md`: Human-readable evidence for every pinned field.
+- `wechat_cli/core/native_sending.py`: No behavior change expected; regression tests prove it confirms the exact local row.
+
+### Task 1: Restore the proven Mars baseline on the isolated branch
+
+**Files:**
+- Modify: `native/build.sh`
+- Modify: `native/include/wechat_bridge.hpp`
+- Modify: `native/src/wechat_adapter.mm`
+- Modify: `native/tests/fake_host.mm`
+- Modify: `tests/test_native_bridge_host.py`
+- Delete: `codex_lldb_once.txt`
+- Delete: `codex_map_functions.py`
+
+- [ ] **Step 1: Record the expected baseline difference**
+
+Run:
+
+```bash
+git diff --exit-code feat/wechat-send -- \
+ native/build.sh native/include/wechat_bridge.hpp \
+ native/src/wechat_adapter.mm native/tests/fake_host.mm \
+ tests/test_native_bridge_host.py
+```
+
+Expected: non-zero because the unsafe direct UI-closure experiment differs from the working branch.
+
+- [ ] **Step 2: Restore only the five experimental source/test files with `apply_patch`**
+
+Use `git show feat/wechat-send:` as the exact source for each file. Preserve the revised design and plan commits, and do not switch or modify `feat/wechat-send` itself. Remove the dead-PID LLDB command and the unused cross-build mapping helper; keep useful capture logic for Task 3.
+
+- [ ] **Step 3: Verify exact source parity with the working adapter**
+
+Run the same `git diff --exit-code` command from Step 1.
+
+Expected: exit 0 with no output.
+
+- [ ] **Step 4: Run the native bridge regression suite**
+
+Run:
+
+```bash
+PYTHONPATH=. pytest -q tests/test_native_bridge_host.py
+```
+
+Expected: all tests pass and the fake host builds with `hook_trampolines.S` linked.
+
+- [ ] **Step 5: Commit the clean baseline**
+
+```bash
+git add native/build.sh native/include/wechat_bridge.hpp \
+ native/src/wechat_adapter.mm native/tests/fake_host.mm \
+ tests/test_native_bridge_host.py
+git commit -m "refactor: restore proven Mars send baseline"
+```
+
+### Task 2: Add the local-visible transaction contract
+
+**Files:**
+- Create: `native/src/local_visible_adapter.mm`
+- Modify: `native/include/wechat_bridge.hpp`
+- Modify: `native/build.sh`
+- Modify: `native/tests/fake_host.mm`
+- Modify: `tests/test_native_bridge_host.py`
+
+- [ ] **Step 1: Write failing fake-gateway tests**
+
+Add fake-host modes and Python tests for these exact cases:
+
+```python
+@pytest.mark.parametrize(
+ ("scenario", "expected_local_calls", "expected_network_calls", "expected_update_calls"),
+ [
+ ("insert-failed", 1, 0, 0),
+ ("network-unknown", 1, 1, 0),
+ ("accepted", 1, 1, 1),
+ ],
+)
+def test_local_visible_transaction_order(
+ native_build,
+ scenario,
+ expected_local_calls,
+ expected_network_calls,
+ expected_update_calls,
+):
+ result = _run_transaction_scenario(native_build, scenario)
+ assert result["insert_calls"] == expected_local_calls
+ assert result["network_calls"] == expected_network_calls
+ assert result["update_calls"] == expected_update_calls
+
+
+def test_local_visible_transaction_reuses_identifiers(native_build):
+ result = _run_transaction_scenario(native_build, "accepted")
+ assert result["insert_client_id"] == result["network_client_id"]
+ assert result["insert_local_id"] == result["update_local_id"] == 41
+ assert result["receipt"] == {
+ "ack_state": "acknowledged",
+ "local_id": 41,
+ "server_id": 99,
+ }
+```
+
+- [ ] **Step 2: Run the new tests and verify RED**
+
+Run:
+
+```bash
+PYTHONPATH=. pytest -q tests/test_native_bridge_host.py -k local_visible_transaction
+```
+
+Expected: failures because the transaction factory and fake-host modes do not exist.
+
+- [ ] **Step 3: Define the gateway interfaces**
+
+Add these internal contracts to `native/include/wechat_bridge.hpp`:
+
+```cpp
+struct LocalMessageIdentity {
+ std::int64_t local_id = 0;
+ std::uint64_t client_id = 0;
+ std::uint32_t create_time = 0;
+};
+
+struct NetworkAcceptance {
+ bool accepted = false;
+ std::int64_t server_id = 0;
+};
+
+using LocalInsertCompletion =
+ std::function)>;
+using NetworkCompletion = std::function;
+using LocalUpdateCompletion = std::function;
+
+class LocalMessageGateway {
+ public:
+ virtual ~LocalMessageGateway() = default;
+ virtual void InsertOutgoing(const SendRequest& request,
+ std::uint64_t client_id,
+ std::uint32_t create_time,
+ LocalInsertCompletion completion) = 0;
+ virtual void MarkAccepted(const LocalMessageIdentity& identity,
+ std::int64_t server_id,
+ LocalUpdateCompletion completion) = 0;
+};
+
+class MarsGateway {
+ public:
+ virtual ~MarsGateway() = default;
+ virtual void Submit(const SendRequest& request,
+ const LocalMessageIdentity& identity,
+ NetworkCompletion completion) = 0;
+};
+
+std::shared_ptr CreateLocalVisibleAdapter(
+ std::shared_ptr local,
+ std::shared_ptr mars);
+```
+
+- [ ] **Step 4: Implement the minimal transaction state machine**
+
+In `native/src/local_visible_adapter.mm`, generate `client_id` and `create_time` once, call `InsertOutgoing`, call `MarsGateway::Submit` only after a positive local ID, and call `MarkAccepted` only after a positive server ID. All invalid or incomplete callbacks produce `AckState::kUnknown`; no branch calls `Submit` twice.
+
+- [ ] **Step 5: Run RED tests to verify GREEN, then run all native tests**
+
+```bash
+PYTHONPATH=. pytest -q tests/test_native_bridge_host.py -k local_visible_transaction
+PYTHONPATH=. pytest -q tests/test_native_bridge_host.py
+```
+
+Expected: both commands exit 0.
+
+- [ ] **Step 6: Commit the transaction layer**
+
+```bash
+git add native/include/wechat_bridge.hpp native/src/local_visible_adapter.mm \
+ native/build.sh native/tests/fake_host.mm tests/test_native_bridge_host.py
+git commit -m "feat: add local-visible send transaction"
+```
+
+### Task 3: Produce a complete read-only WeChat storage profile
+
+**Files:**
+- Create: `tools/wechat_profile_capture.py`
+- Create: `tools/validate_wechat_profile.py`
+- Create: `tests/fixtures/wechat_local_store_capture.json`
+- Create: `tests/test_wechat_profile_validation.py`
+- Create: `docs/research/wechat-4.1.8.28-local-store-profile.md`
+- Create: `native/include/wechat_4_1_8_28_profile.hpp`
+- Delete: `codex_lldb_capture.py`
+
+- [ ] **Step 1: Write failing profile-validation tests**
+
+```python
+def test_profile_rejects_missing_queue_or_identifier_evidence(tmp_path):
+ capture = _valid_capture()
+ capture.pop("storage_queue")
+ assert validate_capture(capture) == "missing_storage_queue"
+
+
+def test_profile_rejects_direct_gcd_ui_closure(tmp_path):
+ capture = _valid_capture()
+ capture["insert_entry_rva"] = 0x3399194
+ assert validate_capture(capture) == "unsafe_await_closure"
+
+
+def test_profile_emits_all_pinned_constants(tmp_path):
+ header = render_header(_valid_capture())
+ for name in (
+ "kLocalInsertRva",
+ "kLocalUpdateRva",
+ "kConversationNotifyRva",
+ "kStorageDispatchRva",
+ "kResponseDecodeRva",
+ ):
+ assert name in header
+```
+
+- [ ] **Step 2: Run the validator tests and verify RED**
+
+```bash
+PYTHONPATH=. pytest -q tests/test_wechat_profile_validation.py
+```
+
+Expected: import failure because `tools/validate_wechat_profile.py` does not exist.
+
+- [ ] **Step 3: Convert the existing capture helper into a bounded tool**
+
+`tools/wechat_profile_capture.py` must:
+
+- derive the loaded `wechat.dylib` base instead of hard-coding a PID or load address;
+- filter every breakpoint by one exact marker present in the outgoing model;
+- capture entry bytes, registers, queue/thread name, return value, and the same model before/after;
+- include the observed candidates `0x26e6264` (local insert) and `0x26ea4fc` (local update), but label them candidates until validation proves their outputs;
+- collect response decoding and conversation notification callers;
+- automatically detach after the marker send and never invoke a send function.
+
+- [ ] **Step 4: Implement strict offline validation**
+
+`validate_capture()` returns a named failure unless all of these are measured in one manual marker send: positive local ID transition, identical client ID through request serialization, storage-queue identity, positive server ID update, conversation notification, current dylib UUID `ABD88EC8-4590-3FDB-AAB5-4E5865B86B2A`, and SHA-256 `b4a740135f3f1e937bca10caf0a95cff986ddf27fa6bb15ccaf64001fc651c93`.
+
+On success, `render_header()` emits integer constants and byte signatures. It must reject RVA `0x3399194` for insert, update, dispatch, or notification.
+
+- [ ] **Step 5: Run the offline tests to verify GREEN**
+
+```bash
+PYTHONPATH=. pytest -q tests/test_wechat_profile_validation.py
+```
+
+Expected: all tests pass.
+
+- [ ] **Step 6: Capture one user-performed manual marker send**
+
+Run LLDB in read-only observation mode, ask the user to manually send one unique marker to the dedicated test group, then detach automatically. The tool itself must not send, write WeChat memory, patch instructions, or keep the process stopped.
+
+- [ ] **Step 7: Validate and generate the pinned header**
+
+```bash
+python3 tools/validate_wechat_profile.py \
+ --capture /tmp/wechat-local-store-capture.json \
+ --image /private/tmp/wechat-arm64-profile.dylib \
+ --fixture tests/fixtures/wechat_local_store_capture.json \
+ --header native/include/wechat_4_1_8_28_profile.hpp
+```
+
+Expected: `profile_valid`; otherwise keep production sending disabled and stop this task without a live bridge test.
+
+- [ ] **Step 8: Record evidence and commit**
+
+Document each generated constant, ABI argument, return value, queue, and signature in `docs/research/wechat-4.1.8.28-local-store-profile.md`.
+
+```bash
+git add tools/wechat_profile_capture.py tools/validate_wechat_profile.py \
+ tests/fixtures/wechat_local_store_capture.json \
+ tests/test_wechat_profile_validation.py \
+ docs/research/wechat-4.1.8.28-local-store-profile.md \
+ native/include/wechat_4_1_8_28_profile.hpp
+git commit -m "docs: pin WeChat local message storage profile"
+```
+
+### Task 4: Implement the pinned local message gateway
+
+**Files:**
+- Create: `native/src/wechat_local_store.mm`
+- Modify: `native/include/wechat_bridge.hpp`
+- Modify: `native/build.sh`
+- Modify: `native/tests/fake_host.mm`
+- Modify: `tests/test_native_bridge_host.py`
+
+- [ ] **Step 1: Write failing native layout and queue tests**
+
+Add fake-host tests that verify:
+
+```python
+def test_local_store_builds_exact_outgoing_text_model(native_build):
+ result = _run_local_store_contract(native_build, "insert")
+ assert result["recipient"] == "59034084590@chatroom"
+ assert result["text"] == "Unicode 👋\n$HOME `literal`"
+ assert result["client_id"] == 202608020001
+ assert result["local_id"] == 41
+
+
+def test_local_store_dispatches_only_on_captured_storage_queue(native_build):
+ result = _run_local_store_contract(native_build, "queue")
+ assert result["socket_thread_calls"] == 0
+ assert result["storage_queue_calls"] == 1
+```
+
+- [ ] **Step 2: Run the new tests and verify RED**
+
+```bash
+PYTHONPATH=. pytest -q tests/test_native_bridge_host.py -k local_store
+```
+
+Expected: failure because `wechat_local_store.mm` is absent.
+
+- [ ] **Step 3: Implement the model, dispatch, insert, and update calls**
+
+Use only generated constants from `wechat_4_1_8_28_profile.hpp`. Validate all entry signatures before constructing the gateway. Retain model and callback storage until completion. `InsertOutgoing` accepts only exact `@chatroom` recipients and returns `nullopt` unless WeChat reports a positive local ID. `MarkAccepted` updates the same local ID with the measured positive server ID and invokes the measured conversation notification on the same internal queue.
+
+Expose this factory in `native/include/wechat_bridge.hpp`:
+
+```cpp
+std::shared_ptr CreatePinnedLocalMessageGateway(
+ std::string* unsupported_reason);
+```
+
+- [ ] **Step 4: Verify GREEN and all native regressions**
+
+```bash
+PYTHONPATH=. pytest -q tests/test_native_bridge_host.py -k local_store
+PYTHONPATH=. pytest -q tests/test_native_bridge_host.py
+```
+
+Expected: both commands exit 0.
+
+- [ ] **Step 5: Commit the local gateway**
+
+```bash
+git add native/src/wechat_local_store.mm native/include/wechat_bridge.hpp native/build.sh \
+ native/tests/fake_host.mm tests/test_native_bridge_host.py
+git commit -m "feat: add pinned local message gateway"
+```
+
+### Task 5: Adapt the proven Mars task to measured identifiers and ACK
+
+**Files:**
+- Modify: `native/include/wechat_bridge.hpp`
+- Modify: `native/src/wechat_adapter.mm`
+- Modify: `native/src/hook_trampolines.S`
+- Modify: `native/tests/fake_host.mm`
+- Modify: `tests/test_native_bridge_host.py`
+
+- [ ] **Step 1: Write failing request and response tests**
+
+```python
+def test_mars_request_reuses_local_message_client_id(native_build):
+ result = _run_mars_contract(native_build, "serialize")
+ assert result["message_client_id"] == 202608020001
+ assert result["network_submit_count"] == 1
+
+
+def test_mars_ack_returns_server_id_once(native_build):
+ result = _run_mars_contract(native_build, "accepted")
+ assert result["completion_count"] == 1
+ assert result["accepted"] is True
+ assert result["server_id"] == 99
+
+
+def test_mars_timeout_never_resubmits(native_build):
+ result = _run_mars_contract(native_build, "timeout")
+ assert result["network_submit_count"] == 1
+ assert result["accepted"] is False
+```
+
+- [ ] **Step 2: Run the new tests and verify RED**
+
+```bash
+PYTHONPATH=. pytest -q tests/test_native_bridge_host.py -k mars
+```
+
+Expected: failures because the original adapter generates its own client ID and discards response data.
+
+- [ ] **Step 3: Convert the original adapter into `MarsGateway`**
+
+Preserve `BuildTextRequest`, `PrepareOpaqueObjects`, `MMStartTask`, and the working Req2Buf hook layout. Replace its task-generated client ID with `LocalMessageIdentity.client_id`. Decode the response through the measured profile boundary and complete once with `NetworkAcceptance{true, positive_server_id}`. Transport errors, parse failures, process exit, or missing IDs complete unknown and never call `MMStartTask` again.
+
+Expose this factory in `native/include/wechat_bridge.hpp`:
+
+```cpp
+std::shared_ptr CreatePinnedMarsGateway(
+ std::string* unsupported_reason);
+```
+
+- [ ] **Step 4: Verify GREEN and all native regressions**
+
+```bash
+PYTHONPATH=. pytest -q tests/test_native_bridge_host.py -k mars
+PYTHONPATH=. pytest -q tests/test_native_bridge_host.py
+```
+
+Expected: both commands exit 0.
+
+- [ ] **Step 5: Commit the Mars gateway**
+
+```bash
+git add native/include/wechat_bridge.hpp native/src/wechat_adapter.mm native/src/hook_trampolines.S \
+ native/tests/fake_host.mm tests/test_native_bridge_host.py
+git commit -m "feat: correlate Mars ACK with local message"
+```
+
+### Task 6: Wire the production factory and fail-closed profile gate
+
+**Files:**
+- Modify: `native/include/wechat_bridge.hpp`
+- Modify: `native/src/bridge_service.mm`
+- Modify: `native/src/wechat_adapter.mm`
+- Modify: `native/src/bridge_entry.mm`
+- Modify: `native/tests/fake_host.mm`
+- Modify: `tests/test_native_bridge_host.py`
+- Modify: `wechat_cli/core/native_bridge_provider.py`
+- Modify: `tests/test_native_bridge_provider.py`
+
+- [ ] **Step 1: Write failing production-factory tests**
+
+```python
+def test_production_factory_requires_complete_local_profile(native_build):
+ result = _run_factory_contract(native_build, "missing-notify-signature")
+ assert result == "unsupported_wechat_local_store_profile"
+
+
+def test_production_factory_composes_local_and_mars_gateways(native_build):
+ result = _run_factory_contract(native_build, "complete-fake-profile")
+ assert result == "local-visible-adapter-ready"
+
+
+def test_local_visible_endpoint_is_distinct_from_base_endpoint(tmp_path):
+ paths = _bridge_paths(tmp_path, pid=8717)
+ assert paths.metadata.name == "bridge-8717-local-visible.json"
+ assert paths.socket.name == "wechat-bridge-8717-local-visible.sock"
+ assert paths.error.name == "bridge-8717-local-visible.error"
+```
+
+- [ ] **Step 2: Run the tests and verify RED**
+
+```bash
+PYTHONPATH=. pytest -q tests/test_native_bridge_host.py -k production_factory
+```
+
+Expected: failures because production still constructs only the Mars adapter.
+
+- [ ] **Step 3: Compose the production transaction**
+
+`CreateProductionWeChatAdapter` must validate the image path, UUID, SHA-256 supplied by preflight, every generated entry signature, and the storage dispatcher before returning. It creates both gateways first, rejects either error, and then returns:
+
+```cpp
+return CreateLocalVisibleAdapter(
+ std::move(local_gateway),
+ std::move(mars_gateway));
+```
+
+Any missing component publishes `unsupported_wechat_local_store_profile` before the bridge socket starts. Do not retain the direct UI closure RVA in executable code.
+
+Add `BridgeConfig.endpoint_suffix = "local-visible"`, accept only the literal
+safe suffix in `BridgeService`, and use it consistently for metadata, socket,
+startup-error, and Python provider paths. This prevents an already-loaded base
+bridge in the same PID from being mistaken for the local-visible bridge and
+permits hot loading without a WeChat restart.
+
+Define the Python path helper with a stable return type:
+
+```python
+@dataclass(frozen=True)
+class BridgePaths:
+ metadata: Path
+ socket: Path
+ error: Path
+
+
+def _bridge_paths(directory: Path, pid: int) -> BridgePaths:
+ suffix = "local-visible"
+ return BridgePaths(
+ metadata=directory / f"bridge-{pid}-{suffix}.json",
+ socket=directory / f"wechat-bridge-{pid}-{suffix}.sock",
+ error=directory / f"bridge-{pid}-{suffix}.error",
+ )
+```
+
+- [ ] **Step 4: Verify GREEN and run all native tests**
+
+```bash
+PYTHONPATH=. pytest -q tests/test_native_bridge_host.py -k production_factory
+PYTHONPATH=. pytest -q tests/test_native_bridge_provider.py
+PYTHONPATH=. pytest -q tests/test_native_bridge_host.py
+```
+
+Expected: both commands exit 0.
+
+- [ ] **Step 5: Commit production composition**
+
+```bash
+git add native/include/wechat_bridge.hpp native/src/bridge_service.mm \
+ native/src/wechat_adapter.mm native/src/bridge_entry.mm \
+ native/tests/fake_host.mm tests/test_native_bridge_host.py \
+ wechat_cli/core/native_bridge_provider.py tests/test_native_bridge_provider.py
+git commit -m "feat: enable pinned local-visible send adapter"
+```
+
+### Task 7: Run Python transaction and confirmation regressions
+
+**Files:**
+- Test: `tests/test_native_send_service.py`
+- Test: `tests/test_send_confirmation.py`
+- Test: `tests/test_send.py`
+
+- [ ] **Step 1: Add a regression for the persistent row contract if absent**
+
+```python
+def test_native_send_accepts_only_the_bridge_local_row():
+ service, store = _service(
+ receipt=_receipt(local_id=41, server_id=99),
+ confirmation=MessageConfirmation(local_id=41, server_id=99),
+ )
+ result = service.send_text(_request())
+ assert result.local_id == 41
+ assert result.server_id == 99
+ assert store.poll_calls[0]["local_id"] == 41
+```
+
+- [ ] **Step 2: Run the focused Python tests**
+
+```bash
+PYTHONPATH=. pytest -q \
+ tests/test_native_send_service.py \
+ tests/test_send_confirmation.py \
+ tests/test_send.py
+```
+
+Expected: all tests pass; if the regression already exists, no Python production file changes are required.
+
+- [ ] **Step 3: Commit only if a test was added**
+
+```bash
+git add tests/test_native_send_service.py
+git commit -m "test: require exact persisted local message"
+```
+
+### Task 8: Package, verify, and perform one explicitly approved live test
+
+**Files:**
+- Modify: `wechat_cli/bin/libwechat_send_bridge.dylib`
+- Modify: `wechat_cli/bin/wechat_send_injector`
+- Modify: `README.md`
+- Modify: `README_CN.md`
+
+- [ ] **Step 1: Run the complete automated suite**
+
+```bash
+PYTHONPATH=. pytest -q
+```
+
+Expected: zero failures.
+
+- [ ] **Step 2: Build and inspect packaged ARM64 artifacts**
+
+```bash
+./native/build.sh wechat_cli/bin
+file wechat_cli/bin/libwechat_send_bridge.dylib \
+ wechat_cli/bin/wechat_send_injector
+codesign --verify --strict wechat_cli/bin/libwechat_send_bridge.dylib
+codesign --verify --strict wechat_cli/bin/wechat_send_injector
+```
+
+Expected: both artifacts are ARM64 and both signature checks exit 0.
+
+- [ ] **Step 3: Verify CLI and fail-closed behavior without sending**
+
+```bash
+PYTHONPATH=. python -m wechat_cli.main send --help
+```
+
+Expected: documented group/text/timeout/format options and no process attachment.
+
+- [ ] **Step 4: Ask for a separate live-test confirmation**
+
+State the exact group and one unique message. Do not reuse any earlier failed text. Submit exactly once only after the user confirms.
+
+- [ ] **Step 5: Verify local persistence after that one submission**
+
+Confirm all four observations without retrying: the CLI receipt contains positive `local_id/server_id`, the desktop conversation displays the text, `wechat-cli history` contains the exact outgoing row, and the row remains after a user-chosen WeChat restart. If any observation is incomplete, report `unknown` and stop.
+
+- [ ] **Step 6: Update documentation and commit packaged output**
+
+Document the pinned build, persistent local-row behavior, no-retry rule, and unsupported-profile failure.
+
+```bash
+git add README.md README_CN.md \
+ wechat_cli/bin/libwechat_send_bridge.dylib \
+ wechat_cli/bin/wechat_send_injector
+git commit -m "feat: ship persistent local-visible group send"
+```
diff --git a/docs/superpowers/plans/2026-08-02-safe-local-visible-send-recovery-phase1.md b/docs/superpowers/plans/2026-08-02-safe-local-visible-send-recovery-phase1.md
new file mode 100644
index 0000000..dd2fd8c
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-02-safe-local-visible-send-recovery-phase1.md
@@ -0,0 +1,1233 @@
+# Safe Local-Visible Send Recovery Phase 1 Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Remove the deterministic WeChat crash path, make production sending fail before injection while evidence is incomplete, and produce a complete measured local-message profile from one explicitly approved read-only observation.
+
+**Architecture:** Phase 1 deliberately ships no enabled sender. It replaces the unsafe high-level adapter with a fail-closed production stub, restores the strict identifier-based receipt contract, and builds an auditable capture/validation pipeline whose output remains `adapter_ready: false`. Phase 2 is written only after the measured profile supplies exact native types, entry signatures, ownership, execution context, and state transitions.
+
+**Tech Stack:** Python 3.10+, pytest, C++17/Objective-C++, ARM64 Mach-O inspection, LLDB Python API in read-only mode, macOS code signing and process inspection.
+
+---
+
+## Evidence correction (2026-08-02)
+
+Static verification after Tasks 1-4 invalidated the assumed complete probe map
+used by Tasks 4-8 below. In particular, the model setters may be direct field
+writes, `0x372FD60` is not proved to be the send-response decoder, and
+`0x202906C` is inside a larger function rather than a notification entry.
+
+The schema-v2 profile examples and the zero-RVA resolution instructions below
+are retained as historical plan context, but they must not be used to sanitize
+or generate artifacts from the first marker capture. The corrected discovery
+capture installs only the four verified boundaries documented in
+`docs/research/wechat-4.1.8.28-local-store-candidates.md`: insert wrapper, insert
+implementation, Mars submit, and local update. Its purpose is to capture the
+insert/update model data and backtraces needed to discover the actual response
+and notification boundaries. No Task 8 profile artifact may be generated until
+a revised schema removes every disproved role and a new failing-test-first plan
+defines the evidence chain.
+
+## Scope and sequencing
+
+This plan covers the safety and evidence boundary only. It does not guess the
+private local-message gateway ABI and does not perform `wechat-cli send`.
+
+The phase is complete when:
+
+- the crashing `OwlHighLevelSendAdapter` cannot be built or selected;
+- `wechat-cli send` refuses before invoking the injector while
+ `adapter_ready` is false or absent;
+- Python accepts only an acknowledged receipt with matching positive IDs;
+- one sanitized measured profile passes strict validation;
+- the generated profile manifest still says `adapter_ready: false`;
+- all tests and clean temporary builds pass without injecting WeChat.
+
+Only then can a Phase 2 plan name exact typedefs and implement
+`PinnedLocalMessageGateway` and `PinnedMarsGateway` without placeholders.
+
+## File map
+
+- `native/src/production_adapter.mm`: New fail-closed production factory used
+ until the measured gateways are implemented in Phase 2.
+- `native/src/wechat_local_visible.mm`: Delete the deterministic crash path.
+- `wechat_cli/bin/libwechat_local_visible_bridge*.dylib`: Delete the untracked
+ experimental local-visible artifacts so `wechat_cli = ["bin/*"]` cannot
+ package v1/v3/v4/v5/v6 accidentally.
+- `native/build.sh`: Stop compiling the unsafe source; compile the transaction,
+ production stub, bridge service, and legacy Mars code into a safe-v1 artifact.
+- `native/include/wechat_bridge.hpp`: Remove unsafe helper exports and the
+ transient submitted ACK state; retain the committed gateway contracts.
+- `native/src/bridge_entry.mm`: Use the stable `local-visible-safe-v1` endpoint.
+- `native/src/bridge_service.mm`: Serialize only acknowledged/unknown receipts.
+- `native/tests/fake_host.mm`: Remove forged high-level model/Owl probes and add
+ production-stub contract coverage.
+- `tests/test_native_bridge_host.py`: Crash regression, source/artifact ban, and
+ production-stub tests.
+- `wechat_cli/core/native_bridge_provider.py`: Validate a packaged profile
+ manifest before running the injector.
+- `tests/test_native_bridge_provider.py`: Prove missing/incomplete profiles stop
+ before subprocess execution.
+- `wechat_cli/core/send_bridge.py`: Restore acknowledged/unknown protocol only.
+- `wechat_cli/core/native_sending.py`: Require receipt IDs and exact-ID database
+ confirmation.
+- `wechat_cli/core/send_confirmation.py`: Remove exact-text fallback lookup.
+- `tests/test_send_bridge.py`: Reject submitted receipts.
+- `tests/test_native_send_service.py`: Reject receipts without positive IDs.
+- `tests/test_send_confirmation.py`: Retain exact-local-ID confirmation tests and
+ remove fallback-only cases.
+- `tools/wechat_profile_capture.py`: Bounded phased read-only LLDB capture.
+- `tools/validate_wechat_profile.py`: Strict raw-evidence parser, sanitizer,
+ profile validator, header and manifest generator.
+- `tests/test_wechat_profile_capture.py`: Pure capture-plan and no-call tests.
+- `tests/test_wechat_profile_validation.py`: Incomplete, inconsistent, unsafe,
+ and measured-profile tests.
+- `tests/fixtures/wechat_local_store_capture.json`: Sanitized measured output,
+ created only after the approved marker observation.
+- `native/include/wechat_4_1_8_28_profile.hpp`: Generated measured constants,
+ created only after validation.
+- `wechat_cli/bin/libwechat_local_visible_bridge_safe_v1.dylib`: Generated
+ fail-closed Phase 1 bridge whose digest is pinned by the manifest.
+- `wechat_cli/bin/wechat_local_visible_profile.json`: Generated profile gate with
+ `adapter_ready: false` in Phase 1.
+- `docs/research/wechat-4.1.8.28-local-store-profile.md`: Evidence table and raw
+ capture digest, with no message text or memory dumps.
+
+### Task 1: Quarantine the deterministic crash path
+
+**Files:**
+- Create: `native/src/production_adapter.mm`
+- Delete: `native/src/wechat_local_visible.mm`
+- Delete: `wechat_cli/bin/libwechat_local_visible_bridge.dylib`
+- Delete: `wechat_cli/bin/libwechat_local_visible_bridge_v3.dylib`
+- Delete: `wechat_cli/bin/libwechat_local_visible_bridge_v4.dylib`
+- Delete: `wechat_cli/bin/libwechat_local_visible_bridge_v5.dylib`
+- Delete: `wechat_cli/bin/libwechat_local_visible_bridge_v6.dylib`
+- Modify: `native/build.sh`
+- Modify: `native/include/wechat_bridge.hpp`
+- Modify: `native/src/bridge_entry.mm`
+- Modify: `native/tests/fake_host.mm`
+- Modify: `tests/test_native_bridge_host.py`
+
+- [ ] **Step 1: Write the crashing-symbol regression test**
+
+Add this test to `tests/test_native_bridge_host.py`:
+
+```python
+def test_production_build_excludes_crashing_high_level_send(native_build):
+ _, dylib = native_build
+ symbols = subprocess.run(
+ ["nm", "-nm", str(dylib)],
+ check=True,
+ text=True,
+ capture_output=True,
+ ).stdout
+ build_script = (ROOT / "native" / "build.sh").read_text(encoding="utf-8")
+ source = "\n".join(
+ path.read_text(encoding="utf-8")
+ for path in (ROOT / "native" / "src").glob("*.mm")
+ )
+
+ assert "OwlHighLevelSendAdapter" not in symbols
+ assert "OwlHighLevelSendAdapter" not in source
+ assert "VerifyOwlMainScopeRunner" not in symbols
+ assert "wechat_local_visible.mm" not in build_script
+ assert "0x25cd228" not in source.lower()
+ unsafe_artifacts = (
+ "libwechat_local_visible_bridge.dylib",
+ "libwechat_local_visible_bridge_v3.dylib",
+ "libwechat_local_visible_bridge_v4.dylib",
+ "libwechat_local_visible_bridge_v5.dylib",
+ "libwechat_local_visible_bridge_v6.dylib",
+ )
+ assert all(not (ROOT / "wechat_cli" / "bin" / name).exists()
+ for name in unsafe_artifacts)
+```
+
+Add a production-stub expectation:
+
+```python
+def test_production_factory_fails_closed_until_profile_is_composed(native_build):
+ host, _ = native_build
+ completed = subprocess.run(
+ [str(host), "--verify-production-profile-incomplete"],
+ cwd=ROOT,
+ stdin=subprocess.DEVNULL,
+ text=True,
+ capture_output=True,
+ )
+ assert completed.returncode == 0, completed.stderr
+```
+
+- [ ] **Step 2: Run the tests and verify RED**
+
+Run:
+
+```bash
+uv run --project . pytest -q tests/test_native_bridge_host.py \
+ -k 'excludes_crashing or profile_incomplete'
+```
+
+Expected: the first test fails because the v6 symbol, source, and build entry
+exist; the second fails because the fake-host mode does not exist.
+
+- [ ] **Step 3: Add the fail-closed production factory**
+
+Create `native/src/production_adapter.mm` with exactly:
+
+```cpp
+#include "wechat_bridge.hpp"
+
+#include
+#include
+
+namespace wechat_bridge {
+
+std::shared_ptr CreateProductionWeChatAdapter(
+ std::string* unsupported_reason) {
+ if (unsupported_reason != nullptr) {
+ *unsupported_reason = "unsupported_wechat_local_store_profile";
+ }
+ return nullptr;
+}
+
+} // namespace wechat_bridge
+```
+
+In `native/build.sh`, remove `native/src/wechat_local_visible.mm`, add
+`native/src/production_adapter.mm`, keep `native/src/local_visible_adapter.mm`,
+and name the experimental output
+`libwechat_local_visible_bridge_safe_v1.dylib` with the same install name.
+
+Delete `native/src/wechat_local_visible.mm`. In
+`native/include/wechat_bridge.hpp`, delete only these unsafe declarations:
+
+```cpp
+bool VerifyLocalVisibleTextLayout();
+bool VerifyRelocatedMessageVtable(std::uintptr_t image_base,
+ const void* bytes,
+ std::size_t size);
+bool VerifyOwlMainScopeRunner();
+```
+
+Delete the five exact experimental dylib paths listed in this task. Do not
+delete the committed legacy Mars artifact `libwechat_send_bridge.dylib`.
+
+Set the exact bridge tag in `native/src/bridge_entry.mm`:
+
+```cpp
+constexpr char kBridgeInstanceTag[] = "local-visible-safe-v1";
+```
+
+Remove the three matching fake-host modes and their JSON output. Add this mode:
+
+```cpp
+if (argc == 2 &&
+ std::strcmp(argv[1], "--verify-production-profile-incomplete") == 0) {
+ std::string reason;
+ const auto adapter =
+ wechat_bridge::CreateProductionWeChatAdapter(&reason);
+ return adapter == nullptr &&
+ reason == "unsupported_wechat_local_store_profile"
+ ? 0
+ : 14;
+}
+```
+
+- [ ] **Step 4: Rebuild in a temporary directory and verify GREEN**
+
+Run:
+
+```bash
+uv run --project . pytest -q tests/test_native_bridge_host.py
+```
+
+Expected: all native host tests pass; no command attaches to WeChat.
+
+- [ ] **Step 5: Verify the unsafe source and symbol are absent**
+
+Run:
+
+```bash
+rg -n 'OwlHighLevelSendAdapter|0x25cd228|wechat_local_visible\.mm' \
+ native/src native/include native/build.sh wechat_cli/core
+```
+
+Expected: no production references. Historical design documents and crash
+forensics are outside the searched paths.
+
+- [ ] **Step 6: Commit the quarantine**
+
+```bash
+git add native/build.sh native/include/wechat_bridge.hpp \
+ native/src/bridge_entry.mm native/src/production_adapter.mm \
+ native/tests/fake_host.mm tests/test_native_bridge_host.py
+git commit -m "fix: quarantine crashing local-visible adapter"
+```
+
+### Task 2: Stop before injection without a ready profile
+
+**Files:**
+- Modify: `wechat_cli/core/native_bridge_provider.py`
+- Modify: `tests/test_native_bridge_provider.py`
+
+- [ ] **Step 1: Write profile-manifest gate tests**
+
+Add to `tests/test_native_bridge_provider.py`:
+
+```python
+import json
+from types import SimpleNamespace
+
+import pytest
+
+from wechat_cli.core.sending import SendUnavailableError
+
+
+def test_missing_profile_manifest_stops_before_injector(monkeypatch, tmp_path):
+ called = False
+
+ def forbidden_run(*args, **kwargs):
+ nonlocal called
+ called = True
+ raise AssertionError("injector must not run")
+
+ monkeypatch.setattr(native_bridge_provider, "_safe_bridge_directory", lambda: tmp_path)
+ monkeypatch.setattr(native_bridge_provider, "_package_binary", lambda name: tmp_path / name)
+ monkeypatch.setattr(native_bridge_provider.subprocess, "run", forbidden_run)
+
+ with pytest.raises(SendUnavailableError, match="profile.*未完成"):
+ native_bridge_provider.prepare_native_bridge(
+ SimpleNamespace(pid=123), "12345678-1234-1234-1234-123456789abc"
+ )
+
+ assert called is False
+
+
+@pytest.mark.parametrize(
+ "payload",
+ [
+ {},
+ [],
+ {"version": "1", "profile_complete": True, "adapter_ready": True},
+ {"version": 1, "profile_complete": True, "adapter_ready": False},
+ {"version": 1, "profile_complete": False, "adapter_ready": True},
+ {"version": 1, "profile_complete": True, "adapter_ready": True,
+ "dylib": "../unsafe.dylib", "dylib_sha256": "0" * 64},
+ ],
+)
+def test_incomplete_or_unsafe_manifest_stops_before_injector(
+ monkeypatch, tmp_path, payload
+):
+ manifest = tmp_path / "wechat_local_visible_profile.json"
+ manifest.write_text(json.dumps(payload), encoding="utf-8")
+ monkeypatch.setattr(native_bridge_provider, "_package_binary", lambda name: tmp_path / name)
+ monkeypatch.setattr(
+ native_bridge_provider.subprocess,
+ "run",
+ lambda *args, **kwargs: pytest.fail("injector must not run"),
+ )
+
+ with pytest.raises(SendUnavailableError, match="profile.*未完成"):
+ native_bridge_provider.prepare_native_bridge(
+ SimpleNamespace(pid=123), "12345678-1234-1234-1234-123456789abc"
+ )
+```
+
+- [ ] **Step 2: Run the tests and verify RED**
+
+Run:
+
+```bash
+uv run --project . pytest -q tests/test_native_bridge_provider.py \
+ -k 'profile_manifest or unsafe_manifest'
+```
+
+Expected: failures because the provider reads no manifest.
+
+- [ ] **Step 3: Implement strict manifest loading before `_safe_bridge_directory`**
+
+Add these constants and helpers to `native_bridge_provider.py`:
+
+```python
+import hashlib
+import json
+
+BRIDGE_INSTANCE_TAG = "local-visible-safe-v1"
+BRIDGE_DYLIB_NAME = "libwechat_local_visible_bridge_safe_v1.dylib"
+PROFILE_MANIFEST_NAME = "wechat_local_visible_profile.json"
+_SHA256 = re.compile(r"[0-9a-f]{64}")
+_UUID = re.compile(
+ r"[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}"
+)
+
+
+def _ready_profile():
+ manifest_path = _package_binary(PROFILE_MANIFEST_NAME)
+ try:
+ info = manifest_path.lstat()
+ payload = json.loads(manifest_path.read_text(encoding="utf-8"))
+ except (OSError, UnicodeError, json.JSONDecodeError) as error:
+ raise SendUnavailableError("本地可见发送 profile 未完成,未发送任何消息") from error
+ expected = {
+ "version", "profile_complete", "adapter_ready", "capture_sha256",
+ "image_uuid", "image_sha256", "dylib", "dylib_sha256",
+ }
+ if (
+ not stat.S_ISREG(info.st_mode)
+ or info.st_uid not in (0, os.geteuid())
+ or info.st_mode & 0o022
+ or not isinstance(payload, dict)
+ or set(payload) != expected
+ or payload["version"] != 1
+ or payload["profile_complete"] is not True
+ or payload["adapter_ready"] is not True
+ or payload["dylib"] != BRIDGE_DYLIB_NAME
+ or not isinstance(payload["capture_sha256"], str)
+ or _SHA256.fullmatch(payload["capture_sha256"]) is None
+ or not isinstance(payload["image_uuid"], str)
+ or _UUID.fullmatch(payload["image_uuid"]) is None
+ or not isinstance(payload["image_sha256"], str)
+ or _SHA256.fullmatch(payload["image_sha256"]) is None
+ or not isinstance(payload["dylib_sha256"], str)
+ or _SHA256.fullmatch(payload["dylib_sha256"]) is None
+ ):
+ raise SendUnavailableError("本地可见发送 profile 未完成,未发送任何消息")
+ dylib = _package_binary(BRIDGE_DYLIB_NAME)
+ try:
+ dylib_info = dylib.lstat()
+ dylib_sha256 = hashlib.sha256(dylib.read_bytes()).hexdigest()
+ except OSError as error:
+ raise SendUnavailableError(
+ "本地可见发送 profile 与 bridge 不匹配,未发送任何消息"
+ ) from error
+ if (
+ not stat.S_ISREG(dylib_info.st_mode)
+ or dylib_info.st_uid not in (0, os.geteuid())
+ or dylib_info.st_mode & 0o022
+ or dylib_sha256 != payload["dylib_sha256"]
+ ):
+ raise SendUnavailableError("本地可见发送 profile 与 bridge 不匹配,未发送任何消息")
+ return payload
+```
+
+Call `_ready_profile()` as the first statement of `prepare_native_bridge`,
+before creating the runtime directory or resolving the injector.
+
+The manifest is intentionally absent until Task 8, and Task 8 writes
+`adapter_ready: false`, so all Phase 1 production calls stop here.
+
+- [ ] **Step 4: Run provider tests and verify GREEN**
+
+Run:
+
+```bash
+uv run --project . pytest -q tests/test_native_bridge_provider.py
+```
+
+Expected: all provider tests pass and mocked injector calls remain zero.
+
+- [ ] **Step 5: Commit the pre-injection gate**
+
+```bash
+git add wechat_cli/core/native_bridge_provider.py \
+ tests/test_native_bridge_provider.py
+git commit -m "fix: gate local-visible injection on measured profile"
+```
+
+### Task 3: Restore strict identifier-based confirmation
+
+**Files:**
+- Modify: `native/include/wechat_bridge.hpp`
+- Modify: `native/src/bridge_service.mm`
+- Modify: `wechat_cli/core/send_bridge.py`
+- Modify: `wechat_cli/core/native_sending.py`
+- Modify: `wechat_cli/core/send_confirmation.py`
+- Modify: `tests/test_send_bridge.py`
+- Modify: `tests/test_native_send_service.py`
+- Modify: `tests/test_send_confirmation.py`
+
+- [ ] **Step 1: Write strict receipt regressions**
+
+In `tests/test_send_bridge.py`, replace the submitted-success case with:
+
+```python
+def test_client_rejects_submitted_receipt_without_ids_as_unknown(tmp_path):
+ fake_socket = FakeSocket(
+ _response(
+ _success_payload(
+ ack_state="submitted",
+ local_id=None,
+ server_id=None,
+ )
+ )
+ )
+ client = _client(tmp_path, fake_socket)
+
+ with pytest.raises(SendUnknownError):
+ client.send_text(_request())
+```
+
+In `tests/test_native_send_service.py`, add:
+
+```python
+@pytest.mark.parametrize("local_id,server_id", [(None, None), (41, None), (None, 99)])
+def test_native_send_never_accepts_receipt_without_both_ids(local_id, server_id):
+ bridge = FakeBridge(
+ receipt=_receipt(
+ ack_state=BridgeAckState.ACKNOWLEDGED,
+ local_id=local_id,
+ server_id=server_id,
+ )
+ )
+ service, _, store, _ = _service(bridge=bridge)
+
+ result = service.send_text(_request())
+
+ assert result.status is SendStatus.UNKNOWN
+ assert store.poll_calls == []
+```
+
+In `tests/test_send_confirmation.py`, add:
+
+```python
+def test_confirmation_rejects_matching_id_with_different_text(tmp_path):
+ db_path = tmp_path / "message.db"
+ _create_message_db(
+ db_path,
+ rows=[(21, 201, 1, 7, 2, "另一条消息")],
+ names=[(7, "wxid_me")],
+ )
+ store = MessageConfirmationStore(_locator_for(db_path))
+ baseline = MessageBaseline(
+ username=USERNAME,
+ max_local_ids={_identity(db_path): 20},
+ )
+
+ confirmation = store.find_confirmation(
+ username=USERNAME,
+ self_username="wxid_me",
+ local_id=21,
+ text="预期消息",
+ baseline=baseline,
+ )
+
+ assert confirmation is None
+```
+
+- [ ] **Step 2: Run tests and verify RED**
+
+Run:
+
+```bash
+uv run --project . pytest -q tests/test_send_bridge.py \
+ tests/test_native_send_service.py
+```
+
+Expected: submitted is currently accepted and the service polls by text for a
+receipt without IDs, so the new expectations fail.
+
+- [ ] **Step 3: Remove the transient submitted protocol and text fallback**
+
+Make these exact contract changes:
+
+```cpp
+enum class AckState {
+ kAcknowledged,
+ kUnknown,
+};
+```
+
+In `native/src/bridge_service.mm`, encode only `acknowledged` or `unknown`.
+
+In `send_bridge.py`, remove `BridgeAckState.SUBMITTED`; require positive
+`local_id` for acknowledged receipts and allow optional IDs only for unknown.
+
+In `NativeSendService.send_text`, accept only
+`BridgeAckState.ACKNOWLEDGED`, require positive `receipt.local_id` and
+`receipt.server_id`, and call only:
+
+```python
+confirmation = self._confirmation_store.poll_confirmation(
+ username=request.username,
+ self_username=self_username,
+ local_id=receipt.local_id,
+ text=request.text,
+ baseline=baseline,
+ timeout=remaining,
+)
+```
+
+Delete `find_confirmation_by_text` and `poll_confirmation_by_text` from
+`send_confirmation.py`, their fake-store method, and their fallback-only tests.
+Add a required `text` keyword to `find_confirmation` and `poll_confirmation`.
+Select `message_content` in the same local-ID query and accept the row only when
+`type(message_content) is str and message_content == text`. Update every direct
+confirmation test call to pass the row's exact text; tests exercising invalid
+rows or invalid baselines pass any non-empty string because they must fail
+before content can establish success. In the native-service success test,
+assert `store.poll_calls[0]["text"] == "你好 👋\n第二行"`.
+
+- [ ] **Step 4: Run the focused confirmation suite and verify GREEN**
+
+Run:
+
+```bash
+uv run --project . pytest -q tests/test_send_bridge.py \
+ tests/test_native_send_service.py tests/test_send_confirmation.py tests/test_send.py
+```
+
+Expected: all focused tests pass.
+
+- [ ] **Step 5: Commit the strict contract**
+
+```bash
+git add native/include/wechat_bridge.hpp native/src/bridge_service.mm \
+ wechat_cli/core/send_bridge.py wechat_cli/core/native_sending.py \
+ wechat_cli/core/send_confirmation.py tests/test_send_bridge.py \
+ tests/test_native_send_service.py tests/test_send_confirmation.py
+git commit -m "fix: require exact local IDs for send success"
+```
+
+### Task 4: Define the measured profile schema and generator
+
+**Files:**
+- Modify: `tools/validate_wechat_profile.py`
+- Modify: `tests/test_wechat_profile_validation.py`
+
+- [ ] **Step 1: Replace the fabricated flat fixture with a measured schema**
+
+Use this exact top-level schema in tests:
+
+```python
+EXPECTED_ARM64_SLICE_SHA256 = (
+ "0e8c932461b883a4e4dc90313a14a30ea69a190fedf1083d014259bec56da2e0"
+)
+
+
+def _measured_profile(*, adapter_ready=False):
+ return {
+ "schema_version": 2,
+ "evidence_kind": "read_only_lldb",
+ "adapter_ready": adapter_ready,
+ "capture_sha256": "1" * 64,
+ "marker_sha256": "2" * 64,
+ "image": {
+ "path": "/Applications/WeChat.app/Contents/Frameworks/wechat.dylib",
+ "uuid": EXPECTED_UUID,
+ "sha256": EXPECTED_SHA256,
+ "arm64_slice_sha256": EXPECTED_ARM64_SLICE_SHA256,
+ },
+ "runtime_ready": {
+ "entry_rva": 0x3EF9964,
+ "signature": "e98202f029b143f9490000b420011fd6",
+ "ready_value": 1,
+ },
+ "execution_context": {
+ "thread_name": "coroutine",
+ "queue_label": "owl.main",
+ "dispatch_rva": 0x57B30,
+ "signature": "f85fbca9f65701a9f44f02a9fd7b03a9",
+ },
+ "model": {
+ "factory_rva": 0x1010,
+ "factory_signature": "3" * 32,
+ "recipient_setter_rva": 0x1020,
+ "recipient_setter_signature": "4" * 32,
+ "text_setter_rva": 0x1030,
+ "text_setter_signature": "5" * 32,
+ "client_id_setter_rva": 0x1040,
+ "client_id_setter_signature": "6" * 32,
+ "create_time_setter_rva": 0x1050,
+ "create_time_setter_signature": "7" * 32,
+ "ownership": "shared_ptr",
+ },
+ "insert": {
+ "entry_rva": 0x26E6264,
+ "signature": "e93c0390294d42f9490000b420011fd6",
+ "local_id_before": 0,
+ "local_id_after": 41,
+ "client_id": 202608020001,
+ },
+ "mars": {"client_id": 202608020001, "submit_count": 1},
+ "response": {
+ "decode_rva": 0x372FD60,
+ "signature": "8" * 32,
+ "server_id": 99,
+ },
+ "update": {
+ "entry_rva": 0x26EA4FC,
+ "signature": "c93c0390296142f9490000b420011fd6",
+ "local_id": 41,
+ "server_id_before": 0,
+ "server_id_after": 99,
+ },
+ "notification": {
+ "entry_rva": 0x202906C,
+ "signature": "9" * 32,
+ "local_id": 41,
+ },
+ }
+```
+
+Add tests that remove one nested field at a time, disagree on IDs, use the
+unsafe high-level RVAs `0x25CD228`/`0x3399194`, use `adapter_ready: true` in
+Phase 1, or supply a wrong raw capture digest. Each must return a stable named
+failure. Add a positive test that expects `profile_valid_not_ready`.
+
+- [ ] **Step 2: Run validator tests and verify RED**
+
+Run:
+
+```bash
+uv run --project . pytest -q tests/test_wechat_profile_validation.py
+```
+
+Expected: failures because the current validator accepts a small flat,
+hand-written dictionary and can render an activation header from it.
+
+- [ ] **Step 3: Implement strict nested validation**
+
+Implement these public functions in `tools/validate_wechat_profile.py`:
+
+```python
+def validate_profile(profile, *, raw_capture=None):
+ """Return profile_valid_not_ready or one stable failure reason."""
+
+
+def sanitize_capture(raw_capture, *, image_path, arm64_slice_path, marker):
+ """Parse exact JSONL bytes and return schema-v2 data without raw secrets."""
+
+
+def render_header(profile):
+ """Render measured constants; reject adapter_ready=True during Phase 1."""
+
+
+def render_manifest(profile, *, dylib_name, dylib_sha256):
+ return {
+ "version": 1,
+ "profile_complete": True,
+ "adapter_ready": False,
+ "capture_sha256": profile["capture_sha256"],
+ "image_uuid": profile["image"]["uuid"],
+ "image_sha256": profile["image"]["sha256"],
+ "dylib": dylib_name,
+ "dylib_sha256": dylib_sha256,
+ }
+```
+
+`sanitize_capture` must hash the exact raw JSONL bytes and marker UTF-8 bytes,
+verify the universal image SHA-256
+`b4a740135f3f1e937bca10caf0a95cff986ddf27fa6bb15ccaf64001fc651c93`,
+verify the extracted ARM64 slice SHA-256
+`0e8c932461b883a4e4dc90313a14a30ea69a190fedf1083d014259bec56da2e0`,
+then omit `regions`, registers, backtraces, raw model bytes, marker text, and
+auth material. `validate_profile` must require exact key sets at every level,
+exact ID agreement, one Mars submission, the supported image and slice, 16-byte
+lowercase signatures, positive bounded RVAs, and absence of unsafe RVAs.
+
+- [ ] **Step 4: Run validator tests and verify GREEN**
+
+Run:
+
+```bash
+uv run --project . pytest -q tests/test_wechat_profile_validation.py
+```
+
+Expected: all schema, sanitization, digest, and rendering tests pass.
+
+- [ ] **Step 5: Commit the evidence schema**
+
+```bash
+git add tools/validate_wechat_profile.py tests/test_wechat_profile_validation.py
+git commit -m "test: require measured local-store profile evidence"
+```
+
+### Task 5: Make the LLDB capture bounded, phased, and read-only
+
+**Files:**
+- Modify: `tools/wechat_profile_capture.py`
+- Create: `tests/test_wechat_profile_capture.py`
+
+- [ ] **Step 1: Write pure capture-plan tests**
+
+Create `tests/test_wechat_profile_capture.py` with:
+
+```python
+import inspect
+
+from tools import wechat_profile_capture as capture
+
+
+def test_probe_plan_never_uses_more_than_four_hardware_breakpoints():
+ phases = capture.build_probe_phases()
+ assert phases
+ assert all(1 <= len(phase.probes) <= 4 for phase in phases)
+ assert {probe.label for phase in phases for probe in phase.probes} >= {
+ "runtime_ready", "model_factory", "recipient_setter", "text_setter",
+ "insert", "mars_submit", "response_decode", "update", "notification",
+ }
+
+
+def test_capture_source_cannot_call_or_evaluate_wechat_functions():
+ source = inspect.getsource(capture)
+ for forbidden in (
+ "EvaluateExpression", "SBExpressionOptions", "CallFunction",
+ "process.Call", "thread.Call", "write_memory", "WriteMemory",
+ ):
+ assert forbidden not in source
+
+
+def test_lldb_arguments_auto_detach_and_never_send(tmp_path):
+ arguments = capture.build_lldb_arguments(
+ pid=123,
+ marker="WCPROFILE-20260802-01",
+ output=tmp_path / "events.jsonl",
+ )
+ lldb_commands = [
+ arguments[index + 1]
+ for index, value in enumerate(arguments[:-1])
+ if value == "-o"
+ ]
+ assert "process continue" in lldb_commands
+ assert any(command.startswith("wechat-profile-start ")
+ for command in lldb_commands)
+ assert all("process call" not in command.lower()
+ for command in lldb_commands)
+ assert all("expression" not in command.lower()
+ for command in lldb_commands)
+ assert any("detach" in command.lower() for command in lldb_commands)
+```
+
+- [ ] **Step 2: Run capture tests and verify RED**
+
+Run:
+
+```bash
+uv run --project . pytest -q tests/test_wechat_profile_capture.py
+```
+
+Expected: import or attribute failures because phased probe objects and the
+argument builder do not exist.
+
+- [ ] **Step 3: Implement the pure plan and command builder**
+
+Add:
+
+```python
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True)
+class Probe:
+ label: str
+ rva: int
+ capture_return: bool = False
+
+
+@dataclass(frozen=True)
+class ProbePhase:
+ name: str
+ probes: tuple[Probe, ...]
+
+
+def build_probe_phases():
+ return (
+ ProbePhase("model", (
+ Probe("runtime_ready", 0x3EF9964, True),
+ Probe("model_factory", 0, True),
+ Probe("recipient_setter", 0, False),
+ Probe("text_setter", 0, False),
+ )),
+ ProbePhase("insert", (
+ Probe("client_id_setter", 0, False),
+ Probe("create_time_setter", 0, False),
+ Probe("insert", 0x26E6264, True),
+ Probe("mars_submit", 0x498D2E0, False),
+ )),
+ ProbePhase("accept", (
+ Probe("response_decode", 0, True),
+ Probe("update", 0x26EA4FC, True),
+ Probe("notification", 0, False),
+ )),
+ )
+```
+
+Zero RVAs are explicit unresolved candidates. `start_capture` must refuse a
+phase containing a zero RVA and print the missing labels, so a marker is never
+requested until static analysis has supplied every candidate.
+
+Implement `build_lldb_arguments` to import this script, start the capture,
+continue, and execute `process detach` after the script's bounded completion
+command. It must validate PID, marker length, and an absolute output path.
+
+- [ ] **Step 4: Implement return probes and bounded automatic detach**
+
+For each `capture_return=True` entry hit, read LR, create a one-shot breakpoint
+restricted to the same thread, disable that entry breakpoint before installing
+the return breakpoint, and capture `x0` plus the same bounded memory regions on
+return. This keeps the installed hardware-breakpoint count at four or fewer.
+The first marker-reachable model/setter event arms the bounded lifecycle; later
+insert/Mars/response/update/notification events may lack marker bytes and are
+retained only inside that same lifecycle window for the same observed thread,
+model pointer, client ID, or local ID. The sanitizer must reject the capture if
+those identities do not form one unbroken chain. Stop after 30 seconds by using
+LLDB's asynchronous process interrupt, remove all breakpoints, write one
+`capture_terminal` event with `complete` or a sorted list of missing labels, and
+detach. Never invoke a target expression.
+
+Every raw event must contain only:
+
+```python
+{
+ "label": str,
+ "edge": "entry" | "return",
+ "time": float,
+ "rva": str,
+ "signature": str,
+ "marker_reachable": bool,
+ "thread_id": int,
+ "thread_name": str | None,
+ "queue_name": str | None,
+ "registers": dict[str, str],
+ "regions": dict[str, str],
+ "backtrace": list[dict[str, object]],
+}
+```
+
+The final record has this separate exact schema:
+
+```python
+{
+ "label": "capture_terminal",
+ "status": "complete" | "incomplete" | "error",
+ "missing_labels": list[str],
+ "pid": int,
+ "duration_ms": int,
+}
+```
+
+- [ ] **Step 5: Run capture tests and verify GREEN**
+
+Run:
+
+```bash
+uv run --project . pytest -q tests/test_wechat_profile_capture.py \
+ tests/test_wechat_profile_validation.py
+```
+
+Expected: all pure tests pass; this command does not import LLDB or attach.
+
+- [ ] **Step 6: Commit the bounded capture tool**
+
+```bash
+git add tools/wechat_profile_capture.py tests/test_wechat_profile_capture.py
+git commit -m "feat: add bounded read-only WeChat profile capture"
+```
+
+### Task 6: Resolve all zero-RVA candidates offline
+
+**Files:**
+- Modify: `tools/wechat_profile_capture.py`
+- Create: `docs/research/wechat-4.1.8.28-local-store-candidates.md`
+- Modify: `tests/test_wechat_profile_capture.py`
+
+- [ ] **Step 1: Add a failing no-zero-RVA test**
+
+```python
+def test_every_runtime_probe_has_a_resolved_positive_rva():
+ probes = [probe for phase in capture.build_probe_phases() for probe in phase.probes]
+ assert all(0 < probe.rva < 0x10000000 for probe in probes)
+```
+
+- [ ] **Step 2: Run it and verify RED**
+
+Run:
+
+```bash
+uv run --project . pytest -q tests/test_wechat_profile_capture.py \
+ -k resolved_positive
+```
+
+Expected: failure listing model factory, setters, response decode, and
+notification candidates as zero.
+
+- [ ] **Step 3: Resolve candidates using only the pinned image**
+
+Use `/private/tmp/wechat-arm64-profile.dylib`. Before disassembly, run:
+
+```bash
+test "$(shasum -a 256 /private/tmp/wechat-arm64-profile.dylib | awk '{print $1}')" = \
+ "0e8c932461b883a4e4dc90313a14a30ea69a190fedf1083d014259bec56da2e0"
+dwarfdump --uuid /private/tmp/wechat-arm64-profile.dylib | \
+ rg 'ABD88EC8-4590-3FDB-AAB5-4E5865B86B2A \(arm64\)'
+```
+
+Expected: both commands exit 0. Then disassemble the known normal-send call
+chains around:
+
+```text
+UI trigger: 0x3399194
+local insert: 0x26e6264
+local update: 0x26ea4fc
+Mars submit: 0x498d2e0
+insert callers: 0x253b85c, 0x25634e4, 0x2533948, 0x260be0c,
+ 0x2609e88, 0x25cf0e4, 0x25cc098, 0x25cbf0c,
+ 0x25cd3b8
+update callers: 0x253b01c, 0x2565ad8, 0x2536fbc, 0x265274c,
+ 0x2651b1c, 0x3725be4
+```
+
+For each new RVA, record the 16 entry bytes, every call-site argument register,
+the caller chain that ties it to the marker model, and why it is a factory,
+setter, response decoder, or notification boundary. Do not infer a role from a
+string alone.
+
+Write the evidence table to
+`docs/research/wechat-4.1.8.28-local-store-candidates.md` and replace every zero
+RVA in `build_probe_phases()` with the measured candidate.
+
+- [ ] **Step 4: Run the no-zero and full capture tests to verify GREEN**
+
+```bash
+uv run --project . pytest -q tests/test_wechat_profile_capture.py
+```
+
+Expected: all probe RVAs are positive and all capture tests pass.
+
+- [ ] **Step 5: Commit the offline candidate map**
+
+```bash
+git add tools/wechat_profile_capture.py tests/test_wechat_profile_capture.py \
+ docs/research/wechat-4.1.8.28-local-store-candidates.md
+git commit -m "docs: map pinned local-store profile candidates"
+```
+
+### Task 7: Capture one explicitly approved normal marker send
+
+**Files:**
+- Runtime output only: `/private/tmp/wechat-local-store-events-v2.jsonl`
+
+- [ ] **Step 1: Run all pre-capture safety checks**
+
+Run:
+
+```bash
+uv run --project . pytest -q tests/test_wechat_profile_capture.py \
+ tests/test_wechat_profile_validation.py tests/test_native_bridge_host.py
+```
+
+Expected: all tests pass.
+
+Record the current WeChat PID and crash-report count without operating the UI:
+
+```bash
+wechat_profile_pid="$(pgrep -x WeChat)"
+test "$(printf '%s\n' "$wechat_profile_pid" | awk 'NF {n++} END {print n+0}')" -eq 1
+printf '%s\n' "$wechat_profile_pid"
+find "$HOME/Library/Logs/DiagnosticReports" -name 'WeChat-*.ips' | wc -l
+```
+
+Expected: exactly one entered WeChat PID. If account readiness is not observable
+without UI operation, ask the user to confirm that WeChat is entered before
+attaching.
+
+- [ ] **Step 2: Request action-time confirmation for the marker**
+
+State exactly:
+
+```text
+The read-only profiler is ready. It requires you to manually send
+"WCPROFILE-20260802-01" once to "AI聊天群". The tool will only observe and detach;
+it will not send, click, focus, or type. May I start the capture?
+```
+
+Do not attach until the user confirms this distinct externally visible action.
+
+- [ ] **Step 3: Start the bounded read-only capture**
+
+Resolve exactly one PID again immediately before attach, then pass that exact
+numeric value to the profiler:
+
+```bash
+wechat_profile_pid="$(pgrep -x WeChat)"
+test "$(printf '%s\n' "$wechat_profile_pid" | awk 'NF {n++} END {print n+0}')" -eq 1
+uv run --project . python tools/wechat_profile_capture.py \
+ --pid "$wechat_profile_pid" \
+ --marker WCPROFILE-20260802-01 \
+ --output /private/tmp/wechat-local-store-events-v2.jsonl
+```
+
+Expected: `wechat_profile_ready`. Ask the user to manually send the exact marker
+once. The capture must print `capture_complete` and detach within 30 seconds.
+
+If it prints missing labels, do not request another marker. Stop and return to
+Task 6 with the captured evidence; any second marker needs a new explicit
+confirmation.
+
+- [ ] **Step 4: Verify process health and capture completeness**
+
+Run:
+
+```bash
+pgrep -x WeChat
+find "$HOME/Library/Logs/DiagnosticReports" -name 'WeChat-*.ips' | wc -l
+tail -n 1 /private/tmp/wechat-local-store-events-v2.jsonl
+```
+
+Expected: the PID and crash-report count are unchanged, and the terminal event
+is complete. If WeChat exited or a new report exists, stop Phase 1.
+
+### Task 8: Sanitize, validate, and generate a not-ready profile
+
+**Files:**
+- Create: `tests/fixtures/wechat_local_store_capture.json`
+- Create: `native/include/wechat_4_1_8_28_profile.hpp`
+- Create: `wechat_cli/bin/libwechat_local_visible_bridge_safe_v1.dylib`
+- Create: `wechat_cli/bin/wechat_local_visible_profile.json`
+- Create: `docs/research/wechat-4.1.8.28-local-store-profile.md`
+- Modify: `tests/test_wechat_profile_validation.py`
+
+- [ ] **Step 1: Add a fixture reproducibility test**
+
+```python
+def test_committed_profile_artifacts_are_reproducible():
+ profile = json.loads(PROFILE_FIXTURE.read_text(encoding="utf-8"))
+ assert validate_profile(profile) == "profile_valid_not_ready"
+ assert PROFILE_HEADER.read_text(encoding="utf-8") == render_header(profile)
+ manifest = json.loads(PROFILE_MANIFEST.read_text(encoding="utf-8"))
+ assert manifest["profile_complete"] is True
+ assert manifest["adapter_ready"] is False
+ assert manifest["capture_sha256"] == profile["capture_sha256"]
+```
+
+- [ ] **Step 2: Run it and verify RED**
+
+Run:
+
+```bash
+uv run --project . pytest -q tests/test_wechat_profile_validation.py \
+ -k reproducible
+```
+
+Expected: failure because no measured fixture/header/manifest is committed.
+
+- [ ] **Step 3: Generate sanitized artifacts from the raw capture**
+
+Run:
+
+```bash
+phase1_package_build="$(mktemp -d /private/tmp/wechat-phase1-package.XXXXXX)"
+./native/build.sh "$phase1_package_build"
+install -m 755 \
+ "$phase1_package_build/libwechat_local_visible_bridge_safe_v1.dylib" \
+ wechat_cli/bin/libwechat_local_visible_bridge_safe_v1.dylib
+uv run --project . python tools/validate_wechat_profile.py \
+ --raw-capture /private/tmp/wechat-local-store-events-v2.jsonl \
+ --image /Applications/WeChat.app/Contents/Frameworks/wechat.dylib \
+ --arm64-slice /private/tmp/wechat-arm64-profile.dylib \
+ --marker WCPROFILE-20260802-01 \
+ --fixture tests/fixtures/wechat_local_store_capture.json \
+ --header native/include/wechat_4_1_8_28_profile.hpp \
+ --manifest wechat_cli/bin/wechat_local_visible_profile.json \
+ --bridge wechat_cli/bin/libwechat_local_visible_bridge_safe_v1.dylib
+```
+
+Expected: `profile_valid_not_ready`. The command must never print the marker or
+raw memory. The generated manifest must contain `adapter_ready: false`.
+
+- [ ] **Step 4: Write the evidence document**
+
+Document the exact image fingerprint, capture digest, every measured RVA and
+signature, ABI argument/return observations, execution context, ownership,
+local/client/server ID agreement, notification evidence, and unresolved Phase 2
+work in `docs/research/wechat-4.1.8.28-local-store-profile.md`.
+
+Do not include marker text, raw registers, memory dumps, decrypted database
+paths, account IDs, or tokens.
+
+- [ ] **Step 5: Run reproducibility and provider fail-closed tests**
+
+Run:
+
+```bash
+uv run --project . pytest -q tests/test_wechat_profile_validation.py \
+ tests/test_native_bridge_provider.py
+```
+
+Expected: all tests pass. Provider tests prove `adapter_ready: false` still
+stops before injection.
+
+- [ ] **Step 6: Commit measured evidence only**
+
+```bash
+git add tests/fixtures/wechat_local_store_capture.json \
+ native/include/wechat_4_1_8_28_profile.hpp \
+ wechat_cli/bin/libwechat_local_visible_bridge_safe_v1.dylib \
+ wechat_cli/bin/wechat_local_visible_profile.json \
+ docs/research/wechat-4.1.8.28-local-store-profile.md \
+ tests/test_wechat_profile_validation.py
+git commit -m "docs: pin measured local-store profile evidence"
+```
+
+Never add `/private/tmp/wechat-local-store-events-v2.jsonl`.
+
+### Task 9: Verify Phase 1 remains fail closed
+
+- [ ] **Step 1: Run the complete automated suite**
+
+```bash
+uv run --project . pytest -q
+```
+
+Expected: zero failures.
+
+- [ ] **Step 2: Build and inspect temporary native artifacts**
+
+```bash
+phase1_build_dir=$(mktemp -d /private/tmp/wechat-phase1-build.XXXXXX)
+./native/build.sh "$phase1_build_dir"
+file "$phase1_build_dir"/*
+codesign --verify --strict "$phase1_build_dir/libwechat_local_visible_bridge_safe_v1.dylib"
+nm -nm "$phase1_build_dir/libwechat_local_visible_bridge_safe_v1.dylib" | \
+ rg 'OwlHighLevelSendAdapter|VerifyOwlMainScopeRunner'
+```
+
+Expected: the build and code-sign commands exit 0; the final `rg` exits 1 with
+no matches. Remove the explicit temporary directory after recording its exact
+path; it contains reproducible build outputs only.
+
+- [ ] **Step 3: Prove production still stops before injection**
+
+Run:
+
+```bash
+uv run --project . python - <<'PY'
+from wechat_cli.core.native_bridge_provider import _ready_profile
+from wechat_cli.core.sending import SendUnavailableError
+
+try:
+ _ready_profile()
+except SendUnavailableError as error:
+ message = str(error)
+ assert "profile" in message
+ assert "未发送任何消息" in message
+ print("profile_gate_fail_closed")
+else:
+ raise AssertionError("Phase 1 profile must not enable the adapter")
+PY
+```
+
+Expected: exit 0 and print only `profile_gate_fail_closed`. This imports and
+checks the packaged manifest directly; it does not invoke the CLI, injector,
+bridge socket, or message store.
+
+## Phase 1 completion gate
+
+Do not claim the send feature is fixed at the end of this plan. Report only that
+the crashing route is quarantined and the measured profile is ready for Phase
+2. Start a separate `writing-plans` pass only after Task 9, using the generated
+header and evidence table to write every Phase 2 typedef, RVA, ownership rule,
+and call sequence exactly. `测试信息6` remains unsent until that evidence-derived
+Phase 2 implementation, clean verification, and pre-send runtime checks all
+pass.
diff --git a/docs/superpowers/specs/2026-08-01-local-visible-send-design.md b/docs/superpowers/specs/2026-08-01-local-visible-send-design.md
new file mode 100644
index 0000000..e58859e
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-01-local-visible-send-design.md
@@ -0,0 +1,114 @@
+# Local-Visible Background Send Design
+
+## Goal
+
+Make `wechat-cli send` create the same persistent outgoing text row that a
+normal WeChat send creates, while retaining background operation, exact group
+resolution, and the already-working network delivery path.
+
+## Observed behavior
+
+The adapter on `feat/wechat-send` submits one `/newsendmsg` Mars task. Group
+members receive the text, but WeChat does not create an outgoing row in its
+local message store. The message is therefore absent from the desktop
+conversation and from `wechat-cli history`.
+
+An experimental replacement called a captured UI send closure from the GCD
+main queue. WeChat aborted before local persistence or network submission with
+`await_noexcept() must be called from coroutine context`. That boundary cannot
+be called directly and remains disabled.
+
+## Chosen design
+
+Retain the proven Mars adapter from `feat/wechat-send` and add a pinned local
+persistence stage for WeChat 4.1.8.28 ARM64. Before the existing adapter submits
+its one network task, the bridge asks WeChat's own MessageStore/MessageService
+layer, on its required internal queue, to create the outgoing text row. The
+returned positive `local_id` and the message's client identifier are reused by
+the Mars request. A successful response updates that same row's status and
+`server_id` through WeChat's internal message API and publishes the normal
+conversation refresh notification.
+
+This design does not call the UI send closure or replace the working network
+path. The base `feat/wechat-send` branch remains unchanged. If local persistence
+cannot be proven safe before any side effect, this branch fails before network
+submission. After a local row or network task may exist, every incomplete
+result is `unknown`; the bridge never falls back or retries.
+
+## Data flow
+
+1. Resolve an exact group and validate non-empty text as today.
+2. Build one outgoing WeChat message model with a unique request/client ID.
+3. Submit a local-insert operation to WeChat's message-storage queue and require
+ a positive `local_id` before continuing.
+4. Serialize the existing Mars task once, reusing the same recipient, text,
+ client ID, timestamp, and local identifier where the pinned protocol fields
+ support it.
+5. On the internal response callback, update the same local row to the accepted
+ state with a positive `server_id`, then notify the conversation model.
+6. Refresh the existing read cache and confirm that exact outgoing row before
+ reporting `server_accepted`.
+
+If the local row exists but submission or acknowledgement is incomplete, the
+row remains pending or failed according to WeChat's own state transition. It is
+not deleted, rewritten directly, or automatically resent.
+
+## Profile discovery
+
+Use offline metadata, strings, disassembly, and instruction cross-references
+first. Candidate storage and update boundaries must expose or lead to all of:
+
+- an exact `@chatroom` recipient;
+- UTF-8 text passed without rewriting;
+- creation of a positive local message ID on WeChat's storage queue;
+- an update of the same row by local/client identifier;
+- a conversation refresh notification;
+- a completion path that stores a positive server ID.
+
+Any runtime observation remains read-only until the ABI, queue, ownership, and
+instruction signatures are known. No diagnostic message is sent to a real group
+without a separate explicit request.
+
+## Bridge contract
+
+Keep the existing authenticated Unix-socket request format and the Mars
+adapter's single network submission. The native adapter returns `acknowledged`
+only when it has a positive local ID and the corresponding internal response
+updated the same message. Python then confirms the outgoing row has `status=2`
+and a positive server ID before returning `server_accepted`.
+
+The bridge remains single-flight. Timeout, process exit, incomplete callbacks,
+or identifier disagreement returns `unknown` and forbids automatic retry.
+
+## Hot loading
+
+Build the experimental bridge with a distinct dylib/install name and distinct
+PID-specific metadata/socket names. This permits loading without restarting
+WeChat while preventing reuse of a bridge from a dead PID. The CLI on this
+branch uses only the experimental endpoint and never falls back to the base
+endpoint after a request begins.
+
+## Non-goals
+
+- No direct writes to WeChat databases.
+- No UI input, clipboard use, or foreground-window switching.
+- No external recreation of the WeChat network protocol.
+- No direct invocation of an `await_noexcept` closure from a GCD queue.
+- No automatic fallback or retry after a possible submission.
+- No support for non-text messages, mentions, replies, attachments, or direct
+ contacts.
+
+## Failure containment and tests
+
+Every pinned function has a build fingerprint and entry-instruction signature.
+The storage queue, ownership rules, local-ID output, update callback, and
+notification boundary must all be profiled; partial profiles remain disabled.
+Unsupported or ambiguous profiles fail before local insertion or network
+submission. All owned argument storage remains alive through completion.
+
+Native fake-host tests cover local insert success/failure, exact identifier
+reuse, acknowledgement updates, conversation notifications, timeouts, and the
+rule that no network submission occurs when local insertion fails. CI never
+attaches to WeChat. A real test is allowed only after those checks pass and the
+user separately approves one uniquely identifiable message to a dedicated test
+group.
diff --git a/docs/superpowers/specs/2026-08-02-safe-local-visible-send-recovery-design.md b/docs/superpowers/specs/2026-08-02-safe-local-visible-send-recovery-design.md
new file mode 100644
index 0000000..5330d54
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-02-safe-local-visible-send-recovery-design.md
@@ -0,0 +1,213 @@
+# Safe Local-Visible Background Send Recovery Design
+
+## Goal
+
+Make `wechat-cli send` deliver one group text while creating the same persistent
+outgoing row that desktop WeChat displays, without focusing, raising, clicking,
+typing into, or otherwise operating any WeChat window. The path must also work
+when WeChat is minimized or covered by another application.
+
+The first approved live acceptance message is exactly `测试信息6` to the uniquely
+resolved group `AI聊天群`. It may be submitted once only after every offline and
+non-mutating runtime gate passes.
+
+## Incident and root cause
+
+The current uncommitted `local-visible v6` experiment is unsafe and must not be
+used again. Crash reports at 14:03 and 18:02 on 2026-08-02 show the same
+deterministic failure:
+
+- WeChat 4.1.8 (36571) terminates itself with `SIGABRT` on the main thread.
+- The stack returns directly to `OwlHighLevelSendAdapter` after its indirect call
+ to WeChat RVA `0x25cd228`.
+- The adapter passes a manually fabricated private message object to the high
+ level send routine.
+- WeChat reaches an internal fatal invariant and calls `abort()` before a local
+ row or confirmed network result exists.
+
+The exact rejected private field is not present in the stripped crash report,
+but the call boundary and repeated failure are conclusive. This is not a login
+transition, timeout, or random process crash.
+
+The existing 212-test suite passes because its fake Owl runner executes a C++
+lambda without modeling WeChat's private object and coroutine invariants. Those
+tests do not establish live safety.
+
+## Rejected approaches
+
+### Patch the high-level private send object
+
+Rejected. The private object layout and caller-owned invariants are incomplete,
+and two live attempts produced the same fatal abort. Further field-by-field live
+experiments would use the user's WeChat process as a crash probe.
+
+### Directly write the WeChat database
+
+Rejected. Direct writes can desynchronize WCDB indexes, conversation state,
+notifications, encryption metadata, and server acknowledgement state. They also
+cannot prove that the desktop client accepted and owns the row.
+
+### UI automation
+
+Rejected by the product requirement. The send path must not focus or operate a
+window, clipboard, input field, or keyboard, even temporarily.
+
+## Chosen architecture
+
+Retain the committed queue-independent `LocalVisibleAdapter` transaction and
+complete its two gateway dependencies:
+
+1. `PinnedLocalMessageGateway` creates and updates an outgoing row through
+ WeChat's measured internal message-storage API on its measured execution
+ context.
+2. `PinnedMarsGateway` retains the proven one-shot network task from
+ `feat/wechat-send` and reuses the local message identity.
+3. `LocalVisibleAdapter` sequences local insert, one network submission, and
+ local acceptance update. It never retries or falls back.
+
+The production factory composes these gateways only when an exact, complete,
+measured profile is available for the running WeChat image. Otherwise `send`
+fails before creating a local row or submitting a network task.
+
+The unsafe `OwlHighLevelSendAdapter`, its source, its provider selection, and
+all experimental v3-v6 packaged dylibs are removed from the production path.
+
+## Profile evidence gate
+
+Production constants must be generated from one sanitized, read-only capture
+of a normal user-performed marker send on the exact supported build. Static
+disassembly may explain captured calls but cannot substitute for runtime
+evidence.
+
+The capture must establish all of these in one message lifecycle:
+
+- exact image path, ARM64 UUID, SHA-256, and entry signatures;
+- the real message model ownership and required lifetime;
+- the storage execution context and dispatch boundary;
+- exact insert arguments and a zero-to-positive `local_id` transition;
+- the same client identifier in local storage and the Mars request;
+- the server response decode boundary and a positive `server_id`;
+- update of the same local row to accepted status;
+- the normal conversation refresh notification for that row;
+- a non-mutating runtime-readiness probe that distinguishes an entered,
+ send-capable account from the pre-entry login screen.
+
+The validator emits a pinned header only when every invariant agrees. A
+hand-written test fixture can test validator branches, but it can never enable
+the production factory. Generated output records a digest of the sanitized raw
+capture so fabricated constants or partial captures fail closed.
+
+If another manual marker capture is required, it is a separate externally
+visible action and requires explicit confirmation at action time. The capture
+tool only observes and automatically detaches; it never calls a WeChat function
+or sends a message.
+
+## Send data flow
+
+1. Resolve `GROUP` to one exact existing `@chatroom` contact and validate text.
+2. Verify the pinned app path, bundle/build identifiers, image UUID/hash, code
+ signatures, one live WeChat PID, and the measured send-capable account state.
+3. Capture the target message-table baseline before any mutation.
+4. Generate one request ID, client ID, and creation timestamp.
+5. On the measured storage context, insert the outgoing text model and require a
+ positive local ID that matches the transaction identity.
+6. Submit the proven Mars task exactly once with the same recipient, text,
+ client ID, timestamp, and supported local identifier fields.
+7. Decode one positive server acceptance. On the measured storage context,
+ update the same local row and publish the measured conversation notification.
+8. Refresh the read cache and confirm exactly one new outgoing text row with the
+ same local ID, positive server ID, sender identity, text, and accepted status.
+9. Return `server_accepted` only after all checks pass.
+
+No step operates the WeChat UI or changes the frontmost application.
+
+## Failure semantics
+
+Failures are classified by the last proven boundary:
+
+- Before local insertion and before network submission: return `unavailable`
+ and explicitly state that no message was sent.
+- After a local row or any network bytes may exist: return `unknown`, preserve
+ every known identifier, and prohibit automatic retry.
+- Process exit, bridge disconnect, timeout, duplicate matching rows, identifier
+ disagreement, missing notification, or an incomplete callback after a
+ mutation boundary is always `unknown`.
+- A stale bridge from another PID or profile is never reused.
+- The tool never restarts, relaunches, re-enters, or foregrounds WeChat after a
+ failure.
+
+## Safety containment
+
+- The build and source tests ban the high-level send RVA and
+ `OwlHighLevelSendAdapter` from the production bridge.
+- Profile validation occurs before the production adapter becomes reachable.
+- All native argument, callback, model, and task storage remains owned through
+ its final callback or process lifetime where the WeChat lifetime is unknown.
+- The local and network gateways are single-flight for one group transaction.
+- Injection permission failure is an `unavailable` result; the tool does not
+ re-sign or restart WeChat during `send`.
+- Runtime files use distinct PID- and implementation-scoped names and strict
+ ownership/mode checks.
+- Logs and receipts exclude message text, auth tokens, decrypted keys, and raw
+ captured memory.
+
+## Testing strategy
+
+Implementation follows test-first red-green-refactor cycles.
+
+### Static and profile tests
+
+- Reject every missing capture invariant, synthesized production profile,
+ wrong image, unsafe RVA, signature mismatch, or queue mismatch.
+- Verify the generated header is reproducible from the sanitized capture.
+- Verify production source and linked artifacts contain no reference to the
+ unsafe adapter or high-level send entry.
+
+### Native fake-gateway tests
+
+- Insert failure performs zero network submissions.
+- Success performs one insert, one network submission, and one update in order.
+- Every stage reuses the same identity.
+- Duplicate, late, invalid, timeout, and reentrant callbacks complete once and
+ never resubmit.
+- Model and callback ownership survives asynchronous completion.
+
+### Python and bridge tests
+
+- Only an acknowledged receipt with matching positive IDs and the exact new
+ database row returns success.
+- Pre-entry/login-not-ready state fails before message mutation.
+- Stale endpoints, process replacement, and malformed receipts fail closed.
+- Full CLI, bridge, confirmation, and packaging suites pass from a clean build.
+
+### Live acceptance test
+
+Before the one approved send, record the frontmost app, WeChat PID, crash-report
+baseline, target history baseline, and absence of `测试信息6`. The user controls
+whether WeChat is minimized; the tool does not change its window state.
+
+Submit exactly once to `AI聊天群`, then require all of:
+
+- CLI receipt contains matching positive `local_id` and `server_id`;
+- `wechat-cli history` contains exactly one new outgoing `测试信息6` row;
+- the desktop conversation displays `测试信息6` when inspected read-only;
+- WeChat PID is unchanged and no new crash report exists;
+- the previously frontmost application remains frontmost throughout send.
+
+If the send result is `unknown`, do not retry even when a later observation is
+missing. Report the evidence and stop.
+
+## Non-goals
+
+- Direct contacts, non-text messages, mentions, replies, and attachments.
+- Supporting a different WeChat version or image fingerprint.
+- Restarting WeChat to prove persistence during this recovery task.
+- Generalizing private ABI discovery beyond the one pinned supported build.
+- Hiding an unsupported or incomplete profile behind a best-effort fallback.
+
+## Acceptance criteria
+
+The recovery is complete only when the unsafe path is absent, the evidence gate
+is complete, clean automated verification passes, and the single approved live
+test satisfies every observation above. Passing fake tests without a complete
+measured profile is not completion.
diff --git a/native/build.sh b/native/build.sh
new file mode 100755
index 0000000..828842b
--- /dev/null
+++ b/native/build.sh
@@ -0,0 +1,74 @@
+#!/bin/bash
+set -euo pipefail
+
+if [[ "$(uname -s)" != "Darwin" || "$(uname -m)" != "arm64" ]]; then
+ echo "native bridge builds only on macOS ARM64" >&2
+ exit 2
+fi
+
+if [[ $# -ne 1 || -z "$1" ]]; then
+ echo "usage: native/build.sh OUTPUT_DIRECTORY" >&2
+ exit 2
+fi
+
+script_directory="$(cd "$(dirname "$0")" && pwd)"
+output_directory="$1"
+compiler="$(xcrun --find clang++)"
+sdk_path="$(xcrun --sdk macosx --show-sdk-path)"
+
+mkdir -p "$output_directory"
+
+common_flags=(
+ -std=c++17
+ -arch arm64
+ -isysroot "$sdk_path"
+ -I"$sdk_path/usr/include/c++/v1"
+ -mmacosx-version-min=13.0
+ -fobjc-arc
+ -fblocks
+ -Wall
+ -Wextra
+ -Werror
+ -I"$script_directory/include"
+)
+
+"$compiler" "${common_flags[@]}" \
+ -dynamiclib \
+ -Wl,-install_name,@rpath/libwechat_bridge_core.dylib \
+ "$script_directory/src/bridge_service.mm" \
+ "$script_directory/src/local_visible_adapter.mm" \
+ "$script_directory/src/official_task_adapter.mm" \
+ "$script_directory/src/production_adapter.mm" \
+ "$script_directory/src/wechat_adapter.mm" \
+ "$script_directory/src/official_task_trampolines.S" \
+ "$script_directory/src/hook_trampolines.S" \
+ -framework Foundation \
+ -framework Security \
+ -o "$output_directory/libwechat_bridge_core.dylib"
+
+"$compiler" "${common_flags[@]}" \
+ "$script_directory/tests/fake_host.mm" \
+ -L"$output_directory" \
+ -lwechat_bridge_core \
+ -Wl,-rpath,@loader_path \
+ -framework Foundation \
+ -o "$output_directory/native_fake_host"
+
+"$compiler" "${common_flags[@]}" \
+ -dynamiclib \
+ -Wl,-install_name,@rpath/libwechat_official_task_bridge_v2.dylib \
+ "$script_directory/src/bridge_service.mm" \
+ "$script_directory/src/official_task_adapter.mm" \
+ "$script_directory/src/production_adapter.mm" \
+ "$script_directory/src/official_task_trampolines.S" \
+ "$script_directory/src/bridge_entry.mm" \
+ -framework Foundation \
+ -framework Security \
+ -o "$output_directory/libwechat_official_task_bridge_v2.dylib"
+
+"$compiler" "${common_flags[@]}" \
+ "$script_directory/src/injector.cc" \
+ -o "$output_directory/wechat_send_injector"
+
+codesign --force --sign - "$output_directory/libwechat_official_task_bridge_v2.dylib"
+codesign --force --sign - "$output_directory/wechat_send_injector"
diff --git a/native/include/wechat_bridge.hpp b/native/include/wechat_bridge.hpp
new file mode 100644
index 0000000..5b75594
--- /dev/null
+++ b/native/include/wechat_bridge.hpp
@@ -0,0 +1,135 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace wechat_bridge {
+
+inline constexpr std::uint32_t kProtocolVersion = 1;
+inline constexpr std::size_t kMaximumFrameBytes = 1024 * 1024;
+
+struct SendRequest {
+ std::string request_id;
+ std::string group;
+ std::string username;
+ std::string text;
+};
+
+enum class AckState {
+ kAcknowledged,
+ kSubmitted,
+ kUnknown,
+};
+
+struct SendReceipt {
+ AckState ack_state = AckState::kUnknown;
+ std::optional local_id;
+ std::optional server_id;
+};
+
+using SendCompletion = std::function;
+
+// Implementations must enqueue work on their captured message queue and invoke
+// completion asynchronously. The bridge itself never calls WeChat internals.
+class SendAdapter {
+ public:
+ virtual ~SendAdapter() = default;
+ virtual void Send(const SendRequest& request, SendCompletion completion) = 0;
+};
+
+struct LocalMessageIdentity {
+ std::int64_t local_id = 0;
+ std::uint64_t client_id = 0;
+ std::uint32_t create_time = 0;
+};
+
+struct NetworkAcceptance {
+ bool accepted = false;
+ std::int64_t server_id = 0;
+};
+
+using LocalInsertCompletion =
+ std::function)>;
+using NetworkCompletion = std::function;
+using LocalUpdateCompletion = std::function;
+
+class LocalMessageGateway {
+ public:
+ virtual ~LocalMessageGateway() = default;
+ virtual void InsertOutgoing(const SendRequest& request,
+ std::uint64_t client_id,
+ std::uint32_t create_time,
+ LocalInsertCompletion completion) = 0;
+ virtual void MarkAccepted(const LocalMessageIdentity& identity,
+ std::int64_t server_id,
+ LocalUpdateCompletion completion) = 0;
+};
+
+class MarsGateway {
+ public:
+ virtual ~MarsGateway() = default;
+ virtual void Submit(const SendRequest& request,
+ const LocalMessageIdentity& identity,
+ NetworkCompletion completion) = 0;
+};
+
+std::shared_ptr CreateLocalVisibleAdapter(
+ std::shared_ptr local,
+ std::shared_ptr mars);
+
+struct OfficialTaskBindings {
+ std::uintptr_t image_base = 0;
+ std::uintptr_t builder_address = 0;
+ std::uintptr_t submit_address = 0;
+ void* builder_service = nullptr;
+ std::function)> main_dispatch;
+};
+
+// Exposed for the native fake host so the pinned ABI can be tested without
+// loading WeChat. Production callers use CreateProductionWeChatAdapter.
+std::shared_ptr CreateOfficialTaskAdapter(
+ OfficialTaskBindings bindings);
+
+struct BridgeConfig {
+ std::string safe_directory;
+ std::string instance_tag;
+ std::uint32_t adapter_timeout_ms = 15000;
+};
+
+class BridgeService {
+ public:
+ BridgeService();
+ ~BridgeService();
+
+ BridgeService(const BridgeService&) = delete;
+ BridgeService& operator=(const BridgeService&) = delete;
+
+ bool Start(const BridgeConfig& config,
+ std::shared_ptr adapter,
+ std::string* error);
+ void Stop();
+
+ std::string metadata_path() const;
+ std::string socket_path() const;
+
+ private:
+ class Impl;
+ std::unique_ptr impl_;
+};
+
+// Creates the strictly pinned WeChat 4.1.8.28 ARM64 adapter. Any path,
+// instruction signature, or build mismatch fails before sending is enabled.
+std::shared_ptr CreateProductionWeChatAdapter(
+ std::string* unsupported_reason);
+
+// Shared by the pinned adapter and its native fake-host contract test.
+bool VerifyUnusedHookIslandBytes(const void* bytes, std::size_t size);
+int HookWriteProtection();
+int EmptyTextResponseStatus();
+
+} // namespace wechat_bridge
diff --git a/native/src/bridge_entry.mm b/native/src/bridge_entry.mm
new file mode 100644
index 0000000..58884ed
--- /dev/null
+++ b/native/src/bridge_entry.mm
@@ -0,0 +1,127 @@
+#include "wechat_bridge.hpp"
+
+#import
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+
+namespace {
+
+constexpr char kExpectedExecutable[] =
+ "/Applications/WeChat.app/Contents/MacOS/WeChat";
+constexpr char kBridgeInstanceTag[] = "official-task-v1";
+
+wechat_bridge::BridgeService* g_service = nullptr;
+
+bool ExactWeChatProcess() {
+ std::uint32_t size = 0;
+ if (_NSGetExecutablePath(nullptr, &size) != -1 || size == 0) {
+ return false;
+ }
+ std::string raw(size, '\0');
+ if (_NSGetExecutablePath(raw.data(), &size) != 0) {
+ return false;
+ }
+ char resolved[PATH_MAX];
+ return realpath(raw.c_str(), resolved) != nullptr &&
+ std::strcmp(resolved, kExpectedExecutable) == 0 &&
+ [[[NSBundle mainBundle] bundleIdentifier]
+ isEqualToString:@"com.tencent.xinWeChat"];
+}
+
+bool PrepareSafeDirectory(std::string* result) {
+ const passwd* user = getpwuid(geteuid());
+ if (user == nullptr || user->pw_dir == nullptr || user->pw_dir[0] != '/' ||
+ geteuid() != getuid()) {
+ return false;
+ }
+ const std::string directory =
+ std::string(user->pw_dir) +
+ "/Library/Containers/com.tencent.xinWeChat/Data/wcb";
+ struct stat metadata {};
+ if (lstat(directory.c_str(), &metadata) != 0) {
+ if (errno != ENOENT || mkdir(directory.c_str(), 0700) != 0) {
+ return false;
+ }
+ }
+ if (lstat(directory.c_str(), &metadata) != 0 ||
+ !S_ISDIR(metadata.st_mode) || metadata.st_uid != geteuid() ||
+ chmod(directory.c_str(), 0700) != 0) {
+ return false;
+ }
+ char resolved[PATH_MAX];
+ if (realpath(directory.c_str(), resolved) == nullptr ||
+ directory != resolved) {
+ return false;
+ }
+ *result = directory;
+ return true;
+}
+
+void PublishStartupError(const std::string& directory,
+ const std::string& reason) {
+ if (reason.empty() || reason.size() > 128 ||
+ reason.find_first_not_of("abcdefghijklmnopqrstuvwxyz0123456789_") !=
+ std::string::npos) {
+ return;
+ }
+ const std::string path = directory + "/bridge-" +
+ kBridgeInstanceTag + "-" + std::to_string(getpid()) + ".error";
+ const int descriptor = open(
+ path.c_str(), O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC,
+ 0600);
+ if (descriptor < 0) {
+ return;
+ }
+ const std::string payload = reason + "\n";
+ (void)write(descriptor, payload.data(), payload.size());
+ (void)fchmod(descriptor, 0600);
+ close(descriptor);
+}
+
+void StartBridge() {
+ @autoreleasepool {
+ if (!ExactWeChatProcess() || g_service != nullptr) {
+ return;
+ }
+ std::string safe_directory;
+ if (!PrepareSafeDirectory(&safe_directory)) {
+ return;
+ }
+ std::string adapter_error;
+ auto adapter =
+ wechat_bridge::CreateProductionWeChatAdapter(&adapter_error);
+ if (adapter == nullptr) {
+ PublishStartupError(safe_directory, adapter_error);
+ return;
+ }
+ auto service = std::make_unique();
+ wechat_bridge::BridgeConfig config;
+ config.safe_directory = safe_directory;
+ config.instance_tag = kBridgeInstanceTag;
+ config.adapter_timeout_ms = 15000;
+ std::string service_error;
+ if (!service->Start(config, std::move(adapter), &service_error)) {
+ PublishStartupError(safe_directory, service_error);
+ return;
+ }
+ g_service = service.release();
+ }
+}
+
+__attribute__((constructor)) void WechatSendBridgeEntry() {
+ dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
+ StartBridge();
+ });
+}
+
+} // namespace
diff --git a/native/src/bridge_service.mm b/native/src/bridge_service.mm
new file mode 100644
index 0000000..1fec724
--- /dev/null
+++ b/native/src/bridge_service.mm
@@ -0,0 +1,1139 @@
+#include "wechat_bridge.hpp"
+
+#import
+#import
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace wechat_bridge {
+namespace {
+
+bool ValidInstanceTag(const std::string& value) {
+ if (value.size() > 32) {
+ return false;
+ }
+ return std::all_of(value.begin(), value.end(), [](unsigned char byte) {
+ return (byte >= 'a' && byte <= 'z') ||
+ (byte >= '0' && byte <= '9') || byte == '-';
+ });
+}
+
+constexpr std::size_t kTokenBytes = 32;
+constexpr int kClientIoTimeoutSeconds = 5;
+constexpr std::size_t kMaximumJsonDepth = 64;
+constexpr std::size_t kMaximumGroupBytes = 16 * 1024;
+constexpr std::size_t kMaximumUsernameBytes = 4096;
+
+bool WriteAll(int descriptor, const std::uint8_t* bytes, std::size_t size) {
+ std::size_t written = 0;
+ while (written < size) {
+ const ssize_t count =
+ write(descriptor, bytes + written, size - written);
+ if (count > 0) {
+ written += static_cast(count);
+ continue;
+ }
+ if (count < 0 && errno == EINTR) {
+ continue;
+ }
+ return false;
+ }
+ return true;
+}
+
+bool ReadAll(int descriptor, std::uint8_t* bytes, std::size_t size) {
+ std::size_t received = 0;
+ while (received < size) {
+ const ssize_t count =
+ read(descriptor, bytes + received, size - received);
+ if (count > 0) {
+ received += static_cast(count);
+ continue;
+ }
+ if (count < 0 && errno == EINTR) {
+ continue;
+ }
+ return false;
+ }
+ return true;
+}
+
+bool IsIntegerNumber(NSNumber* value) {
+ if (value == nil || CFGetTypeID((__bridge CFTypeRef)value) ==
+ CFBooleanGetTypeID()) {
+ return false;
+ }
+ const char type = value.objCType[0];
+ return std::strchr("cCsSiIlLqQ", type) != nullptr;
+}
+
+std::string Utf8(NSString* value) {
+ NSData* data = [value dataUsingEncoding:NSUTF8StringEncoding];
+ return std::string(static_cast(data.bytes), data.length);
+}
+
+class JsonSyntaxScanner {
+ public:
+ JsonSyntaxScanner(const std::uint8_t* bytes, std::size_t size)
+ : bytes_(bytes), size_(size) {}
+
+ bool Parse() {
+ SkipWhitespace();
+ if (!ParseValue(0)) {
+ return false;
+ }
+ SkipWhitespace();
+ return position_ == size_;
+ }
+
+ private:
+ bool ParseValue(std::size_t depth) {
+ if (position_ >= size_ || depth > kMaximumJsonDepth) {
+ return false;
+ }
+ switch (bytes_[position_]) {
+ case '{':
+ return ParseObject(depth + 1);
+ case '[':
+ return ParseArray(depth + 1);
+ case '"': {
+ std::size_t ignored_start = 0;
+ std::size_t ignored_end = 0;
+ return ParseString(&ignored_start, &ignored_end);
+ }
+ case 't':
+ return ParseLiteral("true");
+ case 'f':
+ return ParseLiteral("false");
+ case 'n':
+ return ParseLiteral("null");
+ default:
+ return ParseNumber();
+ }
+ }
+
+ bool ParseObject(std::size_t depth) {
+ ++position_;
+ SkipWhitespace();
+ if (Consume('}')) {
+ return true;
+ }
+ std::set keys;
+ while (true) {
+ std::size_t start = 0;
+ std::size_t end = 0;
+ if (!ParseString(&start, &end)) {
+ return false;
+ }
+ std::string decoded_key;
+ if (!DecodeString(start, end, &decoded_key) ||
+ !keys.insert(decoded_key).second) {
+ return false;
+ }
+ SkipWhitespace();
+ if (!Consume(':')) {
+ return false;
+ }
+ SkipWhitespace();
+ if (!ParseValue(depth)) {
+ return false;
+ }
+ SkipWhitespace();
+ if (Consume('}')) {
+ return true;
+ }
+ if (!Consume(',')) {
+ return false;
+ }
+ SkipWhitespace();
+ }
+ }
+
+ bool ParseArray(std::size_t depth) {
+ ++position_;
+ SkipWhitespace();
+ if (Consume(']')) {
+ return true;
+ }
+ while (true) {
+ if (!ParseValue(depth)) {
+ return false;
+ }
+ SkipWhitespace();
+ if (Consume(']')) {
+ return true;
+ }
+ if (!Consume(',')) {
+ return false;
+ }
+ SkipWhitespace();
+ }
+ }
+
+ bool ParseString(std::size_t* start, std::size_t* end) {
+ if (position_ >= size_ || bytes_[position_] != '"') {
+ return false;
+ }
+ *start = position_++;
+ while (position_ < size_) {
+ const std::uint8_t byte = bytes_[position_++];
+ if (byte == '"') {
+ *end = position_;
+ return true;
+ }
+ if (byte < 0x20) {
+ return false;
+ }
+ if (byte != '\\') {
+ continue;
+ }
+ if (position_ >= size_) {
+ return false;
+ }
+ const std::uint8_t escape = bytes_[position_++];
+ if (std::strchr("\"\\/bfnrt", escape) != nullptr) {
+ continue;
+ }
+ if (escape != 'u' || position_ + 4 > size_) {
+ return false;
+ }
+ for (int index = 0; index < 4; ++index) {
+ const std::uint8_t digit = bytes_[position_++];
+ if (!((digit >= '0' && digit <= '9') ||
+ (digit >= 'a' && digit <= 'f') ||
+ (digit >= 'A' && digit <= 'F'))) {
+ return false;
+ }
+ }
+ }
+ return false;
+ }
+
+ bool DecodeString(std::size_t start,
+ std::size_t end,
+ std::string* decoded) {
+ NSMutableData* wrapped = [NSMutableData data];
+ const std::uint8_t prefix = '[';
+ const std::uint8_t suffix = ']';
+ [wrapped appendBytes:&prefix length:1];
+ [wrapped appendBytes:bytes_ + start length:end - start];
+ [wrapped appendBytes:&suffix length:1];
+ NSError* error = nil;
+ id value = [NSJSONSerialization JSONObjectWithData:wrapped
+ options:0
+ error:&error];
+ if (error != nil || ![value isKindOfClass:[NSArray class]] ||
+ [value count] != 1 ||
+ ![[value objectAtIndex:0] isKindOfClass:[NSString class]]) {
+ return false;
+ }
+ *decoded = Utf8([value objectAtIndex:0]);
+ return true;
+ }
+
+ bool ParseNumber() {
+ const std::size_t start = position_;
+ Consume('-');
+ if (position_ >= size_) {
+ return false;
+ }
+ if (Consume('0')) {
+ if (position_ < size_ && bytes_[position_] >= '0' &&
+ bytes_[position_] <= '9') {
+ return false;
+ }
+ } else {
+ if (bytes_[position_] < '1' || bytes_[position_] > '9') {
+ return false;
+ }
+ while (position_ < size_ && bytes_[position_] >= '0' &&
+ bytes_[position_] <= '9') {
+ ++position_;
+ }
+ }
+ if (Consume('.')) {
+ if (!ConsumeDigits()) {
+ return false;
+ }
+ }
+ if (position_ < size_ &&
+ (bytes_[position_] == 'e' || bytes_[position_] == 'E')) {
+ ++position_;
+ if (position_ < size_ &&
+ (bytes_[position_] == '+' || bytes_[position_] == '-')) {
+ ++position_;
+ }
+ if (!ConsumeDigits()) {
+ return false;
+ }
+ }
+ return position_ > start;
+ }
+
+ bool ConsumeDigits() {
+ const std::size_t start = position_;
+ while (position_ < size_ && bytes_[position_] >= '0' &&
+ bytes_[position_] <= '9') {
+ ++position_;
+ }
+ return position_ > start;
+ }
+
+ bool ParseLiteral(const char* literal) {
+ const std::size_t length = std::strlen(literal);
+ if (position_ + length > size_ ||
+ std::memcmp(bytes_ + position_, literal, length) != 0) {
+ return false;
+ }
+ position_ += length;
+ return true;
+ }
+
+ bool Consume(std::uint8_t expected) {
+ if (position_ >= size_ || bytes_[position_] != expected) {
+ return false;
+ }
+ ++position_;
+ return true;
+ }
+
+ void SkipWhitespace() {
+ while (position_ < size_ &&
+ (bytes_[position_] == ' ' || bytes_[position_] == '\t' ||
+ bytes_[position_] == '\r' || bytes_[position_] == '\n')) {
+ ++position_;
+ }
+ }
+
+ const std::uint8_t* bytes_;
+ std::size_t size_;
+ std::size_t position_ = 0;
+};
+
+bool ConstantTimeTokenMatch(NSString* supplied,
+ const std::array& token) {
+ NSData* data = [supplied dataUsingEncoding:NSASCIIStringEncoding
+ allowLossyConversion:NO];
+ const std::size_t length = data == nil ? 0 : data.length;
+ const auto* bytes = static_cast(data.bytes);
+ std::size_t difference = length ^ (kTokenBytes * 2);
+
+ // The published token is hexadecimal, so compare against its encoded form.
+ static constexpr char kHex[] = "0123456789abcdef";
+ for (std::size_t index = 0; index < kTokenBytes * 2; ++index) {
+ const std::uint8_t expected =
+ static_cast(kHex[(index % 2 == 0)
+ ? token[index / 2] >> 4
+ : token[index / 2] & 0x0f]);
+ const std::uint8_t actual =
+ index < length ? bytes[index] : static_cast(0);
+ difference |= expected ^ actual;
+ }
+ return difference == 0;
+}
+
+std::string HexToken(const std::array& token) {
+ static constexpr char kHex[] = "0123456789abcdef";
+ std::string encoded(kTokenBytes * 2, '0');
+ for (std::size_t index = 0; index < token.size(); ++index) {
+ encoded[index * 2] = kHex[token[index] >> 4];
+ encoded[index * 2 + 1] = kHex[token[index] & 0x0f];
+ }
+ return encoded;
+}
+
+std::string CompactSocketName(const std::string& instance_tag,
+ const std::string& pid) {
+ constexpr std::uint64_t kFnvOffset = 14695981039346656037ULL;
+ constexpr std::uint64_t kFnvPrime = 1099511628211ULL;
+ static constexpr char kHex[] = "0123456789abcdef";
+ std::uint64_t hash = kFnvOffset;
+ for (const unsigned char byte : instance_tag) {
+ hash ^= byte;
+ hash *= kFnvPrime;
+ }
+ std::string encoded(16, '0');
+ for (std::size_t index = 0; index < encoded.size(); ++index) {
+ encoded[encoded.size() - index - 1] = kHex[hash & 0x0f];
+ hash >>= 4;
+ }
+ return "w" + encoded + "p" + pid;
+}
+
+bool ExactRequestKeys(NSDictionary* object) {
+ static NSSet* expected = [NSSet
+ setWithArray:@[
+ @"version", @"type", @"auth_token", @"request_id", @"group",
+ @"username", @"text"
+ ]];
+ return object.count == expected.count &&
+ [[NSSet setWithArray:object.allKeys] isEqualToSet:expected];
+}
+
+bool ContainsNul(NSString* value) {
+ for (NSUInteger index = 0; index < value.length; ++index) {
+ if ([value characterAtIndex:index] == 0) {
+ return true;
+ }
+ }
+ return false;
+}
+
+bool ParseRequest(const std::uint8_t* bytes,
+ std::size_t size,
+ const std::array& token,
+ SendRequest* request) {
+ if (!JsonSyntaxScanner(bytes, size).Parse()) {
+ return false;
+ }
+ NSData* data = [NSData dataWithBytes:bytes length:size];
+ if ([[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] ==
+ nil) {
+ return false;
+ }
+ NSError* error = nil;
+ id value = [NSJSONSerialization JSONObjectWithData:data
+ options:0
+ error:&error];
+ if (error != nil || ![value isKindOfClass:[NSDictionary class]]) {
+ return false;
+ }
+ NSDictionary* object = value;
+ if (!ExactRequestKeys(object)) {
+ return false;
+ }
+ NSNumber* version = object[@"version"];
+ NSString* type = object[@"type"];
+ NSString* auth_token = object[@"auth_token"];
+ NSString* request_id = object[@"request_id"];
+ NSString* group = object[@"group"];
+ NSString* username = object[@"username"];
+ NSString* text = object[@"text"];
+ if (!IsIntegerNumber(version) || version.longLongValue != kProtocolVersion ||
+ ![type isKindOfClass:[NSString class]] ||
+ ![type isEqualToString:@"send_text"] ||
+ ![auth_token isKindOfClass:[NSString class]] ||
+ ![request_id isKindOfClass:[NSString class]] ||
+ ![group isKindOfClass:[NSString class]] ||
+ ![username isKindOfClass:[NSString class]] ||
+ ![text isKindOfClass:[NSString class]] ||
+ !ConstantTimeTokenMatch(auth_token, token)) {
+ return false;
+ }
+ const std::size_t request_id_size =
+ [request_id lengthOfBytesUsingEncoding:NSUTF8StringEncoding];
+ const std::size_t group_size =
+ [group lengthOfBytesUsingEncoding:NSUTF8StringEncoding];
+ const std::size_t username_size =
+ [username lengthOfBytesUsingEncoding:NSUTF8StringEncoding];
+ const std::size_t text_size =
+ [text lengthOfBytesUsingEncoding:NSUTF8StringEncoding];
+ NSString* trimmed_text = [text
+ stringByTrimmingCharactersInSet:
+ [NSCharacterSet whitespaceAndNewlineCharacterSet]];
+ if (request_id_size == 0 || request_id_size > 256 ||
+ group_size == 0 || group_size > kMaximumGroupBytes ||
+ username_size == 0 || username_size > kMaximumUsernameBytes ||
+ text_size == 0 || text_size > kMaximumFrameBytes ||
+ trimmed_text.length == 0 ||
+ ![username hasSuffix:@"@chatroom"] ||
+ ContainsNul(request_id) || ContainsNul(group) ||
+ ContainsNul(username) || ContainsNul(text)) {
+ return false;
+ }
+ request->request_id = Utf8(request_id);
+ request->group = Utf8(group);
+ request->username = Utf8(username);
+ request->text = Utf8(text);
+ return true;
+}
+
+NSData* EncodeReceipt(const SendRequest& request, SendReceipt receipt) {
+ if (receipt.local_id.has_value() && receipt.local_id.value() <= 0) {
+ receipt.local_id.reset();
+ receipt.server_id.reset();
+ receipt.ack_state = AckState::kUnknown;
+ }
+ if (receipt.server_id.has_value() && receipt.server_id.value() <= 0) {
+ receipt.server_id.reset();
+ }
+ if (receipt.ack_state == AckState::kAcknowledged &&
+ !receipt.local_id.has_value()) {
+ receipt.ack_state = AckState::kUnknown;
+ }
+ if (receipt.ack_state == AckState::kSubmitted &&
+ (receipt.local_id.has_value() || receipt.server_id.has_value())) {
+ receipt.local_id.reset();
+ receipt.server_id.reset();
+ receipt.ack_state = AckState::kUnknown;
+ }
+ NSString* request_id =
+ [[NSString alloc] initWithBytes:request.request_id.data()
+ length:request.request_id.size()
+ encoding:NSUTF8StringEncoding];
+ NSString* group = [[NSString alloc] initWithBytes:request.group.data()
+ length:request.group.size()
+ encoding:NSUTF8StringEncoding];
+ NSString* username =
+ [[NSString alloc] initWithBytes:request.username.data()
+ length:request.username.size()
+ encoding:NSUTF8StringEncoding];
+ if (request_id == nil || group == nil || username == nil) {
+ return nil;
+ }
+ NSDictionary* object = @{
+ @"version" : @(kProtocolVersion),
+ @"type" : @"send_receipt",
+ @"ack_state" : receipt.ack_state == AckState::kAcknowledged
+ ? @"acknowledged"
+ : receipt.ack_state == AckState::kSubmitted
+ ? @"submitted"
+ : @"unknown",
+ @"request_id" : request_id,
+ @"group" : group,
+ @"username" : username,
+ @"local_id" : receipt.local_id.has_value()
+ ? @(receipt.local_id.value())
+ : [NSNull null],
+ @"server_id" : receipt.server_id.has_value()
+ ? @(receipt.server_id.value())
+ : [NSNull null],
+ };
+ NSError* error = nil;
+ NSData* data = [NSJSONSerialization dataWithJSONObject:object
+ options:0
+ error:&error];
+ return error == nil ? data : nil;
+}
+
+bool SameFile(const std::string& path,
+ dev_t device,
+ ino_t inode,
+ mode_t type) {
+ struct stat metadata {};
+ return lstat(path.c_str(), &metadata) == 0 &&
+ metadata.st_dev == device && metadata.st_ino == inode &&
+ (metadata.st_mode & S_IFMT) == type;
+}
+
+bool SameFileAt(int directory_fd,
+ const std::string& name,
+ dev_t device,
+ ino_t inode,
+ mode_t type) {
+ struct stat metadata {};
+ return directory_fd >= 0 &&
+ fstatat(directory_fd, name.c_str(), &metadata,
+ AT_SYMLINK_NOFOLLOW) == 0 &&
+ metadata.st_dev == device && metadata.st_ino == inode &&
+ (metadata.st_mode & S_IFMT) == type;
+}
+
+} // namespace
+
+class BridgeService::Impl {
+ public:
+ ~Impl() { Stop(); }
+
+ bool Start(const BridgeConfig& config,
+ std::shared_ptr adapter,
+ std::string* error) {
+ std::lock_guard lock(lifecycle_mutex_);
+ if (running_ || adapter == nullptr || config.safe_directory.empty() ||
+ config.safe_directory.front() != '/' ||
+ config.adapter_timeout_ms == 0) {
+ SetError(error, "invalid_or_already_running_bridge_configuration");
+ return false;
+ }
+
+ struct stat supplied_directory {};
+ char resolved[PATH_MAX];
+ char symlink_target[PATH_MAX];
+ const ssize_t symlink_size = readlink(
+ config.safe_directory.c_str(), symlink_target,
+ sizeof(symlink_target));
+ if (symlink_size >= 0 ||
+ lstat(config.safe_directory.c_str(), &supplied_directory) != 0 ||
+ !S_ISDIR(supplied_directory.st_mode) ||
+ realpath(config.safe_directory.c_str(), resolved) == nullptr) {
+ SetError(error, "unsafe_bridge_directory");
+ return false;
+ }
+ if (config.safe_directory != std::string(resolved)) {
+ SetError(error, "unsafe_bridge_directory");
+ return false;
+ }
+ directory_ = resolved;
+ directory_fd_ =
+ open(directory_.c_str(), O_RDONLY | O_DIRECTORY | O_NOFOLLOW |
+ O_CLOEXEC);
+ struct stat directory_metadata {};
+ if (directory_fd_ < 0 ||
+ fstat(directory_fd_, &directory_metadata) != 0 ||
+ !S_ISDIR(directory_metadata.st_mode) ||
+ directory_metadata.st_uid != geteuid() ||
+ (directory_metadata.st_mode & 0077) != 0) {
+ SetError(error, "unsafe_bridge_directory");
+ CloseDirectory();
+ return false;
+ }
+ directory_identity_.device = directory_metadata.st_dev;
+ directory_identity_.inode = directory_metadata.st_ino;
+ directory_identity_.type = S_IFDIR;
+ if (!SameDirectoryPath()) {
+ SetError(error, "unsafe_bridge_directory");
+ ResetPathsAndDirectory();
+ return false;
+ }
+
+ if (!ValidInstanceTag(config.instance_tag)) {
+ SetError(error, "invalid_bridge_instance_tag");
+ ResetPathsAndDirectory();
+ return false;
+ }
+ const std::string pid = std::to_string(getpid());
+ const std::string tag = config.instance_tag.empty()
+ ? std::string()
+ : "-" + config.instance_tag;
+ socket_name_ = "wechat-bridge" + tag + "-" + pid + ".sock";
+ metadata_name_ = "bridge" + tag + "-" + pid + ".json";
+ socket_path_ = directory_ + "/" + socket_name_;
+ if (socket_path_.size() >= sizeof(sockaddr_un::sun_path)) {
+ socket_name_ = CompactSocketName(config.instance_tag, pid);
+ socket_path_ = directory_ + "/" + socket_name_;
+ }
+ metadata_path_ = directory_ + "/" + metadata_name_;
+ if (socket_path_.size() >= sizeof(sockaddr_un::sun_path) ||
+ RelativePathExists(socket_name_) ||
+ RelativePathExists(metadata_name_)) {
+ SetError(error, "bridge_paths_unavailable");
+ ResetPathsAndDirectory();
+ return false;
+ }
+
+ if (SecRandomCopyBytes(kSecRandomDefault, token_.size(), token_.data()) !=
+ errSecSuccess) {
+ SetError(error, "secure_token_generation_failed");
+ ResetPathsAndDirectory();
+ return false;
+ }
+
+ listen_fd_.store(socket(AF_UNIX, SOCK_STREAM, 0));
+ if (listen_fd_.load() < 0) {
+ SetError(error, "bridge_socket_creation_failed");
+ ResetPathsAndDirectory();
+ return false;
+ }
+ fcntl(listen_fd_.load(), F_SETFD, FD_CLOEXEC);
+ sockaddr_un address {};
+ address.sun_family = AF_UNIX;
+ std::memcpy(address.sun_path, socket_path_.c_str(),
+ socket_path_.size() + 1);
+ if (bind(listen_fd_.load(), reinterpret_cast(&address),
+ sizeof(address)) != 0) {
+ SetError(error, "bridge_socket_bind_failed");
+ CloseListenSocket();
+ ResetPathsAndDirectory();
+ return false;
+ }
+ if (!SameDirectoryPath() ||
+ !CaptureOwnedRelativePath(socket_name_, S_IFSOCK,
+ &socket_identity_)) {
+ SetError(error, "bridge_socket_bind_failed");
+ CloseListenSocket();
+ ResetPathsAndDirectory();
+ return false;
+ }
+ owns_socket_ = true;
+ if (chmod(socket_path_.c_str(), 0600) != 0 ||
+ listen(listen_fd_.load(), 8) != 0 ||
+ !SameDirectoryPath() ||
+ !SameFileAt(directory_fd_, socket_name_, socket_identity_.device,
+ socket_identity_.inode, socket_identity_.type)) {
+ SetError(error, "bridge_socket_bind_failed");
+ CloseListenSocket();
+ CleanupOwnedPaths();
+ ResetPathsAndDirectory();
+ return false;
+ }
+
+ const std::string token = HexToken(token_);
+ NSString* socket_path_value =
+ [[NSString alloc] initWithUTF8String:socket_path_.c_str()];
+ NSString* token_value = [NSString stringWithUTF8String:token.c_str()];
+ if (socket_path_value == nil || token_value == nil) {
+ SetError(error, "bridge_metadata_publish_failed");
+ CleanupOwnedPaths();
+ CloseListenSocket();
+ ResetPathsAndDirectory();
+ return false;
+ }
+ NSDictionary* metadata = @{
+ @"version" : @(kProtocolVersion),
+ @"pid" : @(getpid()),
+ @"socket_path" : socket_path_value,
+ @"token" : token_value,
+ };
+ NSError* json_error = nil;
+ NSData* metadata_data =
+ [NSJSONSerialization dataWithJSONObject:metadata
+ options:0
+ error:&json_error];
+ if (json_error != nil || metadata_data == nil ||
+ metadata_data.length == 0 || !SameDirectoryPath()) {
+ SetError(error, "bridge_metadata_publish_failed");
+ CleanupOwnedPaths();
+ CloseListenSocket();
+ ResetPathsAndDirectory();
+ return false;
+ }
+ const std::string metadata_temp_name =
+ "." + metadata_name_ + "." + token.substr(0, 16) + ".tmp";
+ if (RelativePathExists(metadata_temp_name)) {
+ SetError(error, "bridge_metadata_publish_failed");
+ CleanupOwnedPaths();
+ CloseListenSocket();
+ ResetPathsAndDirectory();
+ return false;
+ }
+ const int metadata_fd =
+ openat(directory_fd_, metadata_temp_name.c_str(),
+ O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0600);
+ if (metadata_fd < 0) {
+ SetError(error, "bridge_metadata_publish_failed");
+ CleanupOwnedPaths();
+ CloseListenSocket();
+ ResetPathsAndDirectory();
+ return false;
+ }
+ struct stat metadata_file {};
+ if (fstat(metadata_fd, &metadata_file) != 0 ||
+ !S_ISREG(metadata_file.st_mode) ||
+ metadata_file.st_uid != geteuid()) {
+ close(metadata_fd);
+ SetError(error, "bridge_metadata_publish_failed");
+ CleanupOwnedPaths();
+ CloseListenSocket();
+ ResetPathsAndDirectory();
+ return false;
+ }
+ PathIdentity metadata_temp_identity;
+ metadata_temp_identity.device = metadata_file.st_dev;
+ metadata_temp_identity.inode = metadata_file.st_ino;
+ metadata_temp_identity.type = S_IFREG;
+ if (fchmod(metadata_fd, 0600) != 0 ||
+ !WriteAll(metadata_fd,
+ static_cast(metadata_data.bytes),
+ metadata_data.length) ||
+ fsync(metadata_fd) != 0) {
+ close(metadata_fd);
+ if (SameFileAt(directory_fd_, metadata_temp_name,
+ metadata_temp_identity.device,
+ metadata_temp_identity.inode,
+ metadata_temp_identity.type)) {
+ unlinkat(directory_fd_, metadata_temp_name.c_str(), 0);
+ }
+ SetError(error, "bridge_metadata_publish_failed");
+ CleanupOwnedPaths();
+ CloseListenSocket();
+ ResetPathsAndDirectory();
+ return false;
+ }
+ close(metadata_fd);
+ if (!SameDirectoryPath() ||
+ !SameFileAt(directory_fd_, metadata_temp_name,
+ metadata_temp_identity.device,
+ metadata_temp_identity.inode,
+ metadata_temp_identity.type) ||
+ renameatx_np(directory_fd_, metadata_temp_name.c_str(),
+ directory_fd_, metadata_name_.c_str(),
+ RENAME_EXCL) != 0) {
+ if (SameFileAt(directory_fd_, metadata_temp_name,
+ metadata_temp_identity.device,
+ metadata_temp_identity.inode,
+ metadata_temp_identity.type)) {
+ unlinkat(directory_fd_, metadata_temp_name.c_str(), 0);
+ }
+ SetError(error, "bridge_metadata_publish_failed");
+ CleanupOwnedPaths();
+ CloseListenSocket();
+ ResetPathsAndDirectory();
+ return false;
+ }
+ metadata_identity_ = metadata_temp_identity;
+ owns_metadata_ = true;
+ if (!SameDirectoryPath() ||
+ !SameFileAt(directory_fd_, metadata_name_, metadata_identity_.device,
+ metadata_identity_.inode, metadata_identity_.type)) {
+ SetError(error, "bridge_metadata_publish_failed");
+ CleanupOwnedPaths();
+ CloseListenSocket();
+ ResetPathsAndDirectory();
+ return false;
+ }
+ adapter_ = std::move(adapter);
+ adapter_timeout_ms_ = config.adapter_timeout_ms;
+ stopping_.store(false);
+ running_ = true;
+ try {
+ server_thread_ = std::thread([this] { ServerLoop(); });
+ } catch (...) {
+ running_ = false;
+ adapter_.reset();
+ CloseListenSocket();
+ CleanupOwnedPaths();
+ ResetPathsAndDirectory();
+ SetError(error, "bridge_server_thread_creation_failed");
+ return false;
+ }
+ return true;
+ }
+
+ void Stop() {
+ std::unique_lock lock(lifecycle_mutex_);
+ if (!running_ && !owns_socket_ && !owns_metadata_) {
+ return;
+ }
+ stopping_.store(true);
+ const int descriptor = listen_fd_.exchange(-1);
+ if (descriptor >= 0) {
+ shutdown(descriptor, SHUT_RDWR);
+ close(descriptor);
+ }
+ {
+ std::lock_guard client_lock(client_mutex_);
+ if (active_client_fd_ >= 0) {
+ shutdown(active_client_fd_, SHUT_RDWR);
+ }
+ if (const auto pending = active_pending_.lock()) {
+ std::lock_guard pending_lock(pending->mutex);
+ pending->cancelled = true;
+ pending->condition.notify_one();
+ }
+ }
+ lock.unlock();
+ if (server_thread_.joinable()) {
+ server_thread_.join();
+ }
+ lock.lock();
+ CleanupOwnedPaths();
+ adapter_.reset();
+ running_ = false;
+ ResetPathsAndDirectory();
+ }
+
+ std::string metadata_path() const {
+ std::lock_guard lock(lifecycle_mutex_);
+ return metadata_path_;
+ }
+
+ std::string socket_path() const {
+ std::lock_guard lock(lifecycle_mutex_);
+ return socket_path_;
+ }
+
+ private:
+ struct PathIdentity {
+ dev_t device = 0;
+ ino_t inode = 0;
+ mode_t type = 0;
+ };
+
+ struct PendingReceipt {
+ std::mutex mutex;
+ std::condition_variable condition;
+ bool completed = false;
+ bool cancelled = false;
+ SendReceipt receipt;
+ };
+
+ static void SetError(std::string* error, const char* value) {
+ if (error != nullptr) {
+ *error = value;
+ }
+ }
+
+ bool SameDirectoryPath() const {
+ return SameFile(directory_, directory_identity_.device,
+ directory_identity_.inode, directory_identity_.type);
+ }
+
+ bool RelativePathExists(const std::string& name) const {
+ struct stat metadata {};
+ if (fstatat(directory_fd_, name.c_str(), &metadata,
+ AT_SYMLINK_NOFOLLOW) == 0) {
+ return true;
+ }
+ return errno != ENOENT;
+ }
+
+ bool CaptureOwnedRelativePath(const std::string& name,
+ mode_t type,
+ PathIdentity* identity) const {
+ struct stat metadata {};
+ if (fstatat(directory_fd_, name.c_str(), &metadata,
+ AT_SYMLINK_NOFOLLOW) != 0 ||
+ (metadata.st_mode & S_IFMT) != type ||
+ metadata.st_uid != geteuid()) {
+ return false;
+ }
+ identity->device = metadata.st_dev;
+ identity->inode = metadata.st_ino;
+ identity->type = type;
+ return true;
+ }
+
+ void ServerLoop() {
+ while (!stopping_.load()) {
+ const int descriptor = listen_fd_.load();
+ if (descriptor < 0) {
+ break;
+ }
+ const int client = accept(descriptor, nullptr, nullptr);
+ if (client < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ if (stopping_.load() || errno == EBADF || errno == EINVAL) {
+ break;
+ }
+ continue;
+ }
+ fcntl(client, F_SETFD, FD_CLOEXEC);
+ int no_sigpipe = 1;
+ setsockopt(client, SOL_SOCKET, SO_NOSIGPIPE, &no_sigpipe,
+ sizeof(no_sigpipe));
+ timeval timeout {};
+ timeout.tv_sec = kClientIoTimeoutSeconds;
+ setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
+ setsockopt(client, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout));
+ {
+ std::lock_guard client_lock(client_mutex_);
+ active_client_fd_ = client;
+ }
+ uid_t peer_euid = static_cast(-1);
+ gid_t peer_egid = static_cast(-1);
+ if (!stopping_.load() &&
+ getpeereid(client, &peer_euid, &peer_egid) == 0 &&
+ peer_euid == geteuid()) {
+ HandleClient(client);
+ }
+ {
+ std::lock_guard client_lock(client_mutex_);
+ active_client_fd_ = -1;
+ }
+ close(client);
+ }
+ }
+
+ void HandleClient(int client) {
+ std::uint32_t network_size = 0;
+ if (!ReadAll(client, reinterpret_cast(&network_size),
+ sizeof(network_size))) {
+ return;
+ }
+ const std::uint32_t size = ntohl(network_size);
+ if (size == 0 || size > kMaximumFrameBytes) {
+ return;
+ }
+ std::unique_ptr bytes(new std::uint8_t[size]);
+ if (!ReadAll(client, bytes.get(), size)) {
+ return;
+ }
+ SendRequest request;
+ if (!ParseRequest(bytes.get(), size, token_, &request)) {
+ return;
+ }
+
+ auto pending = std::make_shared();
+ const auto adapter = adapter_;
+ {
+ std::lock_guard client_lock(client_mutex_);
+ if (stopping_.load() || adapter == nullptr) {
+ return;
+ }
+ active_pending_ = pending;
+ try {
+ adapter->Send(
+ request,
+ [pending, adapter](SendReceipt receipt) {
+ (void)adapter;
+ std::lock_guard lock(pending->mutex);
+ if (pending->completed || pending->cancelled) {
+ return;
+ }
+ pending->receipt = std::move(receipt);
+ pending->completed = true;
+ pending->condition.notify_one();
+ });
+ } catch (...) {
+ active_pending_.reset();
+ return;
+ }
+ }
+
+ SendReceipt receipt;
+ bool adapter_timed_out = false;
+ bool adapter_cancelled = false;
+ {
+ std::unique_lock lock(pending->mutex);
+ if (!pending->condition.wait_for(
+ lock, std::chrono::milliseconds(adapter_timeout_ms_),
+ [this, &pending] {
+ return pending->completed || pending->cancelled ||
+ stopping_.load();
+ })) {
+ receipt.ack_state = AckState::kUnknown;
+ pending->cancelled = true;
+ adapter_timed_out = true;
+ } else if (pending->cancelled || stopping_.load()) {
+ pending->cancelled = true;
+ adapter_cancelled = true;
+ } else {
+ receipt = pending->receipt;
+ }
+ }
+ {
+ std::lock_guard client_lock(client_mutex_);
+ if (active_pending_.lock() == pending) {
+ active_pending_.reset();
+ }
+ }
+ if (adapter_cancelled) {
+ return;
+ }
+ NSData* payload = EncodeReceipt(request, receipt);
+ if (payload == nil || payload.length == 0 ||
+ payload.length > kMaximumFrameBytes) {
+ if (adapter_timed_out) {
+ DisableListening();
+ }
+ return;
+ }
+ const std::uint32_t response_size =
+ htonl(static_cast(payload.length));
+ if (!WriteAll(client,
+ reinterpret_cast(&response_size),
+ sizeof(response_size))) {
+ if (adapter_timed_out) {
+ DisableListening();
+ }
+ return;
+ }
+ WriteAll(client, static_cast(payload.bytes),
+ payload.length);
+ if (adapter_timed_out) {
+ DisableListening();
+ }
+ }
+
+ void DisableListening() {
+ stopping_.store(true);
+ CloseListenSocket();
+ std::lock_guard lock(lifecycle_mutex_);
+ CleanupOwnedPaths();
+ }
+
+ void CloseListenSocket() {
+ const int descriptor = listen_fd_.exchange(-1);
+ if (descriptor >= 0) {
+ close(descriptor);
+ }
+ }
+
+ void CloseDirectory() {
+ if (directory_fd_ >= 0) {
+ close(directory_fd_);
+ directory_fd_ = -1;
+ }
+ }
+
+ void CleanupOwnedPaths() {
+ if (owns_metadata_ &&
+ SameFileAt(directory_fd_, metadata_name_, metadata_identity_.device,
+ metadata_identity_.inode, metadata_identity_.type)) {
+ unlinkat(directory_fd_, metadata_name_.c_str(), 0);
+ }
+ owns_metadata_ = false;
+ if (owns_socket_ &&
+ SameFileAt(directory_fd_, socket_name_, socket_identity_.device,
+ socket_identity_.inode, socket_identity_.type)) {
+ unlinkat(directory_fd_, socket_name_.c_str(), 0);
+ }
+ owns_socket_ = false;
+ }
+
+ void ResetPathsAndDirectory() {
+ CloseDirectory();
+ directory_.clear();
+ socket_name_.clear();
+ metadata_name_.clear();
+ socket_path_.clear();
+ metadata_path_.clear();
+ }
+
+ mutable std::mutex lifecycle_mutex_;
+ std::mutex client_mutex_;
+ std::atomic stopping_{false};
+ bool running_ = false;
+ bool owns_socket_ = false;
+ bool owns_metadata_ = false;
+ std::atomic listen_fd_{-1};
+ int active_client_fd_ = -1;
+ std::weak_ptr active_pending_;
+ int directory_fd_ = -1;
+ std::uint32_t adapter_timeout_ms_ = 15000;
+ std::array token_{};
+ std::string directory_;
+ std::string socket_name_;
+ std::string metadata_name_;
+ std::string socket_path_;
+ std::string metadata_path_;
+ PathIdentity socket_identity_;
+ PathIdentity metadata_identity_;
+ PathIdentity directory_identity_;
+ std::shared_ptr adapter_;
+ std::thread server_thread_;
+};
+
+BridgeService::BridgeService() : impl_(std::make_unique()) {}
+
+BridgeService::~BridgeService() = default;
+
+bool BridgeService::Start(const BridgeConfig& config,
+ std::shared_ptr adapter,
+ std::string* error) {
+ return impl_->Start(config, std::move(adapter), error);
+}
+
+void BridgeService::Stop() {
+ impl_->Stop();
+}
+
+std::string BridgeService::metadata_path() const {
+ return impl_->metadata_path();
+}
+
+std::string BridgeService::socket_path() const {
+ return impl_->socket_path();
+}
+
+} // namespace wechat_bridge
diff --git a/native/src/hook_trampolines.S b/native/src/hook_trampolines.S
new file mode 100644
index 0000000..e08697f
--- /dev/null
+++ b/native/src/hook_trampolines.S
@@ -0,0 +1,81 @@
+.text
+.p2align 2
+
+.globl _WechatBridgeReq2BufEnterHook
+_WechatBridgeReq2BufEnterHook:
+ sub sp, sp, #0x120
+ stp x0, x1, [sp, #0x00]
+ stp x2, x3, [sp, #0x10]
+ stp x4, x5, [sp, #0x20]
+ stp x6, x7, [sp, #0x30]
+ stp x8, x9, [sp, #0x40]
+ stp x10, x11, [sp, #0x50]
+ stp x12, x13, [sp, #0x60]
+ stp x14, x15, [sp, #0x70]
+ stp x16, x17, [sp, #0x80]
+ str x30, [sp, #0x90]
+ stp q0, q1, [sp, #0xa0]
+ stp q2, q3, [sp, #0xc0]
+ stp q4, q5, [sp, #0xe0]
+ stp q6, q7, [sp, #0x100]
+ mov x0, x1
+ mov x1, x24
+ bl _WechatBridgeHandleReq2BufEnter
+ ldp q6, q7, [sp, #0x100]
+ ldp q4, q5, [sp, #0xe0]
+ ldp q2, q3, [sp, #0xc0]
+ ldp q0, q1, [sp, #0xa0]
+ ldr x30, [sp, #0x90]
+ ldp x16, x17, [sp, #0x80]
+ ldp x14, x15, [sp, #0x70]
+ ldp x12, x13, [sp, #0x60]
+ ldp x10, x11, [sp, #0x50]
+ ldp x8, x9, [sp, #0x40]
+ ldp x6, x7, [sp, #0x30]
+ ldp x4, x5, [sp, #0x20]
+ ldp x2, x3, [sp, #0x10]
+ ldp x0, x1, [sp, #0x00]
+ add sp, sp, #0x120
+ ldr x9, [x24, #0x60]!
+ adrp x17, _WechatBridgeReq2BufEnterContinue@PAGE
+ ldr x17, [x17, _WechatBridgeReq2BufEnterContinue@PAGEOFF]
+ br x17
+
+.globl _WechatBridgeReq2BufExitHook
+_WechatBridgeReq2BufExitHook:
+ sub sp, sp, #0x120
+ stp x0, x1, [sp, #0x00]
+ stp x2, x3, [sp, #0x10]
+ stp x4, x5, [sp, #0x20]
+ stp x6, x7, [sp, #0x30]
+ stp x8, x9, [sp, #0x40]
+ stp x10, x11, [sp, #0x50]
+ stp x12, x13, [sp, #0x60]
+ stp x14, x15, [sp, #0x70]
+ stp x16, x17, [sp, #0x80]
+ str x30, [sp, #0x90]
+ stp q0, q1, [sp, #0xa0]
+ stp q2, q3, [sp, #0xc0]
+ stp q4, q5, [sp, #0xe0]
+ stp q6, q7, [sp, #0x100]
+ mov x0, x25
+ bl _WechatBridgeHandleReq2BufExit
+ ldp q6, q7, [sp, #0x100]
+ ldp q4, q5, [sp, #0xe0]
+ ldp q2, q3, [sp, #0xc0]
+ ldp q0, q1, [sp, #0xa0]
+ ldr x30, [sp, #0x90]
+ ldp x16, x17, [sp, #0x80]
+ ldp x14, x15, [sp, #0x70]
+ ldp x12, x13, [sp, #0x60]
+ ldp x10, x11, [sp, #0x50]
+ ldp x8, x9, [sp, #0x40]
+ ldp x6, x7, [sp, #0x30]
+ ldp x4, x5, [sp, #0x20]
+ ldp x2, x3, [sp, #0x10]
+ ldp x0, x1, [sp, #0x00]
+ add sp, sp, #0x120
+ ldp x28, x27, [sp], #0x60
+ adrp x17, _WechatBridgeReq2BufExitContinue@PAGE
+ ldr x17, [x17, _WechatBridgeReq2BufExitContinue@PAGEOFF]
+ br x17
diff --git a/native/src/injector.cc b/native/src/injector.cc
new file mode 100644
index 0000000..55c582f
--- /dev/null
+++ b/native/src/injector.cc
@@ -0,0 +1,317 @@
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace {
+
+constexpr char kExpectedExecutable[] =
+ "/Applications/WeChat.app/Contents/MacOS/WeChat";
+constexpr mach_vm_size_t kRemoteStackBytes = 1024 * 1024;
+constexpr std::size_t kDlopenAddressOffset = 24;
+constexpr std::size_t kPthreadExitAddressOffset = 32;
+constexpr std::size_t kBootstrapParkOffset = 40;
+constexpr std::size_t kLoaderStubBytes = 44;
+
+struct RemoteMemory {
+ mach_vm_size_t page_size = 0;
+ mach_vm_address_t data = 0;
+ mach_vm_address_t code = 0;
+ mach_vm_address_t stack = 0;
+};
+
+void PrintError(const char* value) {
+ std::fprintf(stderr, "%s\n", value);
+}
+
+bool ParsePid(const char* value, pid_t* pid) {
+ if (value == nullptr || *value == '\0') {
+ return false;
+ }
+ char* end = nullptr;
+ errno = 0;
+ const long parsed = std::strtol(value, &end, 10);
+ if (errno != 0 || end == value || *end != '\0' || parsed <= 1 ||
+ parsed > INT32_MAX) {
+ return false;
+ }
+ *pid = static_cast(parsed);
+ return true;
+}
+
+bool ExactRegularDylib(const char* supplied, std::string* resolved_path) {
+ if (supplied == nullptr || supplied[0] != '/') {
+ return false;
+ }
+ char resolved[PATH_MAX];
+ if (realpath(supplied, resolved) == nullptr ||
+ std::strcmp(supplied, resolved) != 0) {
+ return false;
+ }
+ struct stat metadata {};
+ if (lstat(resolved, &metadata) != 0 || !S_ISREG(metadata.st_mode) ||
+ metadata.st_nlink != 1 ||
+ (metadata.st_uid != geteuid() && metadata.st_uid != 0) ||
+ (metadata.st_mode & 0022) != 0 || metadata.st_size <= 0) {
+ return false;
+ }
+ const std::string path(resolved);
+ if (path.size() < 6 || path.compare(path.size() - 6, 6, ".dylib") != 0) {
+ return false;
+ }
+ *resolved_path = path;
+ return true;
+}
+
+bool ExactTarget(pid_t pid) {
+ char path[PROC_PIDPATHINFO_MAXSIZE] = {};
+ const int size = proc_pidpath(pid, path, sizeof(path));
+ if (size <= 0 || std::strcmp(path, kExpectedExecutable) != 0) {
+ return false;
+ }
+ proc_bsdinfo info {};
+ const int info_size = proc_pidinfo(
+ pid, PROC_PIDTBSDINFO, 0, &info, sizeof(info));
+ return info_size == sizeof(info) && info.pbi_uid == geteuid();
+}
+
+bool WriteRemote(task_t task,
+ mach_vm_address_t destination,
+ const void* bytes,
+ mach_vm_size_t size) {
+ return mach_vm_write(
+ task, destination,
+ reinterpret_cast(bytes),
+ static_cast(size)) == KERN_SUCCESS;
+}
+
+bool AllocateRemoteMemory(task_t task, RemoteMemory* memory) {
+ memory->page_size = static_cast(getpagesize());
+ return memory->page_size >= 4096 &&
+ mach_vm_allocate(task, &memory->data, memory->page_size * 2,
+ VM_FLAGS_ANYWHERE) == KERN_SUCCESS &&
+ mach_vm_allocate(task, &memory->code, memory->page_size,
+ VM_FLAGS_ANYWHERE) == KERN_SUCCESS &&
+ mach_vm_allocate(task, &memory->stack, kRemoteStackBytes,
+ VM_FLAGS_ANYWHERE) == KERN_SUCCESS;
+}
+
+bool VerifyMemoryLayout() {
+ RemoteMemory memory;
+ if (!AllocateRemoteMemory(mach_task_self(), &memory)) {
+ return false;
+ }
+ const mach_vm_address_t pthread_storage =
+ memory.data + memory.page_size;
+ const bool separate_pages =
+ memory.code / memory.page_size !=
+ pthread_storage / memory.page_size;
+ const bool protected_code =
+ mach_vm_protect(mach_task_self(), memory.code, memory.page_size, false,
+ VM_PROT_READ | VM_PROT_EXECUTE) == KERN_SUCCESS;
+ const std::uint64_t marker = 0x7763627061676573ULL;
+ const bool writable_storage =
+ WriteRemote(mach_task_self(), pthread_storage, &marker,
+ sizeof(marker));
+ mach_vm_deallocate(mach_task_self(), memory.data, memory.page_size * 2);
+ mach_vm_deallocate(mach_task_self(), memory.code, memory.page_size);
+ mach_vm_deallocate(mach_task_self(), memory.stack, kRemoteStackBytes);
+ return separate_pages && protected_code && writable_storage;
+}
+
+std::vector BuildLoaderStub(
+ std::uintptr_t dlopen_address,
+ std::uintptr_t pthread_exit_address) {
+ // dlopen(path, RTLD_NOW | RTLD_LOCAL), then terminate the created pthread.
+ // pthread_create_from_mach_thread does not provide a return address that a
+ // raw `ret` start routine can safely use on every supported macOS build.
+ std::vector stub(kLoaderStubBytes, 0);
+ const std::uint32_t instructions[] = {
+ 0xd28000c1u, // mov x1, #6
+ 0x580000b0u, // ldr x16, dlopen address
+ 0xd63f0200u, // blr x16
+ 0xd2800000u, // mov x0, #0
+ 0x58000090u, // ldr x16, pthread_exit address
+ 0xd61f0200u, // br x16
+ };
+ const std::uint32_t park = 0x14000000u;
+ std::memcpy(stub.data(), instructions, sizeof(instructions));
+ std::memcpy(
+ stub.data() + kDlopenAddressOffset,
+ &dlopen_address,
+ sizeof(dlopen_address));
+ std::memcpy(
+ stub.data() + kPthreadExitAddressOffset,
+ &pthread_exit_address,
+ sizeof(pthread_exit_address));
+ std::memcpy(stub.data() + kBootstrapParkOffset, &park, sizeof(park));
+ return stub;
+}
+
+bool VerifyLoaderStub() {
+ constexpr std::uintptr_t kDlopenMarker = 0x1122334455667788ULL;
+ constexpr std::uintptr_t kPthreadExitMarker = 0x8877665544332211ULL;
+ const std::vector stub =
+ BuildLoaderStub(kDlopenMarker, kPthreadExitMarker);
+ std::uintptr_t observed_dlopen = 0;
+ std::uintptr_t observed_pthread_exit = 0;
+ std::uint32_t exit_branch = 0;
+ std::uint32_t park = 0;
+ if (stub.size() != kLoaderStubBytes) {
+ return false;
+ }
+ std::memcpy(
+ &observed_dlopen,
+ stub.data() + kDlopenAddressOffset,
+ sizeof(observed_dlopen));
+ std::memcpy(
+ &observed_pthread_exit,
+ stub.data() + kPthreadExitAddressOffset,
+ sizeof(observed_pthread_exit));
+ std::memcpy(&exit_branch, stub.data() + 20, sizeof(exit_branch));
+ std::memcpy(&park, stub.data() + kBootstrapParkOffset, sizeof(park));
+ return observed_dlopen == kDlopenMarker &&
+ observed_pthread_exit == kPthreadExitMarker &&
+ exit_branch == 0xd61f0200u && park == 0x14000000u;
+}
+
+int Inject(pid_t pid, const std::string& dylib_path) {
+ if (!ExactTarget(pid)) {
+ PrintError("target_wechat_identity_mismatch");
+ return 3;
+ }
+
+ mach_port_t task = MACH_PORT_NULL;
+ kern_return_t status = task_for_pid(mach_task_self(), pid, &task);
+ if (status != KERN_SUCCESS || task == MACH_PORT_NULL) {
+ PrintError("task_for_pid_denied_re_sign_wechat_with_debug_entitlements");
+ return 3;
+ }
+
+ void* pthread_entry =
+ dlsym(RTLD_DEFAULT, "pthread_create_from_mach_thread");
+ void* dlopen_entry = dlsym(RTLD_DEFAULT, "dlopen");
+ void* pthread_exit_entry = dlsym(RTLD_DEFAULT, "pthread_exit");
+ if (pthread_entry == nullptr || dlopen_entry == nullptr ||
+ pthread_exit_entry == nullptr) {
+ PrintError("required_system_loader_symbols_unavailable");
+ mach_port_deallocate(mach_task_self(), task);
+ return 3;
+ }
+
+ RemoteMemory memory;
+ if (!AllocateRemoteMemory(task, &memory)) {
+ PrintError("remote_memory_allocation_failed");
+ mach_port_deallocate(mach_task_self(), task);
+ return 3;
+ }
+
+ const mach_vm_address_t remote_path = memory.data;
+ const mach_vm_address_t remote_pthread = memory.data + memory.page_size;
+ const mach_vm_address_t remote_stub = memory.code;
+ const mach_vm_address_t remote_loop =
+ remote_stub + kBootstrapParkOffset;
+ if (!WriteRemote(task, remote_path, dylib_path.c_str(),
+ dylib_path.size() + 1)) {
+ PrintError("remote_path_write_failed");
+ mach_port_deallocate(mach_task_self(), task);
+ return 3;
+ }
+
+ const std::uintptr_t dlopen_address =
+ reinterpret_cast(dlopen_entry);
+ const std::uintptr_t pthread_exit_address =
+ reinterpret_cast(pthread_exit_entry);
+ const std::vector stub =
+ BuildLoaderStub(dlopen_address, pthread_exit_address);
+ if (!WriteRemote(task, remote_stub, stub.data(), stub.size()) ||
+ mach_vm_protect(task, remote_stub, memory.page_size, false,
+ VM_PROT_READ | VM_PROT_EXECUTE) != KERN_SUCCESS) {
+ PrintError("remote_loader_stub_write_failed");
+ mach_port_deallocate(mach_task_self(), task);
+ return 3;
+ }
+
+ arm_thread_state64_t thread_state {};
+ thread_state.__x[0] = remote_pthread;
+ thread_state.__x[1] = 0;
+ thread_state.__x[2] = remote_stub;
+ thread_state.__x[3] = remote_path;
+ arm_thread_state64_set_pc_fptr(
+ thread_state, reinterpret_cast(pthread_entry));
+ arm_thread_state64_set_lr_fptr(
+ thread_state, reinterpret_cast(remote_loop));
+ arm_thread_state64_set_sp(
+ thread_state, memory.stack + kRemoteStackBytes - 0x100);
+
+ thread_act_t bootstrap_thread = MACH_PORT_NULL;
+ status = thread_create_running(
+ task, ARM_THREAD_STATE64,
+ reinterpret_cast(&thread_state),
+ ARM_THREAD_STATE64_COUNT, &bootstrap_thread);
+ if (status != KERN_SUCCESS) {
+ PrintError("remote_loader_thread_creation_failed");
+ mach_port_deallocate(mach_task_self(), task);
+ return 3;
+ }
+
+ // The bootstrap thread only creates a real pthread and then parks. Wait
+ // until it reaches that safe point before terminating the bootstrap.
+ bool parked = false;
+ for (int attempt = 0; attempt < 100; ++attempt) {
+ arm_thread_state64_t observed {};
+ mach_msg_type_number_t observed_count = ARM_THREAD_STATE64_COUNT;
+ if (thread_get_state(
+ bootstrap_thread, ARM_THREAD_STATE64,
+ reinterpret_cast(&observed),
+ &observed_count) == KERN_SUCCESS &&
+ arm_thread_state64_get_pc(observed) == remote_loop) {
+ parked = true;
+ break;
+ }
+ usleep(10000);
+ }
+ if (parked) {
+ thread_terminate(bootstrap_thread);
+ }
+ mach_port_deallocate(mach_task_self(), bootstrap_thread);
+ mach_port_deallocate(mach_task_self(), task);
+ return 0;
+}
+
+} // namespace
+
+int main(int argc, char** argv) {
+ if (argc == 2 &&
+ std::strcmp(argv[1], "--verify-memory-layout") == 0) {
+ return VerifyMemoryLayout() ? 0 : 5;
+ }
+ if (argc == 2 &&
+ std::strcmp(argv[1], "--verify-loader-stub") == 0) {
+ return VerifyLoaderStub() ? 0 : 5;
+ }
+ if (argc != 3) {
+ PrintError("usage: wechat_send_injector PID ABSOLUTE_DYLIB_PATH");
+ return 2;
+ }
+ pid_t pid = 0;
+ std::string dylib_path;
+ if (!ParsePid(argv[1], &pid) ||
+ !ExactRegularDylib(argv[2], &dylib_path)) {
+ PrintError("invalid_injector_arguments");
+ return 2;
+ }
+ return Inject(pid, dylib_path);
+}
diff --git a/native/src/local_visible_adapter.mm b/native/src/local_visible_adapter.mm
new file mode 100644
index 0000000..39190b2
--- /dev/null
+++ b/native/src/local_visible_adapter.mm
@@ -0,0 +1,185 @@
+#include "wechat_bridge.hpp"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace wechat_bridge {
+namespace {
+
+enum class TransactionStage {
+ kWaitingForInsert,
+ kWaitingForNetwork,
+ kWaitingForUpdate,
+ kFinished,
+};
+
+struct TransactionState {
+ std::mutex mutex;
+ TransactionStage stage = TransactionStage::kWaitingForInsert;
+ SendCompletion completion;
+};
+
+std::atomic g_client_sequence{1};
+
+bool EndsWithChatroom(const std::string& username) {
+ static constexpr char kSuffix[] = "@chatroom";
+ constexpr std::size_t kSuffixSize = sizeof(kSuffix) - 1;
+ return username.size() >= kSuffixSize &&
+ username.compare(username.size() - kSuffixSize,
+ kSuffixSize, kSuffix) == 0;
+}
+
+std::uint64_t NextClientId(std::uint32_t create_time) {
+ const auto sequence = g_client_sequence.fetch_add(1);
+ return (static_cast(create_time) << 32) |
+ (sequence == 0 ? 1 : sequence);
+}
+
+bool Advance(const std::shared_ptr& state,
+ TransactionStage expected,
+ TransactionStage next) {
+ std::lock_guard lock(state->mutex);
+ if (state->stage != expected) {
+ return false;
+ }
+ state->stage = next;
+ return true;
+}
+
+void Finish(const std::shared_ptr& state,
+ SendReceipt receipt) {
+ SendCompletion completion;
+ {
+ std::lock_guard lock(state->mutex);
+ if (state->stage == TransactionStage::kFinished) {
+ return;
+ }
+ state->stage = TransactionStage::kFinished;
+ completion = std::move(state->completion);
+ }
+ if (completion) {
+ completion(std::move(receipt));
+ }
+}
+
+SendReceipt UnknownReceipt(
+ const std::optional& identity = std::nullopt,
+ std::optional server_id = std::nullopt) {
+ SendReceipt receipt;
+ receipt.ack_state = AckState::kUnknown;
+ if (identity.has_value() && identity->local_id > 0) {
+ receipt.local_id = identity->local_id;
+ }
+ if (server_id.has_value() && server_id.value() > 0) {
+ receipt.server_id = server_id;
+ }
+ return receipt;
+}
+
+class LocalVisibleAdapter final : public SendAdapter {
+ public:
+ LocalVisibleAdapter(std::shared_ptr local,
+ std::shared_ptr mars)
+ : local_(std::move(local)), mars_(std::move(mars)) {}
+
+ void Send(const SendRequest& request, SendCompletion completion) override {
+ if (local_ == nullptr || mars_ == nullptr || request.request_id.empty() ||
+ request.text.empty() || !EndsWithChatroom(request.username)) {
+ if (completion) {
+ completion(UnknownReceipt());
+ }
+ return;
+ }
+
+ const auto now = std::time(nullptr);
+ if (now <= 0 ||
+ static_cast(now) > UINT32_MAX) {
+ if (completion) {
+ completion(UnknownReceipt());
+ }
+ return;
+ }
+ const auto create_time = static_cast(now);
+ const auto client_id = NextClientId(create_time);
+ auto state = std::make_shared();
+ state->completion = std::move(completion);
+ auto local = local_;
+ auto mars = mars_;
+
+ local->InsertOutgoing(
+ request, client_id, create_time,
+ [state, local, mars, request, client_id, create_time](
+ std::optional identity) mutable {
+ if (!identity.has_value() || identity->local_id <= 0 ||
+ identity->client_id != client_id ||
+ identity->create_time != create_time) {
+ Finish(state, UnknownReceipt(identity));
+ return;
+ }
+ if (!Advance(state, TransactionStage::kWaitingForInsert,
+ TransactionStage::kWaitingForNetwork)) {
+ return;
+ }
+ mars->Submit(
+ request, identity.value(),
+ [state, local, identity](NetworkAcceptance acceptance) mutable {
+ if (!acceptance.accepted || acceptance.server_id <= 0) {
+ Finish(state, UnknownReceipt(identity));
+ return;
+ }
+ if (!Advance(state, TransactionStage::kWaitingForNetwork,
+ TransactionStage::kWaitingForUpdate)) {
+ return;
+ }
+ local->MarkAccepted(
+ identity.value(), acceptance.server_id,
+ [state, identity, acceptance](bool updated) mutable {
+ if (!updated) {
+ Finish(state, UnknownReceipt(
+ identity, acceptance.server_id));
+ return;
+ }
+ if (!Advance(state,
+ TransactionStage::kWaitingForUpdate,
+ TransactionStage::kFinished)) {
+ return;
+ }
+ SendReceipt receipt;
+ receipt.ack_state = AckState::kAcknowledged;
+ receipt.local_id = identity->local_id;
+ receipt.server_id = acceptance.server_id;
+ SendCompletion completion;
+ {
+ std::lock_guard lock(state->mutex);
+ completion = std::move(state->completion);
+ }
+ if (completion) {
+ completion(std::move(receipt));
+ }
+ });
+ });
+ });
+ }
+
+ private:
+ std::shared_ptr local_;
+ std::shared_ptr mars_;
+};
+
+} // namespace
+
+std::shared_ptr CreateLocalVisibleAdapter(
+ std::shared_ptr local,
+ std::shared_ptr mars) {
+ if (local == nullptr || mars == nullptr) {
+ return nullptr;
+ }
+ return std::make_shared(
+ std::move(local), std::move(mars));
+}
+
+} // namespace wechat_bridge
diff --git a/native/src/official_task_adapter.mm b/native/src/official_task_adapter.mm
new file mode 100644
index 0000000..8c5f45e
--- /dev/null
+++ b/native/src/official_task_adapter.mm
@@ -0,0 +1,235 @@
+#include "wechat_bridge.hpp"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace wechat_bridge {
+namespace {
+
+constexpr std::uintptr_t kMessageVtableRva = 0x8a100f0;
+constexpr std::size_t kMessageBytes = 0x540;
+constexpr std::size_t kMaximumTextBytes = 512 * 1024;
+
+constexpr std::size_t kWeakSelfOffset = 0x08;
+constexpr std::size_t kRecipientOffset = 0x90;
+constexpr std::size_t kRequestIdOffset = 0x4c8;
+constexpr std::size_t kTextOffset = 0x510;
+constexpr std::size_t kTrailingStringOffset = 0x528;
+
+struct alignas(8) OpaqueTask {
+ std::array bytes{};
+};
+
+struct alignas(8) OpaqueHandle {
+ std::array bytes{};
+};
+
+struct alignas(16) EmptyCallback {
+ std::array bytes{};
+};
+
+struct SourceLocation {
+ const char* file = nullptr;
+ const char* function = nullptr;
+ std::uint64_t line = 0;
+ const void* return_address = nullptr;
+};
+
+static_assert(sizeof(OpaqueTask) == 0x38);
+static_assert(sizeof(OpaqueHandle) == 0x28);
+static_assert(sizeof(EmptyCallback) == 0x20);
+static_assert(sizeof(SourceLocation) == 0x20);
+
+extern "C" void WechatBridgeInvokeOfficialTaskBuilder(
+ std::uintptr_t function,
+ void* output,
+ void* service,
+ void* messages,
+ bool single_message);
+extern "C" void WechatBridgeInvokeOfficialTaskSubmit(
+ std::uintptr_t function,
+ void* output,
+ const void* task,
+ const void* first_callback,
+ const void* second_callback,
+ const void* third_callback,
+ const void* source_location);
+
+struct alignas(16) MessageStorage {
+ MessageStorage() {
+ bytes.fill(0);
+ new (At>(kWeakSelfOffset)) std::weak_ptr();
+ new (At(kRecipientOffset)) std::string();
+ new (At(kRequestIdOffset)) std::string();
+ new (At(kTextOffset)) std::string();
+ new (At(kTrailingStringOffset)) std::string();
+ }
+
+ ~MessageStorage() {
+ At(kTrailingStringOffset)->~basic_string();
+ At(kTextOffset)->~basic_string();
+ At(kRequestIdOffset)->~basic_string();
+ At(kRecipientOffset)->~basic_string();
+ At>(kWeakSelfOffset)->~weak_ptr();
+ }
+
+ template
+ T* At(std::size_t offset) {
+ return reinterpret_cast(bytes.data() + offset);
+ }
+
+ std::array bytes{};
+};
+
+static_assert(sizeof(MessageStorage) == kMessageBytes);
+
+struct PendingOfficialSend {
+ std::mutex completion_mutex;
+ bool completed = false;
+ std::shared_ptr storage;
+ std::vector> messages;
+ OpaqueTask task;
+ OpaqueHandle handle;
+ EmptyCallback first_callback;
+ EmptyCallback second_callback;
+ EmptyCallback third_callback;
+ SourceLocation source_location;
+ SendCompletion completion;
+};
+
+std::mutex g_retained_mutex;
+std::vector> g_retained_sends;
+
+bool EndsWithChatroom(const std::string& username) {
+ static constexpr char kSuffix[] = "@chatroom";
+ constexpr std::size_t kSuffixSize = sizeof(kSuffix) - 1;
+ return username.size() >= kSuffixSize &&
+ username.compare(username.size() - kSuffixSize,
+ kSuffixSize, kSuffix) == 0;
+}
+
+template
+void Store(MessageStorage* storage, std::size_t offset, T value) {
+ std::memcpy(storage->bytes.data() + offset, &value, sizeof(value));
+}
+
+std::shared_ptr BuildMessage(
+ std::uintptr_t image_base,
+ const SendRequest& request) {
+ auto storage = std::make_shared();
+ Store(storage.get(), 0, image_base + kMessageVtableRva);
+ *storage->At(kRecipientOffset) = request.username;
+ *storage->At(kRequestIdOffset) = request.request_id;
+ *storage->At(kTextOffset) = request.text;
+ Store(storage.get(), 0x7c, 1);
+ Store(storage.get(), 0x84, 7);
+ Store(storage.get(), 0xb0, 1);
+ Store(storage.get(), 0x138, request.text.size());
+
+ std::shared_ptr alias(storage, storage.get());
+ *storage->At>(kWeakSelfOffset) = alias;
+ return storage;
+}
+
+void Retain(const std::shared_ptr& pending) {
+ std::lock_guard lock(g_retained_mutex);
+ g_retained_sends.push_back(pending);
+}
+
+void Complete(const std::shared_ptr& pending,
+ AckState ack_state) {
+ SendCompletion completion;
+ {
+ std::lock_guard lock(pending->completion_mutex);
+ if (pending->completed) {
+ return;
+ }
+ pending->completed = true;
+ completion = std::move(pending->completion);
+ }
+ if (completion) {
+ SendReceipt receipt;
+ receipt.ack_state = ack_state;
+ completion(std::move(receipt));
+ }
+}
+
+const void* SourceReturnAddress() {
+ const void* address = __builtin_return_address(0);
+ return address == nullptr
+ ? reinterpret_cast(&SourceReturnAddress)
+ : address;
+}
+
+class OfficialTaskAdapter final : public SendAdapter {
+ public:
+ explicit OfficialTaskAdapter(OfficialTaskBindings bindings)
+ : bindings_(std::move(bindings)) {}
+
+ void Send(const SendRequest& request, SendCompletion completion) override {
+ if (request.request_id.empty() || request.request_id.size() > 256 ||
+ request.text.empty() || request.text.size() > kMaximumTextBytes ||
+ !EndsWithChatroom(request.username)) {
+ if (completion) {
+ SendReceipt receipt;
+ receipt.ack_state = AckState::kUnknown;
+ completion(std::move(receipt));
+ }
+ return;
+ }
+
+ auto pending = std::make_shared();
+ pending->storage = BuildMessage(bindings_.image_base, request);
+ pending->messages.emplace_back(pending->storage,
+ pending->storage.get());
+ pending->source_location.file = "official_task_adapter.mm";
+ pending->source_location.function = "OfficialTaskAdapter::Send";
+ pending->source_location.line = __LINE__;
+ pending->source_location.return_address = SourceReturnAddress();
+ pending->completion = std::move(completion);
+
+ const OfficialTaskBindings bindings = bindings_;
+ bindings.main_dispatch([pending, bindings] {
+ // Builder/submit may hand ownership to asynchronous WeChat work. Keep
+ // every attempted send alive for the process lifetime rather than guess
+ // opaque destructors or retry an uncertain submission.
+ Retain(pending);
+ try {
+ WechatBridgeInvokeOfficialTaskBuilder(
+ bindings.builder_address, &pending->task,
+ bindings.builder_service, &pending->messages, true);
+ WechatBridgeInvokeOfficialTaskSubmit(
+ bindings.submit_address, &pending->handle, &pending->task,
+ &pending->first_callback, &pending->second_callback,
+ &pending->third_callback, &pending->source_location);
+ Complete(pending, AckState::kSubmitted);
+ } catch (...) {
+ Complete(pending, AckState::kUnknown);
+ }
+ });
+ }
+
+ private:
+ OfficialTaskBindings bindings_;
+};
+
+} // namespace
+
+std::shared_ptr CreateOfficialTaskAdapter(
+ OfficialTaskBindings bindings) {
+ if (bindings.image_base == 0 || bindings.builder_address == 0 ||
+ bindings.submit_address == 0 || !bindings.main_dispatch) {
+ return nullptr;
+ }
+ return std::make_shared(std::move(bindings));
+}
+
+} // namespace wechat_bridge
diff --git a/native/src/official_task_trampolines.S b/native/src/official_task_trampolines.S
new file mode 100644
index 0000000..0cd5c95
--- /dev/null
+++ b/native/src/official_task_trampolines.S
@@ -0,0 +1,22 @@
+.text
+.p2align 2
+
+.globl _WechatBridgeInvokeOfficialTaskBuilder
+_WechatBridgeInvokeOfficialTaskBuilder:
+ mov x9, x0
+ mov x8, x1
+ mov x0, x2
+ mov x1, x3
+ mov w2, w4
+ br x9
+
+.globl _WechatBridgeInvokeOfficialTaskSubmit
+_WechatBridgeInvokeOfficialTaskSubmit:
+ mov x9, x0
+ mov x8, x1
+ mov x0, x2
+ mov x1, x3
+ mov x2, x4
+ mov x3, x5
+ mov x4, x6
+ br x9
diff --git a/native/src/production_adapter.mm b/native/src/production_adapter.mm
new file mode 100644
index 0000000..4b5a994
--- /dev/null
+++ b/native/src/production_adapter.mm
@@ -0,0 +1,151 @@
+#include "wechat_bridge.hpp"
+
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace wechat_bridge {
+namespace {
+
+constexpr char kExpectedImagePath[] =
+ "/Applications/WeChat.app/Contents/Frameworks/wechat.dylib";
+constexpr std::uintptr_t kBuilderRva = 0x33729fc;
+constexpr std::uintptr_t kSubmitRva = 0x695e08;
+constexpr std::uintptr_t kMessageVtableRva = 0x8a100f0;
+constexpr std::uintptr_t kMessageDestructorRva = 0x2ce5520;
+constexpr std::uintptr_t kMessageDeletingDestructorRva = 0x4513d18;
+
+constexpr std::array kBuilderSignature = {
+ 0x29, 0xdb, 0x02, 0x90, 0x29, 0x55, 0x41, 0xf9,
+ 0x49, 0x00, 0x00, 0xb4, 0x20, 0x01, 0x1f, 0xd6,
+};
+constexpr std::array kSubmitSignature = {
+ 0xff, 0x03, 0x03, 0xd1, 0xf8, 0x5f, 0x08, 0xa9,
+ 0xf6, 0x57, 0x09, 0xa9, 0xf4, 0x4f, 0x0a, 0xa9,
+};
+constexpr std::array kExpectedUuid = {
+ 0xab, 0xd8, 0x8e, 0xc8, 0x45, 0x90, 0x3f, 0xdb,
+ 0xaa, 0xb5, 0x4e, 0x58, 0x65, 0xb8, 0x6b, 0x2a,
+};
+
+void SetError(std::string* error, const char* value) {
+ if (error != nullptr) {
+ *error = value;
+ }
+}
+
+std::uintptr_t FindPinnedImage() {
+ for (std::uint32_t index = 0; index < _dyld_image_count(); ++index) {
+ const char* name = _dyld_get_image_name(index);
+ if (name != nullptr && std::strcmp(name, kExpectedImagePath) == 0) {
+ return reinterpret_cast(
+ _dyld_get_image_header(index));
+ }
+ }
+ return 0;
+}
+
+bool UuidMatches(std::uintptr_t image_base) {
+ const auto* header = reinterpret_cast(image_base);
+ if (header == nullptr || header->magic != MH_MAGIC_64 ||
+ header->cputype != CPU_TYPE_ARM64 ||
+ header->sizeofcmds > 16 * 1024 * 1024) {
+ return false;
+ }
+ const auto* command_bytes = reinterpret_cast(
+ header + 1);
+ std::size_t offset = 0;
+ for (std::uint32_t index = 0; index < header->ncmds; ++index) {
+ if (offset + sizeof(load_command) > header->sizeofcmds) {
+ return false;
+ }
+ const auto* command = reinterpret_cast(
+ command_bytes + offset);
+ if (command->cmdsize < sizeof(load_command) ||
+ command->cmdsize > header->sizeofcmds - offset) {
+ return false;
+ }
+ if (command->cmd == LC_UUID) {
+ if (command->cmdsize < sizeof(uuid_command)) {
+ return false;
+ }
+ const auto* uuid = reinterpret_cast(command);
+ return std::memcmp(uuid->uuid, kExpectedUuid.data(),
+ kExpectedUuid.size()) == 0;
+ }
+ offset += command->cmdsize;
+ }
+ return false;
+}
+
+template
+bool SignatureMatches(std::uintptr_t address,
+ const std::array& signature) {
+ return address != 0 &&
+ std::memcmp(reinterpret_cast(address),
+ signature.data(), signature.size()) == 0;
+}
+
+bool MessageVtableMatches(std::uintptr_t image_base) {
+ std::array entries{};
+ std::memcpy(entries.data(),
+ reinterpret_cast(
+ image_base + kMessageVtableRva),
+ sizeof(entries));
+ return entries[0] == image_base + kMessageDestructorRva &&
+ entries[1] == image_base + kMessageDeletingDestructorRva;
+}
+
+} // namespace
+
+std::shared_ptr CreateProductionWeChatAdapter(
+ std::string* unsupported_reason) {
+ const std::uintptr_t image_base = FindPinnedImage();
+ if (image_base == 0) {
+ SetError(unsupported_reason, "unsupported_wechat_build_image");
+ return nullptr;
+ }
+ if (!UuidMatches(image_base)) {
+ SetError(unsupported_reason, "unsupported_wechat_build_uuid");
+ return nullptr;
+ }
+ if (!SignatureMatches(image_base + kBuilderRva, kBuilderSignature)) {
+ SetError(unsupported_reason, "unsupported_wechat_build_builder");
+ return nullptr;
+ }
+ if (!SignatureMatches(image_base + kSubmitRva, kSubmitSignature)) {
+ SetError(unsupported_reason, "unsupported_wechat_build_submit");
+ return nullptr;
+ }
+ if (!MessageVtableMatches(image_base)) {
+ SetError(unsupported_reason, "unsupported_wechat_build_message_vtable");
+ return nullptr;
+ }
+
+ OfficialTaskBindings bindings;
+ bindings.image_base = image_base;
+ bindings.builder_address = image_base + kBuilderRva;
+ bindings.submit_address = image_base + kSubmitRva;
+ bindings.main_dispatch = [](std::function work) {
+ dispatch_async(dispatch_get_main_queue(), ^{
+ work();
+ });
+ };
+ auto adapter = CreateOfficialTaskAdapter(std::move(bindings));
+ if (adapter == nullptr) {
+ SetError(unsupported_reason, "unsupported_wechat_official_task_adapter");
+ } else if (unsupported_reason != nullptr) {
+ unsupported_reason->clear();
+ }
+ return adapter;
+}
+
+} // namespace wechat_bridge
diff --git a/native/src/wechat_adapter.mm b/native/src/wechat_adapter.mm
new file mode 100644
index 0000000..68c2e24
--- /dev/null
+++ b/native/src/wechat_adapter.mm
@@ -0,0 +1,540 @@
+#include "wechat_bridge.hpp"
+
+#import
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include