fix(wechat): persist confirmed QR auth before cancellation (#6199)

* fix(wechat): drain QR auth state writes on cancellation

* test(wechat): pin cancelled QR credential persistence

* fix(wechat): preserve auth persistence contract
This commit is contained in:
NanPan 2026-10-03 16:59:18 +08:00 committed by GitHub
parent 9b16792168
commit b782a3b173
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 92 additions and 8 deletions

View File

@ -27,6 +27,7 @@ from app.channels.base import Channel
from app.channels.commands import is_known_channel_command
from app.channels.connection_identity import attach_connection_identity
from app.channels.message_bus import InboundMessage, InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment
from deerflow.utils.file_io import await_drained
logger = logging.getLogger(__name__)
@ -777,6 +778,32 @@ class WechatChannel(Channel):
params["verify_code"] = verify_code
return await self._request_public_get_json("/ilink/bot/get_qrcode_status", params=params, timeout=timeout)
async def _save_auth_state_drained(
self,
*,
status: str,
bot_token: str | None = None,
ilink_bot_id: str | None = None,
qrcode: str | None = None,
qrcode_img_content: str | None = None,
) -> dict[str, Any]:
"""Persist QR auth state before propagating caller cancellation.
The write is a small local state-file update and is intentionally drained
while _auth_lock is held so cancellation cannot expose an in-memory
credential before its durable state settles.
"""
return await await_drained(
asyncio.to_thread(
self._save_auth_state,
status=status,
bot_token=bot_token,
ilink_bot_id=ilink_bot_id,
qrcode=qrcode,
qrcode_img_content=qrcode_img_content,
)
)
async def _bind_via_qrcode(self) -> dict[str, Any]:
qrcode_data = await self.request_login_qrcode()
qrcode = str(qrcode_data.get("qrcode") or "").strip()
@ -788,8 +815,7 @@ class WechatChannel(Channel):
if qrcode_img_content:
logger.warning("[WeChat] qrcode_img_content=%s", qrcode_img_content)
await asyncio.to_thread(
self._save_auth_state,
await self._save_auth_state_drained(
status="pending",
qrcode=qrcode,
qrcode_img_content=qrcode_img_content or None,
@ -808,8 +834,7 @@ class WechatChannel(Channel):
if ilink_bot_id:
self._ilink_bot_id = ilink_bot_id
return await asyncio.to_thread(
self._save_auth_state,
return await self._save_auth_state_drained(
status="confirmed",
bot_token=token,
ilink_bot_id=self._ilink_bot_id,
@ -818,8 +843,7 @@ class WechatChannel(Channel):
)
if status in {"expired", "canceled", "cancelled", "invalid", "failed"}:
await asyncio.to_thread(
self._save_auth_state,
await self._save_auth_state_drained(
status=status,
qrcode=qrcode,
qrcode_img_content=qrcode_img_content or None,
@ -828,8 +852,7 @@ class WechatChannel(Channel):
await asyncio.sleep(max(self._qrcode_poll_interval, 0.1))
await asyncio.to_thread(
self._save_auth_state,
await self._save_auth_state_drained(
status="timeout",
qrcode=qrcode,
qrcode_img_content=qrcode_img_content or None,

View File

@ -7,6 +7,7 @@ import base64
import json
import logging
import os
import threading
from pathlib import Path
from typing import Any
from unittest import mock
@ -1490,6 +1491,66 @@ def test_qrcode_login_binds_and_persists_auth_state(monkeypatch, tmp_path: Path)
_run(go())
@pytest.mark.asyncio
async def test_qrcode_confirmation_drains_auth_persistence_across_cancellation(monkeypatch, tmp_path: Path):
from app.channels.wechat import WechatChannel
state_dir = tmp_path / "wechat-state"
channel = WechatChannel(
bus=MessageBus(),
config={
"state_dir": str(state_dir),
"qrcode_login_enabled": True,
"qrcode_poll_interval": 0.01,
"qrcode_poll_timeout": 1,
},
)
started = threading.Event()
release = threading.Event()
original_save_auth_state = channel._save_auth_state
async def request_qrcode():
return {"qrcode": "qr-cancel"}
async def request_status(_qrcode, **_kwargs):
return {"status": "confirmed", "bot_token": "durable-token", "ilink_bot_id": "bot-cancel"}
def blocking_save_auth_state(**kwargs):
if kwargs.get("status") == "confirmed":
started.set()
assert release.wait(timeout=5)
return original_save_auth_state(**kwargs)
monkeypatch.setattr(channel, "request_login_qrcode", request_qrcode)
monkeypatch.setattr(channel, "request_login_status", request_status)
monkeypatch.setattr(channel, "_save_auth_state", blocking_save_auth_state)
task = asyncio.create_task(channel._bind_via_qrcode())
try:
assert await asyncio.to_thread(started.wait, 5)
assert channel._bot_token == "durable-token"
task.cancel()
await asyncio.sleep(0)
task.cancel()
await asyncio.sleep(0)
assert not task.done()
release.set()
with pytest.raises(asyncio.CancelledError):
await task
finally:
release.set()
if not task.done():
task.cancel()
await asyncio.gather(task, return_exceptions=True)
auth_state = json.loads((state_dir / "wechat-auth.json").read_text(encoding="utf-8"))
assert auth_state["status"] == "confirmed"
assert auth_state["bot_token"] == "durable-token"
assert auth_state["ilink_bot_id"] == "bot-cancel"
def test_save_auth_state_tightens_preexisting_loose_file(tmp_path: Path):
"""A world-readable auth file is replaced by an owner-only one, atomically.