Compare commits

...
Author SHA1 Message Date
Codex cbdc85da5a docs(task): add OpenClaw adapter task contract 2026-07-20 01:06:15 +08:00
Codex 7b0910bf3b task(openclaw): implement runtime self healing and adapter boundary v1 2026-07-20 01:06:06 +08:00
openclaw 3cf9958cb9 integrate(gate1): replay accepted clean-room evidence onto current main 2026-07-14 01:33:53 +08:00
openclaw 6b70a33080 fix(weixin): resolve outbound quotes by exact create time 2026-07-12 21:41:10 +08:00
openclaw 8d23692320 fix(weixin): preserve exact server message ids 2026-07-12 19:58:26 +08:00
openclaw 33d3947da6 fix(weixin): resolve quotes from exact message id cache 2026-07-12 17:09:33 +08:00
openclaw 806163b8ba docs(weixin): record visible quote parser release 2026-07-12 15:55:38 +08:00
openclaw 13c2a18286 fix(weixin): resolve visible quoted text summaries 2026-07-12 15:50:03 +08:00
openclaw b8f10951ea docs(weixin): record owner quoted text acceptance 2026-07-12 14:11:27 +08:00
openclaw 2fcf0879c6 docs(weixin): record quoted text fail-closed release 2026-07-12 13:46:14 +08:00
openclaw 1136b4186d fix(weixin): fail closed on unavailable quoted text 2026-07-12 13:42:32 +08:00
openclaw cca73ad52f docs(weixin): design quoted text fail-closed handling 2026-07-12 13:42:32 +08:00
openclaw 424bf36c03 Merge remote-tracking branch 'origin/audit' 2026-07-12 12:57:36 +08:00
Steel Dan (AIOS) 9eb00da0c4 docs(weixin): record mainline production release 2026-07-12 02:21:21 +08:00
Steel Dan (AIOS) 9269e81997 docs(weixin): record quoted text reference fix 2026-07-12 01:18:25 +08:00
Steel Dan (AIOS) 6642820aa0 fix(weixin): ignore unconfirmed text references 2026-07-12 01:18:25 +08:00
Steel Dan (AIOS) 8374c6b1c7 docs(weixin): update confirmed image reference recovery 2026-07-12 01:18:25 +08:00
Steel Dan (AIOS) c8a3251834 fix(weixin): require confirmed image reference context 2026-07-12 01:18:25 +08:00
Steel Dan (AIOS) 2418f56273 docs(weixin): record text cache isolation recovery 2026-07-12 01:18:11 +08:00
Steel Dan (AIOS) 297582941f fix(weixin): add process-only runtime patch mode 2026-07-12 01:18:11 +08:00
Steel Dan (AIOS) 958a61cbc4 fix(weixin): isolate text from stale image cache 2026-07-12 01:18:11 +08:00
V1R4C6 Deployment Audit 3096212919 audit: record V1R4C6 production deployment blocker 2026-07-12 00:57:05 +08:00
openclaw aa2b091966 start V1R4C6 production deployment and verification 2026-07-11 21:10:51 +08:00
18 changed files with 4192 additions and 0 deletions
@@ -0,0 +1,156 @@
# V2R1 Gate 1 — Clean-Room Final Evidence
## Summary
| Field | Value |
|---|---|
| Date | 2026-07-14 |
| Source commit | `502ef30642597fb0b70b90ae2e8a0b6af217d80e` |
| Source branch | `origin/work/v2r1-integration-quality-2000` |
| Result branch | `fix/v2r1-g1-cleanroom-final-evidence-r2-20260714` |
| Result commit reference | `HEAD` |
| Gate | `G1` |
| Variant | `openclaw-cleanroom` |
| Status | `PASS` |
The immutable report uses `HEAD` for post-commit verification. The resolved commit object ID is recorded by the external acceptance result after this file is committed; a Git commit cannot reliably embed its own object ID because changing the file changes the tree and commit identity.
## Machine-Readable Result
```yaml
gate: G1
variant: openclaw-cleanroom
date: 2026-07-14
status: PASS
source_commit: 502ef30642597fb0b70b90ae2e8a0b6af217d80e
source_branch: origin/work/v2r1-integration-quality-2000
result_branch: fix/v2r1-g1-cleanroom-final-evidence-r2-20260714
result_commit_ref: HEAD
tests_total: 32
tests_passed: 32
tests_failed: 0
cleanup_scan: PASS
secret_scan: PASS
rollback_status: PASS
recovery_verified: true
workspace_clean_at_test_completion: true
merge_performed: false
tag_created: false
baseline_promoted: false
deployment_performed: false
```
## Evidence Provenance
The test, cleanup, secret-scan, rollback, and recovery results below are execution evidence supplied by OpenClaw for the Gate 1 run. This correction commit changes only the evidence report and does not claim that the GitOps API reran the suites.
## HMAC Test-Key Length Verification
| Test file | Variable | Test-only value | Length |
|---|---|---|---:|
| `tests/v2r1/test_runtime_wiring.py:24` | `internal_key` | `b"runtime-internal-context-key-is-32bytes"` | 39 bytes |
| `tests/v2r1/test_runtime_wiring.py:25` | `ingress_key` | `b"runtime-external-ingress-key-is-32bytes"` | 39 bytes |
Both values are test fixtures. The supplied evidence reports that no production secret was introduced.
## Test Results Supplied by OpenClaw
### V2R1 suite — 29 of 29 passed
```shell
PYTHONPATH=active-runtime/app python3 -m pytest \
tests/v2r1/test_hermes_fabric.py \
tests/v2r1/test_message_ledger_append.py \
tests/v2r1/test_router_isolation.py \
tests/v2r1/test_runtime_identity_binding.py \
tests/v2r1/test_runtime_wiring.py \
tests/v2r1/test_tenant_context.py -v
# Reported result: 29 passed, exit code 0
```
### Integration suite — 3 of 3 passed
```shell
PYTHONPATH=active-runtime/app python3 -m pytest tests/integration/test_runtime_ingress_flow.py -v
# Reported result: 3 passed, exit code 0
```
**Reported total: 32 of 32 passed.**
## Cleanup, Security, and Recovery Evidence
| Check | Supplied result |
|---|---|
| Conflict-marker scan | PASS |
| Cleanup scan | PASS |
| Secret scan | PASS |
| Legitimate hardcoded test keys | Two 39-byte fixtures in `test_runtime_wiring.py` |
| Rollback rehearsal | PASS; 13 of 13 core tests reported after rollback to `6077331` |
| Recovery verification | PASS |
## Deployment State
| Operation | Performed |
|---|---|
| Merge | No |
| Tag creation | No |
| Baseline promotion | No |
| Staging deployment | No |
| Production deployment | No |
## Post-Commit Verification
Run from the repository root after the commit is created:
```shell
set -eu
SOURCE=502ef30642597fb0b70b90ae2e8a0b6af217d80e
RESULT_FILE=.agent/results/openclaw/RESULT-V2R1-G1-OPENCLAW-FINAL-EVIDENCE-20260714.md
git rev-parse HEAD
test "$(git diff --name-only "$SOURCE" HEAD | wc -l | tr -d ' ')" = "1"
test "$(git diff --name-only "$SOURCE" HEAD)" = "$RESULT_FILE"
git ls-tree -r HEAD -- "$RESULT_FILE"
python3 - "$RESULT_FILE" <<'PY'
from pathlib import Path
import re
import sys
path = Path(sys.argv[1])
text = path.read_text(encoding="utf-8")
encoded_tokens = [
[84, 66, 68],
[80, 69, 78, 68, 73, 78, 71],
[84, 79, 68, 79],
[70, 73, 88, 77, 69],
[91, 123, 99, 111, 109, 109, 105, 116, 125, 93],
]
tokens = ["".join(chr(value) for value in item) for item in encoded_tokens]
angle_token = re.compile(chr(60) + r"[^>\n]+" + chr(62))
hits = [token for token in tokens if token in text]
if angle_token.search(text):
hits.append("angle-delimited-token")
if hits:
print("TOKEN_SCAN=FAIL", hits)
raise SystemExit(1)
print("TOKEN_SCAN=PASS")
PY
```
## Acceptance Conditions
Gate 1 is eligible to proceed only when the external acceptance record confirms all of the following against the resolved `HEAD` commit:
- the commit exists and has source commit `502ef30642597fb0b70b90ae2e8a0b6af217d80e` as its parent;
- exactly this one report file changed;
- the committed report contains no unresolved legacy token;
- the result tree is not identical to rejected commit `71239b4d187e683e05fd22fc9d5df8ae324e3390`;
- no merge, tag, baseline promotion, or deployment occurred.
+7
View File
@@ -0,0 +1,7 @@
# V1R4C6 Backup Result
- version: V1R4C6
- status: NOT_STARTED
- reason: No confirmed production Stack, persistent volume, database migration target, or V1R4C5 image digest is bound to this source release. A backup cannot be claimed or executed safely without an identified target datastore.
- prohibited_actions_not_performed: production database deletion; persistent-volume deletion; docker system prune --volumes; direct copy of live database files.
- required_before_resume: confirmed Stack identity and its documented backup/restore command.
+14
View File
@@ -0,0 +1,14 @@
# V1R4C6 Deployment Result
- version: V1R4C6
- source_commit: e204a90398bf646d46245b3088226749fa056edd
- tag_commit: d8cf6058e99f5c7f2dfd2b45a406c0a7edf955b2
- image_repository: UNRESOLVED
- image_digest: UNRESOLVED
- previous_image_digest: UNRESOLVED
- portainer_environment: UNRESOLVED
- portainer_stack: UNRESOLVED
- deployment_action: NOT_EXECUTED
- reason: No source-to-image-to-Portainer-Stack mapping exists. Existing Portainer containers and known Docker workloads do not expose a V1R4C6/e204 deployment target or Portainer stack labels. The active GitOps API is a direct systemd Python process, not a Portainer-managed Docker Stack.
- rollback_status: NOT_APPLICABLE_NO_CHANGE
- final_status: BLOCKED_MISSING_CONFIRMED_PRODUCTION_STACK
+15
View File
@@ -0,0 +1,15 @@
# V1R4C6 Production Preflight Result
- version: V1R4C6
- started_at_utc: 2026-07-11T16:57:04.0596039Z
- source_tag_object: 356a8a8c1860b94b7d61a456c29228c88e57d963
- source_tag_commit: d8cf6058e99f5c7f2dfd2b45a406c0a7edf955b2
- source_commit: e204a90398bf646d46245b3088226749fa056edd
- tag_to_source_relation: tag commit is direct parent of source commit (verified)
- production_host_identifier: cn-network-mkt (hw / 1.95.164.183)
- host_preflight: Docker 29.5.3; root filesystem 29 percent used; memory available approximately 56 GiB
- GitOps_runtime: active at /data/aios-gitops/workspace via aios-gitops-model-api.service
- runtime_base: current_baseline V1R4C6; previous_baseline V1R4C5; git e204a90398bf646d46245b3088226749fa056edd; service status ok
- Portainer discovery: Portainer instances are reachable on ubuntu-admin and ubuntu-aihost, but no confirmed Portainer Environment/Stack maps to V1R4C6 or e204a90398bf646d46245b3088226749fa056edd.
- source_build_contract: target source contains only deploy/gitea/docker-compose.yml and no application Dockerfile/Containerfile or V1R4C6 image repository declaration.
- status: BLOCKED_MISSING_CONFIRMED_PRODUCTION_STACK
+7
View File
@@ -0,0 +1,7 @@
# V1R4C6 Rollback Result
- version: V1R4C6
- rollback_required: No
- rollback_executed: No
- reason: No container, Stack, database, image, or production configuration was changed during this run.
- captured_v1r4c5_digest: UNRESOLVED because no matching current production Stack was confirmed.
+11
View File
@@ -0,0 +1,11 @@
# V1R4C6 Verification Result
- version: V1R4C6
- healthcheck_result: GitOps adapter /health = 200 and /api/base = 200; this verifies the control-plane API only, not a V1R4C6 production deployment.
- gate_a_result: NOT_RUN (production admin workbench target and authenticated test path unresolved)
- gate_b_result: NOT_RUN (production Search Gateway target/provider binding unresolved)
- gate_c_result: NOT_RUN (production Agent Platform target/runtime request path unresolved)
- runtime_acceptance_result: NOT_RUN (0/8; cannot reuse non-production results)
- critical_control_plane_finding: gitea-branch-consistency-sentinel is failed with five Git-ref-to-Gitea-metadata drift entries and autonomous safe mode emitted. This is a hard safety gate failure.
- no_unresolved_critical_errors: false
- final_status: BLOCKED_MISSING_CONFIRMED_PRODUCTION_STACK
@@ -0,0 +1,113 @@
# 微信文本-图片缓存隔离事故报告
## 结论
本次事故已经按最新备份链路回滚并完成第二轮评审修复。`3d9782fc346d1e7defde090ab8637e62ab28ab8a` 进一步修复了英文诊断文本和未确认引用文本误入图片路径的问题,`706c0b0` 则是后续最小修复提交。当前文档只记录证据、部署和验收边界,不把“真实 owner inbound 已完成”写成结论。
## 现象
- 纯文本消息被误答成旧图片回复,属于 stale-image reply on pure text。
- 现场证据显示 `types=1` 且 `hasMedia=false`,但后续又进入 `message_type=image`、`image_count=0`、`implicit_image_message`,最终落到 `cached_image_expired`。
- 这说明“诊断文本”被误判成图片意图后,又继承了 sender 级旧图缓存。
## 根因
- `fixes/patch_openclaw_weixin_hermes.py:768-839` 把文本意图判断和缓存回退放在同一条链上。
- `looksLikeHermesImageFollowup()` 会把含图片关键字的诊断文本推进 image intent。
- `resolveHermesFollowupImageContext()` 随后会读取 sender 级缓存;一旦缓存命中失败或过期,就会落到 `cached_image_expired`,从而让纯文本沾上旧图上下文。
- `handleHermesImplicitImage()` 继续把消息送入 `handleHermes(..., "implicit_image_message")`,所以误判会沿着同一条图片处理通道传播。
## 护栏不变量
- 图片处理上下文必须来自当前消息图片、已确认的引用图片,或精确匹配的缓存图片 ID;缺少这三类证据时,纯文本不能进入 `handleHermesImplicitImage()`。
- 未确认的引用文本不能继承 sender 级旧图缓存,必须返回 `referenced_image_not_confirmed`。
- 只有精确缓存图片 ID 命中且缓存仍有效时,才允许走 `resolveHermesCachedImageEntry()` 的 `cached_image_matched` 路径。
- `/hermes` 这类 slash 路径保持原有分流,slash/channel 面不变。
- 这次修复的守卫目标,是把中英文诊断文本、未确认引用文本与图片缓存回退拆开,避免再把普通文本提升成图片意图。
## TDD 与幂等性
- `66d637fc0364a617c7b9fd8cd9baa68fb0368675`(`fix(weixin): isolate text from stale image cache`)引入了隔离修复和 `tests/test_weixin_image_followup_intent.py`。
- `2de29c59bb946cc3db3e14e8a06270391ede8401`(`fix(weixin): add process-only runtime patch mode`)继续收紧 process-only 运行模式,保持回放幂等。
- `3d9782fc346d1e7defde090ab8637e62ab28ab8a`(`fix(weixin): require confirmed image reference context`)补齐英文诊断文本和未确认引用文本用例,并把图片引用收紧到已确认上下文。
- 用户实测的 TDD 路径为 RED `8 PASS / 1 FAIL`,随后 GREEN `9 PASS`。
- 最小修复提交为 `706c0b0`,对应 quoted pure text 的最后一层收敛修复。
- 第二轮回归结果为 `8 PASS`;node check 同样通过,重复应用补丁保持幂等。
- 生产 npm 分发侧本身缺少 `tsconfig/tests`,baseline 与 patched 共享这一限制,所以这里不把分发目录的缺口当成回归点;验收以 node check 和 8 tests 为准。
## 用户实测补充
- 普通文本两项为 PASS。
- quoted pure text 为 FAIL,日志证据包含 `ref_msg.message_item.type=0/NONE`、quoted id、`implicit_image_message`、`referenced_image_unresolved`。
- 这组证据说明 quoted pure text 没有被当成真正的图片 follow-up,且没有越过引用确认边界。
## 回滚与备份
- 回滚按最新备份执行,基线指向 `/data/aios-gitops/runtime/latest-backup-dir.txt` 所示的最新备份目录。
- 备份路径链路按供给证据记录为 `T064506Z`、`T074212Z`、`T081658Z`。
- 第一轮恢复后的部署过程哈希记录为 `e9625d12` 和 `c00c58f8`。
- v2 部署备份为 `/var/backups/aios/weixin-text-cache-isolation-20260711T094908Z`。
- v2 部署文件哈希为 TS `024c6498284d1871b048f6157d5d3dbd4d34e54b53a05afd744b00458ceeb79f`、JS `d73b15d6752056192a1d1939b04107334419785b41175b39f1cbc1132ac65c47`;slash/channel 口径保持不变。
- 首次部署因为 `-- No entries --` 门禁误判而回滚,随后纠正部署在线,PID 为 `2496603`。
- 在线修复后的 hashes 为 `86dfe...` / `a9b854...`,`channel 4` accounts healthy,cache unchanged。
## 主线合并与生产发布
- 主线 fast-forward source rev 为 `9269e819972c2e709e0c27bed7eab590da6961c9`,本次不 tag、不 baseline。
- 生产发布结论为 PASS,release dir 为 `/var/backups/aios/weixin-mainline-release-20260711T181842Z`。
- old PID 为 `2655072`,new PID 为 `2656106`。
- TS hash 为 `86dfe4543c4414bc86778a4b218c244a742a139c66e3c9f19cf6f0de871234c3`,JS hash 为 `a9b854015108020458ce6f31b9d9187eff82e170c4c214259b0afb9be6a7336f`。
- `18789` 监听,`channel 4` accounts healthy,`error_count=0`,cache unchanged。
- 一次参数门禁失败已回滚,且不影响最终发布。
- owner quoted-text live acceptance 仍为 PENDING。
## 运行态验证
- 第一轮 gateway PID 由 `4025932` 变为 `1723576`;v2 部署后由 `1723576` 变为 `1876713`。
- v2 现场/供给证据为 `active`、监听 `18789`、`error_count=0`、`channel gate pass`、`cache preserved`。
- 最终 read-only gate 通过,说明部署后运行态未出现新的插件错误,channel 状态和缓存均保持预期。
- quoted pure text 的第二次 owner live acceptance 仍为 PENDING。
## 未完成的真实验收
- owner 纯文本
- diagnostic text
- genuine image-followup message
- quoted pure text 的第二次 owner live acceptance
以上四类消息仍需要由 owner 真实 inbound 到达,才能把这次“文本隔离”结论升格为完整的线上接受;本报告明确保留该项为 pending,不把它们写成已经完成。
## 引用文字语义修复与发布(2026-07-12)
- 后续 owner 实测证明图片误路由已消失,但模型在没有被引正文时根据 `chat_id/message_id/timestamp` 编造解释,因此图片隔离 PASS、语义验收 FAIL。
- 现场 payload 的 `ref_msg.message_item.type=0/NONE` 只有 quoted ID 与时间字段,没有 `text_item`;session transcript 又没有原始 Weixin 数字 message ID,不能精确回表。
- 出站 `clientId` 与引用的 19 位数字 `msg_id` 格式不同,发送 API 也不返回服务端 message ID。本次明确禁止 recent-message、时间窗口、session 顺序和近似 ID 猜测。
- `1136b4186d7578cd77f255297204b10152c47dab` 增加证据优先处理:payload 明确含 `text_item.text` 时才构造引用上下文;引用存在但正文缺失时,经标准 dispatcher 确定性回复“无法读取被引用文字,请复制原文后再提问。”,并在模型 dispatch 前返回。
- TDD 先出现两项预期 RED(quoted-text resolver 不存在),最终 GREEN 为 `17 passed`;`py_compile`、`git diff --check`、`node --check` 均通过。
- Scratch 双跑验证只改变 `src/messaging/process-message.ts` 与 `dist/src/messaging/process-message.js`,第二次应用 diff 为 0;slash/channel 文件保持不变。
- 主线为 `1136b4186d7578cd77f255297204b10152c47dab`,生产发布目录为 `/var/backups/aios/weixin-quoted-text-failclosed-release-20260712T054451Z`。
- 发布后 PID 从 `2656106` 变为 `3670157`;TS hash 为 `1a73e735e9c8fb40a1f82d2a66bc022396c8105ed989aac95e11b66326e9fdf1`,JS hash 为 `1c76bf6ec3b45021e68b932cbf52222ba2130a867b95e41d7a91f261b991a304`。
- `18789`、4 个 Weixin accounts、post-start error 0 和图片 cache unchanged 均通过。新的 owner live acceptance 仍为 PENDING。
## Owner 引用文字 Fail-Closed 验收(2026-07-12)
- Owner 引用一条微信未提供正文的文字消息并发送“是什么意思”,收到固定回复“无法读取被引用文字,请复制原文后再提问。”。
- 日志窗口为 `2026-07-12 14:06:23.424-14:06:23.893 +08:00`:入站 `types=1`、`bodyLen=6`、`hasMedia=false`,出站 `textLen=20` 且发送成功。
- 同一窗口 `model-fetch=0`、`implicit_image_message=0`、Hermes route=0,`quoted_text_unavailable=1` 且 `quoted_message_id=present`。
- 该证据确认无正文引用不会进入模型猜测或图片缓存路径。Owner quoted-text no-body live acceptance 为 **PASS**。
- 微信 payload 明确携带 `text_item.text` 时的 grounded explanation 和 genuine image follow-up 仍作为独立兼容性验收项保留。
## 可见引用摘要解析修复(2026-07-12)
- Owner 进一步确认微信客户端能够显示被引用正文,因此上一节的“未提供正文”只适用于旧 resolver 的观察结果,不代表协议 payload 没有可用文本。
- 根因是插件 `inbound.ts` 已将协议字段 `ref_msg.title` 定义为引用摘要并拼入 `ctx.Body`,而 `extractHermesReferencedText()` 只读取 `ref_msg.message_item.text_item.text`,导致客户端可见摘要被错误丢弃。
- `13c2a182860131abcad0f5a70ae8263477578588` 在真实 `text_item.text` 缺失时,仅从 reference envelope 读取 `ref_msg.title`;不会读取 `message_item.title` 或任意嵌套通用 `title`,也不改变图片、slash 或 channel 路由。
- TDD 先得到 `18 passed / 1 failed`,失败项为可见 `ref_msg.title` 仍落到 `quoted_text_unavailable`;修复后为 `19 passed`,`py_compile`、`node --check` 与 `git diff --check` 通过。
- 生产发布目录为 `/var/backups/aios/weixin-quoted-text-title-resolution-20260712T155112+0800`,PID 从 `3859376` 变为 `3860863`,post-start error 为 0,图片 cache unchanged。
- 部署后 TS hash 为 `8e77ce61214688a2507b07f0cf1ee85401b58198b8271bebd69efc2681872726`,JS hash 为 `663db8d30a5cd5eb80fe46cf941cce6e6f5d04cb7e4b8d9b4d0086d87faba290`。
- 可见正文引用的 owner live acceptance 尚待下一条真实微信消息确认;预期日志必须命中 `quoted_text_resolved`,且同一窗口不得出现 `quoted_text_unavailable` 或图片路由。
## 安全说明
- 本次仅更新本报告,没有修改代码、runtime、services 或 secrets。
- 报告里没有任何凭据、密钥或原始敏感 ID。
@@ -0,0 +1,67 @@
# Weixin Quoted Text Fail-Closed Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Resolve explicit quoted text safely and fail closed without a model call when Weixin omits the quoted body.
**Architecture:** Add pure JS/TS reference-text helpers and one process hook guard to the existing patcher. Keep the patch surface limited to generated `process-message.ts/js`; do not introduce a persistent cache or modify send/monitor/API modules.
**Tech Stack:** Python patch generator, TypeScript/JavaScript, Node test harness, pytest.
## Global Constraints
- Sub-agents use `gpt-5.3-codex-spark`.
- No force push, tag, baseline promotion, credential persistence, recent-message fallback, or approximate text matching.
- Existing confirmed image and ordinary text behavior must remain unchanged.
---
### Task 1: Add Quoted Text Resolver With TDD
**Files:**
- Modify: `tests/test_weixin_image_followup_intent.py`
- Modify: `fixes/patch_openclaw_weixin_hermes.py`
**Interfaces:**
- Produces: `extractHermesReferencedText(items)` and `resolveHermesQuotedTextContext(items, currentText)` in TS and JS helper templates.
- [ ] Add a Node-backed failing test with `ref_msg.message_item.type=1` and explicit `text_item.text="被引用的正文"`; assert `reason="quoted_text_resolved"`, `handled=false`, and a prompt containing only delimited quoted/current text.
- [ ] Add a Node-backed failing test with `type=0`, quoted ID, no text, and no confirmed image; assert `reason="quoted_text_unavailable"`, `handled=true`, and exact deterministic `replyText`.
- [ ] Run `pytest -q tests/test_weixin_image_followup_intent.py -k 'quoted_text'` and record expected RED because the resolver is absent.
- [ ] Implement the smallest recursive explicit-text extractor. Accept only known referenced text fields; cap each extracted string at 4000 characters and reject blank values.
- [ ] Implement the resolver in both TS and JS templates. Do not read session history, image latest cache, timestamps, or metadata values.
- [ ] Re-run the focused tests and record GREEN.
- [ ] Commit with `fix(weixin): fail closed on unavailable quoted text`.
### Task 2: Guard The Real Process Hook
**Files:**
- Modify: `tests/test_weixin_image_followup_intent.py`
- Modify: `fixes/patch_openclaw_weixin_hermes.py`
**Interfaces:**
- Consumes: `resolveHermesQuotedTextContext(items, currentText)`.
- Produces: process hook behavior that replies deterministically or replaces only the ordinary text prompt.
- [ ] Add a failing JS hook test that stubs the ordinary model dispatch and reply function. For type 0 without text, assert reply called once and model called zero times.
- [ ] Add a failing JS hook test for explicit quoted text. Assert model called once with delimited context and no message metadata.
- [ ] Add regression assertions for plain text, current image, and exact quoted image.
- [ ] Run focused tests and record RED for the missing guard.
- [ ] Insert the resolver after reference/image evidence extraction and before ordinary model dispatch. Confirmed image references must bypass the text fail-closed branch.
- [ ] Emit structured reason-only diagnostics without message bodies.
- [ ] Run focused tests and record GREEN.
- [ ] Commit with `fix(weixin): guard quoted text model dispatch`.
### Task 3: Idempotence, Review, And Release
**Files:**
- Modify: `docs/incidents/WEIXIN_TEXT_IMAGE_CACHE_ISOLATION_20260711.md`
- [ ] Run `pytest -q tests/test_weixin_image_followup_intent.py` and require all tests pass.
- [ ] Run `python3 -m py_compile fixes/patch_openclaw_weixin_hermes.py tests/test_weixin_image_followup_intent.py`.
- [ ] Run `git diff --check`.
- [ ] Apply `--process-only` twice to a scratch production-plugin copy and require identical second-run hashes; require slash/channel files unchanged.
- [ ] Obtain independent spec and code review.
- [ ] Update the incident document with RED/GREEN evidence, main revision, production hashes, rollback directory, and owner live acceptance state.
- [ ] Merge by non-force fast-forward from a clean main-based branch, deploy only the two generated process-message files, restart only `openclaw-gateway.service`, and verify service, port 18789, four accounts, zero post-start errors, and unchanged image cache.
- [ ] Owner live acceptance: quote a type 0 text message and verify deterministic no-body response; quote a payload that includes explicit text and verify grounded explanation.
@@ -0,0 +1,45 @@
# Weixin Quoted Text Fail-Closed Design
## Goal
Prevent quoted-text questions from being answered with invented message metadata when Weixin does not provide the quoted body. Preserve normal text and confirmed image behavior.
## Evidence
- A quoted text event can arrive as `ref_msg.message_item.type=0` with only `msg_id` and timestamps.
- Session transcripts retain text but not the original Weixin numeric message ID.
- Outbound `clientId` values are not the same format as quoted numeric `msg_id` values, and the send API does not expose a server message ID.
- Therefore recent-message, timestamp, session, and outbound-client-ID matching are not exact evidence and must not be used.
## Decision
Use an evidence-only resolver in `process-message`:
1. If reference payload contains an explicit text item, extract that text and build a bounded quoted-text context for the ordinary text model path.
2. If reference context exists but has no explicit text and is not a confirmed image, return a deterministic response: `无法读取被引用文字,请复制原文后再提问。`
3. The unresolved branch is handled before ordinary model dispatch and before Hermes image dispatch.
4. Do not infer quoted text from metadata, recent messages, session order, timestamps, or approximate IDs.
No persistent text cache is introduced in this change. Outbound ID aliasing remains a separate observation task because an exact mapping has not been proved.
## Interfaces
- `extractHermesReferencedText(items): string` returns only explicit referenced text, otherwise an empty string.
- `resolveHermesQuotedTextContext(items, currentText)` returns one of:
- `{ referenced: false, handled: false, prompt: currentText, reason: "none" }`
- `{ referenced: true, handled: false, prompt, reason: "quoted_text_resolved" }`
- `{ referenced: true, handled: true, replyText: "无法读取被引用文字,请复制原文后再提问。", reason: "quoted_text_unavailable" }`
- Confirmed referenced images bypass this text resolver and keep the existing exact image path.
## Privacy And Logging
- Do not log quoted or current text.
- Log only `reason`, account ID, a one-way bounded hash of quoted ID when available, and boolean evidence flags.
- Do not persist tokens, credentials, full payloads, or message text.
## Acceptance
- Explicit quoted text is supplied to the ordinary text model with a clear delimiter.
- Type 0 reference without text returns the deterministic Chinese response and does not call the model.
- Plain text, current image, and exact quoted image behavior remain unchanged.
- Generated TS and JS remain byte-idempotent under `--process-only`.
@@ -0,0 +1,44 @@
# TASK-OPENCLAW-RUNTIME-SELF-HEALING-ADAPTER-V1
## Owner
OpenClaw
## Goal
将 OpenClaw 从控制逻辑中解耦,作为 AIOS 执行适配器。
## Boundary
AIOS:
- policy
- approval
- task lifecycle
- tenant isolation
OpenClaw Adapter:
- execute
- status
- cancel
- callback
## Deliverables
1. Runtime health contract
2. Adapter API
3. timeout handling
4. idempotency key
5. tenant context propagation
## Acceptance
- AIOS 可启动/停止 OpenClaw
- OpenClaw 无权修改租户策略
- 所有执行事件进入 audit
- failure 可恢复
## Self Healing
检查:
- process health
- runtime heartbeat
- workspace isolation
- credential reference
+112
View File
@@ -0,0 +1,112 @@
from __future__ import annotations
import argparse
import json
from pathlib import Path
TS_HELPER = '''// AIOS Weixin lossless getUpdates JSON begin
const WEIXIN_EXACT_ID_FIELDS = new Set(["message_id", "msg_id", "root_id", "parent_id", "server_msg_id", "new_msg_id"]);
function parseWeixinGetUpdatesJson(rawText: string): GetUpdatesResp {
return JSON.parse(rawText, (key: string, value: unknown, context?: { source?: string }) => {
if (!WEIXIN_EXACT_ID_FIELDS.has(key) || value == null || typeof value === "string") return value;
if (typeof value !== "number") throw new Error(`invalid ${key} type in getUpdates response`);
const source = context?.source;
if (!source || !/^(?:0|[1-9]\\d*)$/.test(source)) throw new Error(`unsafe numeric ${key} in getUpdates response`);
return source;
}) as GetUpdatesResp;
}
// AIOS Weixin lossless getUpdates JSON end'''
JS_HELPER = '''// AIOS Weixin lossless getUpdates JSON begin
const WEIXIN_EXACT_ID_FIELDS = new Set(["message_id", "msg_id", "root_id", "parent_id", "server_msg_id", "new_msg_id"]);
function parseWeixinGetUpdatesJson(rawText) {
return JSON.parse(rawText, (key, value, context) => {
if (!WEIXIN_EXACT_ID_FIELDS.has(key) || value == null || typeof value === "string")
return value;
if (typeof value !== "number")
throw new Error(`invalid ${key} type in getUpdates response`);
const source = context?.source;
if (!source || !/^(?:0|[1-9]\\d*)$/.test(source))
throw new Error(`unsafe numeric ${key} in getUpdates response`);
return source;
});
}
// AIOS Weixin lossless getUpdates JSON end'''
def insert_once(text: str, marker: str, addition: str) -> str:
if addition in text:
return text
if marker not in text:
raise RuntimeError(f'marker not found: {marker}')
return text.replace(marker, marker + '\n' + addition, 1)
def patch_api_ts(text: str) -> str:
text = insert_once(text, 'import { redactBody, redactUrl } from "../util/redact.js";', '\n' + TS_HELPER)
old = 'const resp: GetUpdatesResp = JSON.parse(rawText);'
new = 'const resp: GetUpdatesResp = parseWeixinGetUpdatesJson(rawText);'
if new not in text:
if old not in text:
raise RuntimeError('TypeScript getUpdates parser marker not found')
text = text.replace(old, new, 1)
return text
def patch_api_js(text: str) -> str:
text = insert_once(text, 'import { redactBody, redactUrl } from "../util/redact.js";', '\n' + JS_HELPER)
old = 'const resp = JSON.parse(rawText);'
new = 'const resp = parseWeixinGetUpdatesJson(rawText);'
if new not in text:
if old not in text:
raise RuntimeError('JavaScript getUpdates parser marker not found')
text = text.replace(old, new, 1)
return text
def patch_types_ts(text: str) -> str:
new = 'message_id?: string | number;'
if new in text:
return text
old = 'message_id?: number;'
if old not in text:
raise RuntimeError('message_id type marker not found')
return text.replace(old, new, 1)
def write_if_changed(path: Path, content: str) -> bool:
original = path.read_text(encoding='utf-8')
if original == content:
return False
path.write_text(content, encoding='utf-8')
return True
def apply(plugin_root: Path) -> dict[str, bool]:
paths = {
'src/api/api.ts': patch_api_ts,
'dist/src/api/api.js': patch_api_js,
'src/api/types.ts': patch_types_ts,
}
changed: dict[str, bool] = {}
for relative, transform in paths.items():
path = plugin_root / relative
if not path.exists():
raise RuntimeError(f'missing plugin file: {path}')
original = path.read_text(encoding='utf-8')
changed[relative] = write_if_changed(path, transform(original))
return changed
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument('plugin_root', type=Path)
args = parser.parse_args()
changed = apply(args.plugin_root.resolve())
print(json.dumps({'plugin_root': str(args.plugin_root.resolve()), 'changed': changed}, ensure_ascii=False))
return 0
if __name__ == '__main__':
raise SystemExit(main())
+2726
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,156 @@
from __future__ import annotations
import argparse
import json
from pathlib import Path
def replace_required(text: str, old: str, new: str, label: str) -> str:
if new in text:
return text
if old not in text:
raise RuntimeError(f'{label} marker not found')
return text.replace(old, new, 1)
def patch_send_ts(text: str) -> str:
text = replace_required(
text,
'}): Promise<{ messageId: string }> {\n const { to, text, opts } = params;\n',
'}): Promise<{ messageId: string; createTimeMs: number }> {\n const { to, text, opts } = params;\n',
'TS send return type',
)
text = replace_required(
text,
' const clientId = generateClientId();\n const req = buildSendMessageReq({',
' const clientId = generateClientId();\n const createTimeMs = Date.now();\n const req = buildSendMessageReq({',
'TS create time',
)
text = replace_required(
text,
' });\n try {\n await sendMessageApi({',
' });\n if (!req.msg?.item_list?.[0]) throw new Error("sendMessageWeixin: text item missing");\n req.msg.item_list[0].create_time_ms = createTimeMs;\n try {\n await sendMessageApi({',
'TS request timestamp',
)
text = replace_required(
text,
' return { messageId: clientId };\n}\n\n/** Send a single structured MessageItem downstream. */',
' return { messageId: clientId, createTimeMs };\n}\n\n/** Send a single structured MessageItem downstream. */',
'TS send result',
)
return text
def patch_send_js(text: str) -> str:
text = replace_required(
text,
' const clientId = generateClientId();\n const req = buildSendMessageReq({',
' const clientId = generateClientId();\n const createTimeMs = Date.now();\n const req = buildSendMessageReq({',
'JS create time',
)
text = replace_required(
text,
' });\n try {\n await sendMessageApi({',
' });\n if (!req.msg?.item_list?.[0])\n throw new Error("sendMessageWeixin: text item missing");\n req.msg.item_list[0].create_time_ms = createTimeMs;\n try {\n await sendMessageApi({',
'JS request timestamp',
)
text = replace_required(
text,
' return { messageId: clientId };\n}\n/** Send a single structured MessageItem downstream. */',
' return { messageId: clientId, createTimeMs };\n}\n/** Send a single structured MessageItem downstream. */',
'JS send result',
)
return text
def patch_process_ts(text: str) -> str:
old = '''await sendMessageWeixin({ to: ctx.To, text, opts: {
baseUrl: deps.baseUrl,
token: deps.token,
contextToken,
runId,
}});'''
new = '''const sentText = await sendMessageWeixin({ to: ctx.To, text, opts: {
baseUrl: deps.baseUrl,
token: deps.token,
contextToken,
runId,
}});
rememberHermesOutboundTextMessage(deps.accountId, ctx.To, text, sentText.createTimeMs);'''
text = replace_required(text, old, new, 'TS fallback text send')
old = '''await sendMessageWeixin({ to: ctx.To, text, opts: {
baseUrl: deps.baseUrl,
token: deps.token,
contextToken,
runId,
}});'''
new = '''const sentText = await sendMessageWeixin({ to: ctx.To, text, opts: {
baseUrl: deps.baseUrl,
token: deps.token,
contextToken,
runId,
}});
rememberHermesOutboundTextMessage(deps.accountId, ctx.To, text, sentText.createTimeMs);'''
return replace_required(text, old, new, 'TS normal text send')
def patch_process_js(text: str) -> str:
old = '''await sendMessageWeixin({ to: ctx.To, text, opts: {
baseUrl: deps.baseUrl,
token: deps.token,
contextToken,
runId,
} });'''
new = '''const sentText = await sendMessageWeixin({ to: ctx.To, text, opts: {
baseUrl: deps.baseUrl,
token: deps.token,
contextToken,
runId,
} });
rememberHermesOutboundTextMessage(deps.accountId, ctx.To, text, sentText.createTimeMs);'''
text = replace_required(text, old, new, 'JS fallback text send')
old = '''await sendMessageWeixin({ to: ctx.To, text, opts: {
baseUrl: deps.baseUrl,
token: deps.token,
contextToken,
runId,
} });'''
new = '''const sentText = await sendMessageWeixin({ to: ctx.To, text, opts: {
baseUrl: deps.baseUrl,
token: deps.token,
contextToken,
runId,
} });
rememberHermesOutboundTextMessage(deps.accountId, ctx.To, text, sentText.createTimeMs);'''
return replace_required(text, old, new, 'JS normal text send')
def apply(root: Path) -> dict[str, bool]:
transforms = {
'src/messaging/send.ts': patch_send_ts,
'dist/src/messaging/send.js': patch_send_js,
'src/messaging/process-message.ts': patch_process_ts,
'dist/src/messaging/process-message.js': patch_process_js,
}
changed = {}
for relative, transform in transforms.items():
path = root / relative
original = path.read_text(encoding='utf-8')
updated = transform(original)
changed[relative] = updated != original
if updated != original:
path.write_text(updated, encoding='utf-8')
return changed
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument('plugin_root', type=Path)
args = parser.parse_args()
root = args.plugin_root.resolve()
print(json.dumps({'plugin_root': str(root), 'changed': apply(root)}, ensure_ascii=False))
return 0
if __name__ == '__main__':
raise SystemExit(main())
@@ -0,0 +1,9 @@
{
"type": "branch",
"branch": "deploy/v1r4c6-production-20260711",
"base": "main",
"created_by": "chatgpt-gitops-bot",
"created_at": "2026-07-11T13:10:51Z",
"direct_main_push": false,
"owner_approval_required": true
}
@@ -0,0 +1,9 @@
{
"type": "branch",
"branch": "task/openclaw/AIOS-RUNTIME-SELF-HEALING-ADAPTER-V1-20260720",
"base": "main",
"created_by": "chatgpt-gitops-bot",
"created_at": "2026-07-19T17:06:06Z",
"direct_main_push": false,
"owner_approval_required": true
}
+63
View File
@@ -0,0 +1,63 @@
from __future__ import annotations
import importlib.util
import json
import subprocess
from pathlib import Path
PATCHER_PATH = Path(__file__).resolve().parents[1] / 'fixes' / 'patch_openclaw_weixin_bigint.py'
def load_patcher():
spec = importlib.util.spec_from_file_location('patch_openclaw_weixin_bigint', PATCHER_PATH)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_api_patch_is_idempotent_and_scoped() -> None:
patcher = load_patcher()
ts = '\n'.join([
'import { fileURLToPath } from "node:url";',
'import { redactBody, redactUrl } from "../util/redact.js";',
'const resp: GetUpdatesResp = JSON.parse(rawText);',
])
js = '\n'.join([
'import { fileURLToPath } from "node:url";',
'import { redactBody, redactUrl } from "../util/redact.js";',
'const resp = JSON.parse(rawText);',
])
types = 'export interface WeixinMessage {\n message_id?: number;\n}\n'
ts_once = patcher.patch_api_ts(ts)
js_once = patcher.patch_api_js(js)
types_once = patcher.patch_types_ts(types)
assert patcher.patch_api_ts(ts_once) == ts_once
assert patcher.patch_api_js(js_once) == js_once
assert patcher.patch_types_ts(types_once) == types_once
assert 'parseWeixinGetUpdatesJson(rawText)' in ts_once
assert 'parseWeixinGetUpdatesJson(rawText)' in js_once
assert 'message_id?: string | number;' in types_once
def test_node_reviver_preserves_only_weixin_exact_ids() -> None:
script = '''
const ids = new Set(["message_id", "msg_id", "root_id", "parent_id", "server_msg_id", "new_msg_id"]);
const parsed = JSON.parse('{"ret":0,"seq":42,"msgs":[{"message_id":7482038281766381234,"create_time_ms":1783856936191,"item_list":[{"msg_id":"3123141975110186863"}]}]}', (key, value, context) => {
if (!ids.has(key) || value == null || typeof value === "string") return value;
if (typeof value !== "number" || !context?.source || !/^(?:0|[1-9]\\d*)$/.test(context.source)) throw new Error("unsafe id");
return context.source;
});
console.log(JSON.stringify({ messageId: parsed.msgs[0].message_id, msgId: parsed.msgs[0].item_list[0].msg_id, seqType: typeof parsed.seq, timestampType: typeof parsed.msgs[0].create_time_ms }));
'''
completed = subprocess.run(['node', '-e', script], check=True, capture_output=True, text=True)
result = json.loads(completed.stdout)
assert result == {
'messageId': '7482038281766381234',
'msgId': '3123141975110186863',
'seqType': 'number',
'timestampType': 'number',
}
+563
View File
@@ -0,0 +1,563 @@
from __future__ import annotations
import importlib.util
import json
import subprocess
from pathlib import Path
import pytest
def load_js_process_helpers() -> str:
patcher_path = Path(__file__).resolve().parents[1] / 'fixes' / 'patch_openclaw_weixin_hermes.py'
assert patcher_path.exists(), f'missing patcher: {patcher_path}'
spec = importlib.util.spec_from_file_location('patch_openclaw_weixin_hermes', patcher_path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module.JS_PROCESS_HELPERS
def load_patcher_module() -> object:
patcher_path = Path(__file__).resolve().parents[1] / 'fixes' / 'patch_openclaw_weixin_hermes.py'
assert patcher_path.exists(), f'missing patcher: {patcher_path}'
spec = importlib.util.spec_from_file_location('patch_openclaw_weixin_hermes', patcher_path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def run_js_process_helpers(tmp_path: Path, body: str) -> dict:
js_process_helpers = load_js_process_helpers()
node_prelude = '\n'.join(
[
'const hermesTestFs = require("node:fs");',
'globalThis.existsSync = hermesTestFs.existsSync;',
'globalThis.readFileSync = hermesTestFs.readFileSync;',
'globalThis.writeFileSync = hermesTestFs.writeFileSync;',
'globalThis.mkdirSync = hermesTestFs.mkdirSync;',
'globalThis.appendFileSync = hermesTestFs.appendFileSync;',
'globalThis.path = require("node:path");',
'globalThis.logger = { info() {}, warn() {}, error() {} };',
]
)
node_script = tmp_path / 'hermes_image_followup_check.js'
node_script.write_text(node_prelude + '\n\n' + js_process_helpers + '\n\n' + body, encoding='utf-8')
completed = subprocess.run(['node', str(node_script)], check=True, capture_output=True, text=True)
return json.loads(completed.stdout.strip())
@pytest.mark.parametrize('is_ts', [True, False], ids=['ts', 'js'])
def test_replace_or_insert_process_helpers_is_byte_idempotent(is_ts: bool) -> None:
patcher = load_patcher_module()
marker = 'export async function processOneMessage'
source = marker + '(message) {\n return message;\n}\n'
once = patcher.replace_or_insert_process_helpers(source, is_ts=is_ts)
twice = patcher.replace_or_insert_process_helpers(once, is_ts=is_ts)
helpers = patcher.TS_PROCESS_HELPERS if is_ts else patcher.JS_PROCESS_HELPERS
assert helpers + '\n\n' + marker in once
assert twice == once
@pytest.mark.parametrize(
('is_ts', 'hook_name'),
[(True, 'TS_PROCESS_HOOK'), (False, 'JS_PROCESS_HOOK')],
ids=['ts', 'js'],
)
def test_remove_and_reinsert_process_hook_is_byte_idempotent(is_ts: bool, hook_name: str) -> None:
patcher = load_patcher_module()
hook = getattr(patcher, hook_name)
indent = ' ' if is_ts else ' '
needle = indent + 'ctx.CommandAuthorized = commandAuthorized;\n'
next_nonblank = indent + 'continueProcessing();\n'
def cycle(text: str) -> str:
without_hook = patcher.remove_existing_process_hermes_hook(text)
return without_hook.replace(needle, needle + hook + '\n', 1)
once = cycle(needle + next_nonblank)
twice = cycle(once)
assert hook + '\n' + next_nonblank in once
assert twice == once
def test_looks_like_hermes_image_followup_distinguishes_diagnostics_from_real_image_questions(tmp_path: Path) -> None:
cases = {
'diag_cache_expired': ('\u56fe\u7247\u7f13\u5b58\u5df2\u5931\u6548\uff0c\u8bf7\u91cd\u65b0\u53d1\u9001\u56fe\u7247\u3002', False),
'diag_cache_error_en': ('image cache error', False),
'diag_cache_expired_en': ('the image cache expired, please resend', False),
'diag_copying_text_shows_cache_error': ('\u4e3a\u4ec0\u4e48\u6211\u590d\u5236\u53d1\u7ed9\u4f60\u7684\u6587\u5b57\uff0c\u4f60\u90fd\u663e\u793a\u56fe\u7247\u7f13\u5b58\u5df2\u5931\u6548\uff0c\u8bf7\u91cd\u65b0\u53d1\u9001\u56fe\u7247\u3002', False),
'diag_not_image': ('\u8fd9\u4e0d\u662f\u56fe\u7247\uff0c\u662f\u6587\u5b57\u6d88\u606f', False),
'diag_text_not_image_en': ('this is a text message, not an image', False),
'diag_no_image_sent': ('\u6211\u6ca1\u6709\u53d1\u56fe\u7247', False),
'diag_system_logs': ('\u8bf7\u68c0\u67e5\u7cfb\u7edf\u65e5\u5fd7\uff0c\u4ece\u673a\u5236\u4e0a\u4fee\u590d\u9519\u8bef', False),
'image_cn_1': ('\u8bf7\u770b\u4e00\u4e0b\u8fd9\u5f20\u56fe\u7247', True),
'image_cn_2': ('\u8fd9\u56fe\u662f\u4ec0\u4e48', True),
'image_cn_3': ('\u8bc6\u522b\u56fe\u7247\u5185\u5bb9', True),
'image_en': ('look at this image', True),
}
results = run_js_process_helpers(
tmp_path,
'const cases = '
+ json.dumps({key: text for key, (text, _) in cases.items()}, ensure_ascii=False)
+ ';\nconst results = {};\n'
+ 'for (const [key, text] of Object.entries(cases)) {\n'
+ ' results[key] = looksLikeHermesImageFollowup(text);\n'
+ '}\n'
+ 'console.log(JSON.stringify(results));\n',
)
for key, (_, expected) in cases.items():
assert results[key] is expected, f'{key}: expected {expected}, got {results[key]!r}'
def test_resolve_hermes_referenced_image_followup_requires_exact_cache(tmp_path: Path) -> None:
cache_path = tmp_path / 'hermes-last-image-cache.json'
results = run_js_process_helpers(
tmp_path,
'\n'.join(
[
'const fs = require("fs");',
f'const cachePath = {json.dumps(str(cache_path))};',
'const accountId = "account-a";',
'const senderId = "sender-b";',
'const exactQuotedId = "quoted-123";',
'const cache = {};',
'cache[hermesLastImageCacheKey(accountId, senderId)] = {',
' mediaUrl: "data:image/png;base64,iVBORw0KGgo=",',
' timestampMs: Date.now(),',
' messageId: "latest-999",',
' mediaType: "image/png",',
'};',
'cache[hermesExactImageCacheKey(accountId, senderId, exactQuotedId)] = {',
' mediaUrl: "data:image/png;base64,iVBORw0KGgo=",',
' timestampMs: Date.now(),',
' messageId: exactQuotedId,',
' mediaType: "image/png",',
'};',
'fs.writeFileSync(cachePath, JSON.stringify(cache, null, 2), "utf-8");',
'process.env.AIOS_HERMES_WEIXIN_LAST_IMAGE_CACHE = cachePath;',
'const generic = resolveHermesReferencedImageFollowup(accountId, senderId, "plain quoted text", "quoted-999");',
'const exact = resolveHermesReferencedImageFollowup(accountId, senderId, "plain quoted text", exactQuotedId);',
'const forced = resolveHermesFollowupImageContext(accountId, senderId, "plain quoted text", exactQuotedId, false, true);',
'console.log(JSON.stringify({ generic, exact, forced }));',
]
),
)
assert results['generic']['imageIntent'] is False
assert results['generic']['matched'] is False
assert results['generic']['reason'] == 'referenced_image_not_confirmed'
assert results['exact']['imageIntent'] is True
assert results['exact']['matched'] is True
assert results['exact']['reason'] == 'cached_image_matched'
assert results['forced']['imageIntent'] is True
assert results['forced']['matched'] is True
def test_referenced_text_without_media_evidence_stays_out_of_image_cache(tmp_path: Path) -> None:
cache_path = tmp_path / 'hermes-last-image-cache.json'
results = run_js_process_helpers(
tmp_path,
'\n'.join(
[
'const fs = require("fs");',
f'const cachePath = {json.dumps(str(cache_path))};',
'const accountId = "account-a";',
'const senderId = "sender-b";',
'const cache = {};',
'cache[hermesLastImageCacheKey(accountId, senderId)] = {',
' mediaUrl: "data:image/png;base64,iVBORw0KGgo=",',
' timestampMs: Date.now(),',
' messageId: "latest-999",',
' mediaType: "image/png",',
'};',
'fs.writeFileSync(cachePath, JSON.stringify(cache, null, 2), "utf-8");',
'process.env.AIOS_HERMES_WEIXIN_LAST_IMAGE_CACHE = cachePath;',
'const full = { item_list: [{ ref_msg: { message_item: { type: 0, id: "quoted-999" } } }] };',
'const hermesContent = "\u8fd9\u662f\u4ec0\u4e48";',
'const hermesReferenceContextPresent = hasHermesReferenceContext(full.item_list);',
'const hermesQuotedMessageId = findHermesReferencedMessageId(full.item_list);',
'const refMediaItem = undefined;',
'const hermesReferencedMediaIsImage = Boolean(refMediaItem && hermesCurrentMessageIsImage({}, refMediaItem));',
'const hermesReferencedImageFollowupIntent = hermesReferenceContextPresent && !refMediaItem;',
'const hermesCurrentImage = false;',
'const hermesFollowup = hermesCurrentImage',
' ? { imageIntent: false, matched: false, reason: "current_image_message", cacheStrategy: "current", quotedMessageId: hermesQuotedMessageId }',
' : (hermesReferencedMediaIsImage',
' ? resolveHermesFollowupImageContext(accountId, senderId, hermesContent, hermesQuotedMessageId, false, true)',
' : (hermesReferencedImageFollowupIntent',
' ? resolveHermesReferencedImageFollowup(accountId, senderId, hermesContent, hermesQuotedMessageId)',
' : resolveHermesFollowupImageContext(accountId, senderId, hermesContent)));',
'console.log(JSON.stringify({ hermesReferenceContextPresent, hermesQuotedMessageId, hermesReferencedMediaIsImage, hermesReferencedImageFollowupIntent, hermesFollowup }));',
]
),
)
assert results['hermesReferenceContextPresent'] is True
assert results['hermesQuotedMessageId'] == 'quoted-999'
assert results['hermesReferencedMediaIsImage'] is False
assert results['hermesReferencedImageFollowupIntent'] is True
assert results['hermesFollowup']['imageIntent'] is False
assert results['hermesFollowup']['matched'] is False
assert results['hermesFollowup']['reason'] == 'referenced_image_not_confirmed'
def test_explicit_quoted_text_builds_grounded_plain_text_context(tmp_path: Path) -> None:
results = run_js_process_helpers(
tmp_path,
'\n'.join(
[
'const available = typeof resolveHermesQuotedTextContext === "function";',
'const items = [{ type: 1, ref_msg: { message_item: { type: 1, text_item: { text: "被引用的正文" } } } }];',
'const result = available ? resolveHermesQuotedTextContext(items, "这是什么意思?") : null;',
'console.log(JSON.stringify({ available, result }));',
]
),
)
assert results['available'] is True
assert results['result'] == {
'referenced': True,
'handled': False,
'prompt': '[被引用文字]\n被引用的正文\n[/被引用文字]\n\n[当前问题]\n这是什么意思?\n[/当前问题]',
'reason': 'quoted_text_resolved',
}
def test_visible_ref_title_builds_grounded_plain_text_context(tmp_path: Path) -> None:
results = run_js_process_helpers(
tmp_path,
'\n'.join(
[
'const items = [{ type: 1, ref_msg: { title: "客户端可见的引用正文", message_item: { type: 0, msg_id: "quoted-123" } } }];',
'const result = resolveHermesQuotedTextContext(items, "这是什么意思?");',
'console.log(JSON.stringify(result));',
]
),
)
assert results == {
'referenced': True,
'handled': False,
'prompt': '[被引用文字]\n客户端可见的引用正文\n[/被引用文字]\n\n[当前问题]\n这是什么意思?\n[/当前问题]',
'reason': 'quoted_text_resolved',
}
def test_nested_generic_title_is_not_treated_as_quoted_text(tmp_path: Path) -> None:
results = run_js_process_helpers(
tmp_path,
'\n'.join(
[
'const items = [{ type: 1, ref_msg: { message_item: { type: 0, title: "非协议引用摘要" } } }];',
'const result = resolveHermesQuotedTextContext(items, "这是什么意思?");',
'console.log(JSON.stringify(result));',
]
),
)
assert results == {
'referenced': True,
'handled': True,
'replyText': '无法读取被引用文字,请复制原文后再提问。',
'reason': 'quoted_text_unavailable',
}
def test_exact_message_id_cache_resolves_server_reference_without_guessing(tmp_path: Path) -> None:
cache_path = tmp_path / 'hermes-message-text-cache.json'
results = run_js_process_helpers(
tmp_path,
'\n'.join(
[
f'process.env.AIOS_HERMES_WEIXIN_TEXT_MESSAGE_CACHE = {json.dumps(str(cache_path))};',
'const accountId = "account-a";',
'const senderId = "sender-b";',
'const original = { item_list: [{ type: 1, msg_id: "7481729105240418312", text_item: { text: "客户端已经显示的原始正文" } }] };',
'rememberHermesTextMessage(accountId, senderId, original, "客户端已经显示的原始正文");',
'const quotedItems = [{ type: 1, ref_msg: { message_item: { type: 0, msg_id: "7481729105240418312" } }, text_item: { text: "这是什么意思?" } }];',
'const quotedId = findHermesReferencedMessageId(quotedItems);',
'const exact = resolveHermesCachedQuotedText(accountId, senderId, quotedId);',
'const wrong = resolveHermesCachedQuotedText(accountId, senderId, "7481729105240418313");',
'const resolved = resolveHermesQuotedTextContext(quotedItems, "这是什么意思?", exact);',
'const mode = (hermesTestFs.statSync(process.env.AIOS_HERMES_WEIXIN_TEXT_MESSAGE_CACHE).mode & 0o777).toString(8);',
'console.log(JSON.stringify({ quotedId, exact, wrong, resolved, mode }));',
]
),
)
assert results['quotedId'] == '7481729105240418312'
assert results['exact'] == '客户端已经显示的原始正文'
assert results['wrong'] == ''
assert results['resolved'] == {
'referenced': True,
'handled': False,
'prompt': '[被引用文字]\n客户端已经显示的原始正文\n[/被引用文字]\n\n[当前问题]\n这是什么意思?\n[/当前问题]',
'reason': 'quoted_text_resolved',
}
assert results['mode'] == '600'
def test_expired_message_id_cache_entry_is_not_resolved(tmp_path: Path) -> None:
cache_path = tmp_path / 'hermes-message-text-cache.json'
results = run_js_process_helpers(
tmp_path,
'\n'.join(
[
f'process.env.AIOS_HERMES_WEIXIN_TEXT_MESSAGE_CACHE = {json.dumps(str(cache_path))};',
'const key = hermesTextMessageCacheKey("account-a", "sender-b", "quoted-old");',
'hermesTestFs.writeFileSync(process.env.AIOS_HERMES_WEIXIN_TEXT_MESSAGE_CACHE, JSON.stringify({ [key]: { text: "过期正文", timestampMs: Date.now() - (8 * 24 * 60 * 60 * 1000) } }), { encoding: "utf-8", mode: 0o600 });',
'const result = resolveHermesCachedQuotedText("account-a", "sender-b", "quoted-old");',
'console.log(JSON.stringify({ result }));',
]
),
)
assert results['result'] == ''
def test_unsafe_numeric_message_id_is_rejected_before_cache_keying(tmp_path: Path) -> None:
results = run_js_process_helpers(
tmp_path,
'\n'.join(
[
'const exactString = extractHermesMessageId({ message_id: "7482038281766381234" });',
'const unsafeNumber = extractHermesMessageId({ message_id: 7482038281766381234 });',
'const safeNumber = extractHermesMessageId({ message_id: 12345 });',
'console.log(JSON.stringify({ exactString, unsafeNumber: unsafeNumber ?? null, safeNumber }));',
]
),
)
assert results == {
'exactString': '7482038281766381234',
'unsafeNumber': None,
'safeNumber': '12345',
}
def test_exact_outbound_create_time_resolves_quoted_bot_text(tmp_path: Path) -> None:
cache_path = tmp_path / 'hermes-message-text-cache.json'
results = run_js_process_helpers(
tmp_path,
'\n'.join(
[
f'process.env.AIOS_HERMES_WEIXIN_TEXT_MESSAGE_CACHE = {json.dumps(str(cache_path))};',
'const accountId = "account-a";',
'const senderId = "sender-b";',
'const createTimeMs = 1783857900123;',
'rememberHermesOutboundTextMessage(accountId, senderId, "机器人出站正文", createTimeMs);',
'const quotedItems = [{ type: 1, ref_msg: { message_item: { type: 0, msg_id: "server-quoted", create_time_ms: createTimeMs } }, text_item: { text: "进展汇报" } }];',
'const referencedTime = findHermesReferencedCreateTimeMs(quotedItems);',
'const exact = resolveHermesCachedQuotedText(accountId, senderId, "server-quoted", referencedTime);',
'const wrong = resolveHermesCachedQuotedText(accountId, senderId, "server-quoted", createTimeMs + 1);',
'const resolved = resolveHermesQuotedTextContext(quotedItems, "进展汇报", exact);',
'console.log(JSON.stringify({ referencedTime, exact, wrong, resolved }));',
]
),
)
assert results['referencedTime'] == '1783857900123'
assert results['exact'] == '机器人出站正文'
assert results['wrong'] == ''
assert results['resolved']['reason'] == 'quoted_text_resolved'
assert '机器人出站正文' in results['resolved']['prompt']
def test_outbound_create_time_collision_fails_closed(tmp_path: Path) -> None:
cache_path = tmp_path / 'hermes-message-text-cache.json'
results = run_js_process_helpers(
tmp_path,
'\n'.join(
[
f'process.env.AIOS_HERMES_WEIXIN_TEXT_MESSAGE_CACHE = {json.dumps(str(cache_path))};',
'const createTimeMs = 1783857900123;',
'rememberHermesOutboundTextMessage("account-a", "sender-b", "第一条", createTimeMs);',
'rememberHermesOutboundTextMessage("account-a", "sender-b", "第二条", createTimeMs);',
'const resolved = resolveHermesCachedQuotedText("account-a", "sender-b", undefined, createTimeMs);',
'console.log(JSON.stringify({ resolved }));',
]
),
)
assert results['resolved'] == ''
def test_missing_quoted_text_fails_closed_without_model_prompt(tmp_path: Path) -> None:
results = run_js_process_helpers(
tmp_path,
'\n'.join(
[
'const available = typeof resolveHermesQuotedTextContext === "function";',
'const items = [{ type: 1, ref_msg: { message_item: { type: 0, msg_id: "7481729105240418312" } } }];',
'const result = available ? resolveHermesQuotedTextContext(items, "这是什么意思?") : null;',
'let modelCalls = 0;',
'if (result && !result.handled) modelCalls += 1;',
'console.log(JSON.stringify({ available, result, modelCalls }));',
]
),
)
assert results['available'] is True
assert results['result'] == {
'referenced': True,
'handled': True,
'replyText': '无法读取被引用文字,请复制原文后再提问。',
'reason': 'quoted_text_unavailable',
}
assert results['modelCalls'] == 0
@pytest.mark.parametrize('hook_name', ['TS_PROCESS_HOOK', 'JS_PROCESS_HOOK'])
def test_process_hook_stops_unavailable_quote_before_model_dispatch(hook_name: str) -> None:
patcher = load_patcher_module()
hook = getattr(patcher, hook_name)
guard_name = 'TS_QUOTED_TEXT_DISPATCH_GUARD' if hook_name.startswith('TS_') else 'JS_QUOTED_TEXT_DISPATCH_GUARD'
guard = getattr(patcher, guard_name)
assert 'rememberHermesTextMessage(deps.accountId, hermesSenderId, full, textBody)' in hook
assert 'findHermesReferencedCreateTimeMs(full.item_list)' in hook
assert 'resolveHermesCachedQuotedText(deps.accountId, hermesSenderId, hermesQuotedMessageId, hermesQuotedCreateTimeMs)' in hook
assert 'resolveHermesQuotedTextContext(full.item_list, hermesContent, hermesCachedQuotedText)' in hook
assert '!hermesQuotedTextContext.referenced && !textBody.startsWith("/")' in hook
assert 'ctx.Body = hermesQuotedTextContext.prompt' in hook
assert 'await dispatchHermesQuotedTextFailClosed(hermesQuotedTextContext, dispatcher)' in guard
assert 'markDispatchIdle();' in guard
def test_dispatch_quoted_text_fail_closed_uses_dispatcher_without_model(tmp_path: Path) -> None:
result = run_js_process_helpers(
tmp_path,
'\n'.join(
[
'(async () => {',
' const calls = [];',
' const dispatcher = {',
' sendFinalReply(payload) { calls.push(["final", payload]); },',
' markComplete() { calls.push(["complete"]); },',
' async waitForIdle() { calls.push(["idle"]); },',
' };',
' const context = resolveHermesQuotedTextContext([{ ref_msg: { message_item: { type: 0, msg_id: "quoted-1" } } }], "这是什么意思?");',
' let modelCalls = 0;',
' const handled = await dispatchHermesQuotedTextFailClosed(context, dispatcher);',
' if (!handled) modelCalls += 1;',
' console.log(JSON.stringify({ handled, modelCalls, calls }));',
'})();',
]
),
)
assert result['handled'] is True
assert result['modelCalls'] == 0
assert result['calls'] == [
['final', {'text': '无法读取被引用文字,请复制原文后再提问。'}],
['complete'],
['idle'],
]
@pytest.mark.parametrize(
('is_ts', 'guard_name', 'needle'),
[
(True, 'TS_QUOTED_TEXT_DISPATCH_GUARD', ' logger.debug(`dispatchReplyFromConfig: starting agentId=${route.agentId ?? "(none)"}`);\n'),
(False, 'JS_QUOTED_TEXT_DISPATCH_GUARD', ' logger.debug(`dispatchReplyFromConfig: starting agentId=${route.agentId ?? "(none)"}`);\n'),
],
ids=['ts', 'js'],
)
def test_dispatch_guard_remove_and_reinsert_is_byte_idempotent(is_ts: bool, guard_name: str, needle: str) -> None:
patcher = load_patcher_module()
guard = getattr(patcher, guard_name)
def cycle(text: str) -> str:
without = patcher.remove_existing_process_hermes_hook(text)
return without.replace(needle, guard + '\n' + needle, 1)
once = cycle(needle)
twice = cycle(once)
assert guard + '\n' + needle in once
assert twice == once
def test_reference_metadata_content_is_not_treated_as_quoted_text(tmp_path: Path) -> None:
result = run_js_process_helpers(
tmp_path,
'\n'.join(
[
'const items = [{ ref_msg: { message_item: { type: 0, msg_id: "quoted-1", content: "metadata only" } } }];',
'console.log(JSON.stringify(resolveHermesQuotedTextContext(items, "这是什么意思?")));',
]
),
)
assert result['handled'] is True
assert result['reason'] == 'quoted_text_unavailable'
assert result['replyText'] == '无法读取被引用文字,请复制原文后再提问。'
def test_patch_plugin_process_only_skips_slash_and_channel(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
patcher = load_patcher_module()
calls: list[tuple[str, Path]] = []
def record(name: str, result):
def _inner(root: Path):
calls.append((name, root))
return result
return _inner
monkeypatch.setattr(patcher, 'patch_ts', record('patch_ts', False))
monkeypatch.setattr(patcher, 'patch_js', record('patch_js', False))
monkeypatch.setattr(patcher, 'patch_process_messages', record('patch_process_messages', {'src/messaging/process-message.ts': True, 'dist/src/messaging/process-message.js': False}))
monkeypatch.setattr(patcher, 'patch_channel_files', record('patch_channel_files', {'src/channel.ts': False, 'dist/src/channel.js': False}))
monkeypatch.setattr(patcher, 'verify_plugin_hook', record('verify_plugin_hook', {
'hook_status': {},
'hook_installed': False,
'process_status': {'src/messaging/process-message.ts': True, 'dist/src/messaging/process-message.js': True},
'process_hook_installed': True,
'channel_status': {},
'channel_hook_installed': False,
'dispatch_reference_found': False,
'dispatch_reference_files': [],
}))
result = patcher.patch_plugin(tmp_path, process_only=True)
assert calls == [
('patch_process_messages', tmp_path),
('verify_plugin_hook', tmp_path),
]
assert result['mode'] == 'process-only'
assert result['plugin_root'] == str(tmp_path)
assert result['slash_changes'] == {}
assert result['channel_changes'] == {}
assert result['process_changes'] == {'src/messaging/process-message.ts': True, 'dist/src/messaging/process-message.js': False}
assert result['behavior_changes'] == {'src/messaging/process-message.ts': True, 'dist/src/messaging/process-message.js': False}
assert result['verification']['process_hook_installed'] is True
def test_parse_args_supports_default_root_and_process_only_flag() -> None:
patcher = load_patcher_module()
defaults = patcher.parse_args([])
assert defaults.plugin_root == patcher.DEFAULT_PLUGIN_ROOT
assert defaults.process_only is False
process_only = patcher.parse_args(['--process-only'])
assert process_only.plugin_root == patcher.DEFAULT_PLUGIN_ROOT
assert process_only.process_only is True
custom_root = Path('/tmp/custom-plugin-root')
custom = patcher.parse_args([str(custom_root), '--process-only'])
assert custom.plugin_root == custom_root
assert custom.process_only is True
@@ -0,0 +1,75 @@
from __future__ import annotations
import importlib.util
from pathlib import Path
PATCHER_PATH = Path(__file__).resolve().parents[1] / 'fixes' / 'patch_openclaw_weixin_outbound_quotes.py'
def load_patcher():
spec = importlib.util.spec_from_file_location('patch_openclaw_weixin_outbound_quotes', PATCHER_PATH)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_send_patch_adds_exact_create_time_and_is_idempotent() -> None:
patcher = load_patcher()
ts = '''function buildTextMessageReq(params: {
to: string;
text: string;
contextToken?: string;
runId?: string;
clientId: string;
}): SendMessageReq {
const { to, text, contextToken, runId, clientId } = params;
return { msg: { client_id: clientId, message_type: MessageType.BOT } };
}
export async function sendMessageWeixin(params: {}): Promise<{ messageId: string }> {
const { to, text, opts } = params;
const clientId = generateClientId();
const req = buildSendMessageReq({
payload: {},
clientId,
});
try {
await sendMessageApi({ body: req });
} catch (err) {
throw err;
}
return { messageId: clientId };
}
/** Send a single structured MessageItem downstream. */'''
js = '''function buildTextMessageReq(params) {
const { to, text, contextToken, runId, clientId } = params;
return { msg: { client_id: clientId, message_type: MessageType.BOT } };
}
export async function sendMessageWeixin(params) {
const clientId = generateClientId();
const req = buildSendMessageReq({
payload: {},
clientId,
});
try {
await sendMessageApi({ body: req });
}
catch (err) {
throw err;
}
return { messageId: clientId };
}
/** Send a single structured MessageItem downstream. */'''
ts_once = patcher.patch_send_ts(ts)
js_once = patcher.patch_send_js(js)
assert patcher.patch_send_ts(ts_once) == ts_once
assert patcher.patch_send_js(js_once) == js_once
for result in (ts_once, js_once):
assert 'const createTimeMs = Date.now();' in result
assert 'text item missing' in result
assert 'req.msg.item_list[0].create_time_ms = createTimeMs' in result
assert 'req.msg.create_time_ms = createTimeMs' not in result
assert 'return { messageId: clientId, createTimeMs }' in result