From 488452059a88d55a794d3fd38f060d007c452a11 Mon Sep 17 00:00:00 2001 From: Fieldy YuYang Date: Wed, 17 Jun 2026 02:05:56 +0800 Subject: [PATCH 01/14] fix(account-detect): multi-PID + filesystem fallback for find_account_dir (issue #153) find_account_dir only scanned the main WeChat PID's /proc//fd; when the DB fds are held by a helper/renderer process it returned None, so login never persisted logged_in_user and /api/chats returned empty. Add: scan all WeChat-related PIDs, then a filesystem fallback over xwechat_files/* (account dirs with core DBs, newest mtime wins). Redacted diagnostic logging + unit tests for the pure helpers. login.rs: loud warn on detection give-up (control flow unchanged to preserve the ws/login contract). --- packages/agent-server-rust/src/plans/login.rs | 13 ++ .../agent-server-rust/src/tools/wechat_db.rs | 204 ++++++++++++++++-- 2 files changed, 204 insertions(+), 13 deletions(-) diff --git a/packages/agent-server-rust/src/plans/login.rs b/packages/agent-server-rust/src/plans/login.rs index 31cad1fd..10ae2123 100644 --- a/packages/agent-server-rust/src/plans/login.rs +++ b/packages/agent-server-rust/src/plans/login.rs @@ -305,6 +305,19 @@ async fn handle_detecting_user( plan_state.detect_retries += 1; if plan_state.detect_retries >= 10 { + // NOTE (graila patch): account-dir detection exhausted retries. The + // primary fix is in tools::wechat_db::find_account_dir (multi-PID + + // filesystem fallback for issue #153); reaching here means even that + // failed, so `logged_in_user` was NOT persisted and data APIs + // (/api/chats, /api/contacts, /api/messages) will return empty. + // + // Control flow is intentionally left unchanged (still emits a terminal + // event) to preserve the /api/ws/login client contract; we only surface + // the failure loudly so it is diagnosable instead of silent. + tracing::warn!( + "[login] account_dir_detection_failed retry_count={} next_action=emit_login_success_without_user note=logged_in_user_unset_data_apis_will_be_empty", + plan_state.detect_retries + ); plan_state.phase = LoginPhase::Done; return Some(SelectedAction { action: actions::sequence(vec![ diff --git a/packages/agent-server-rust/src/tools/wechat_db.rs b/packages/agent-server-rust/src/tools/wechat_db.rs index e32b7396..83dfb8f1 100644 --- a/packages/agent-server-rust/src/tools/wechat_db.rs +++ b/packages/agent-server-rust/src/tools/wechat_db.rs @@ -116,28 +116,159 @@ pub fn find_wechat_pid() -> Option { best_pid } -/// Detect the WeChat account directory by scanning /proc//fd. -pub fn find_account_dir(wechat_pid: i64) -> Option { - let fd_dir = format!("/proc/{wechat_pid}/fd"); - let entries = std::fs::read_dir(&fd_dir).ok()?; +/// Pure helper: extract the account dir name (e.g. `wxid_xxx`) from a path that +/// points at a WeChat DB, e.g. `/home/wechat/xwechat_files/wxid_xxx/db_storage/..`. +/// Returns None for unrelated paths. No I/O, fully unit-testable. +pub fn account_dir_from_db_path(target: &str) -> Option { + if !target.contains("db_storage") || !target.ends_with(".db") { + return None; + } + let idx = target.find("xwechat_files/")?; + let rest = &target[idx + "xwechat_files/".len()..]; + let account_dir = rest.split('/').next()?; + if account_dir.is_empty() { + None + } else { + Some(account_dir.to_string()) + } +} +/// Scan a single process's /proc//fd for an open WeChat DB and derive the +/// account dir. Tolerant of permission errors (returns None, never panics). +fn scan_pid_fd_for_account(pid: i64) -> Option { + let fd_dir = format!("/proc/{pid}/fd"); + let entries = std::fs::read_dir(&fd_dir).ok()?; for entry in entries.flatten() { if let Ok(target) = std::fs::read_link(entry.path()) { - let target_str = target.to_string_lossy(); - if target_str.contains("db_storage") && target_str.ends_with(".db") { - if let Some(idx) = target_str.find("xwechat_files/") { - let rest = &target_str[idx + "xwechat_files/".len()..]; - if let Some(account_dir) = rest.split('/').next() { - if !account_dir.is_empty() { - return Some(account_dir.to_string()); - } + if let Some(acct) = account_dir_from_db_path(&target.to_string_lossy()) { + return Some(acct); + } + } + } + None +} + +/// Enumerate PIDs of WeChat-related processes (main client + helper/renderer +/// processes that may hold the DB file descriptors). +fn related_wechat_pids() -> Vec { + let mut pids = Vec::new(); + // -f matches the full command line; covers main + WeChatAppEx/RadiumWMPF helpers. + for pat in ["/usr/bin/wechat", "wechat", "WeChatAppEx", "RadiumWMPF"] { + if let Ok(output) = Command::new("pgrep").args(["-f", pat]).output() { + for s in String::from_utf8_lossy(&output.stdout).split_whitespace() { + if let Ok(pid) = s.parse::() { + if !pids.contains(&pid) { + pids.push(pid); } } } } } + pids +} - None +/// Whether an account dir on disk has the core DBs we need (session + contact). +fn account_dir_has_core_dbs(account_dir: &str) -> bool { + let dbs = list_account_dbs(account_dir); + dbs.iter().any(|n| n == "session.db") && dbs.iter().any(|n| n == "contact.db") +} + +/// Pure helper: pick the most-recently-modified candidate. No I/O. +fn select_newest_candidate( + mut candidates: Vec<(String, std::time::SystemTime)>, +) -> Option { + candidates.sort_by(|a, b| b.1.cmp(&a.1)); + candidates.into_iter().next().map(|(name, _)| name) +} + +/// Filesystem fallback: scan xwechat_files/* for account dirs that contain the +/// core DBs, returning (account_dir_name, mtime) candidates. +fn filesystem_account_candidates() -> Vec<(String, std::time::SystemTime)> { + let bases = [ + "/home/wechat/xwechat_files", + "/home/wechat/Documents/xwechat_files", + ]; + let mut out: Vec<(String, std::time::SystemTime)> = Vec::new(); + for base in bases { + let entries = match std::fs::read_dir(base) { + Ok(e) => e, + Err(_) => continue, + }; + for entry in entries.flatten() { + if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { + continue; + } + let name = entry.file_name().to_string_lossy().to_string(); + // Account dirs look like wxid_*; skip shared dirs like all_users. + if !name.starts_with("wxid_") { + continue; + } + if !account_dir_has_core_dbs(&name) { + continue; + } + let db_storage = entry.path().join("db_storage"); + let mtime = std::fs::metadata(&db_storage) + .and_then(|m| m.modified()) + .unwrap_or(std::time::UNIX_EPOCH); + if !out.iter().any(|(n, _)| n == &name) { + out.push((name, mtime)); + } + } + } + out +} + +/// Detect the WeChat account directory (returns the account dir NAME, e.g. +/// `wxid_xxx`). Robust against the case where the main WeChat PID does not hold +/// the DB file descriptors (the root cause of upstream issue #153: the DB fds +/// are held by a helper/renderer process, so the original single-PID scan +/// returned None -> `logged_in_user` was never persisted -> /api/chats empty). +/// +/// Strategy (first hit wins): +/// 1. pid_fd — scan the given PID's /proc//fd (original behavior). +/// 2. related_pid_fd — scan all WeChat-related PIDs' fds. +/// 3. filesystem — scan xwechat_files/* for an account dir with core DBs; +/// if multiple, pick the most-recently-modified. +/// Logs are REDACTED: method + candidate_count + selected only (never the wxid). +pub fn find_account_dir(wechat_pid: i64) -> Option { + // 1. Original: the given PID. + if let Some(acct) = scan_pid_fd_for_account(wechat_pid) { + tracing::info!( + "[account-detect] method=pid_fd candidate_count=1 selected=true account=" + ); + return Some(acct); + } + + // 2. Fallback: any WeChat-related process may hold the DB fds. + let related = related_wechat_pids(); + for pid in &related { + if *pid == wechat_pid { + continue; + } + if let Some(acct) = scan_pid_fd_for_account(*pid) { + tracing::info!( + "[account-detect] method=related_pid_fd scanned_pids={} selected=true account=", + related.len() + ); + return Some(acct); + } + } + + // 3. Fallback: filesystem scan. + let candidates = filesystem_account_candidates(); + let count = candidates.len(); + let selected = select_newest_candidate(candidates); + tracing::info!( + "[account-detect] method=filesystem_fallback candidate_count={} selected={} account=", + count, + selected.is_some() + ); + if selected.is_none() { + tracing::warn!( + "[account-detect] all methods failed (pid_fd + related_pid_fd + filesystem); logged_in_user will not be set" + ); + } + selected } /// List all .db files that exist on disk for a given account. @@ -436,4 +567,51 @@ mod tests { .unwrap(); assert_eq!(count_fresh, 3, "Fresh immutable connection should see committed writes"); } + + // ---- account-dir detection (issue #153 fix) ---- + use super::{account_dir_from_db_path, select_newest_candidate}; + use std::time::{Duration, UNIX_EPOCH}; + + #[test] + fn account_dir_from_db_path_extracts_wxid() { + let p = "/home/wechat/xwechat_files/wxid_abc123/db_storage/session/session.db"; + assert_eq!(account_dir_from_db_path(p), Some("wxid_abc123".to_string())); + } + + #[test] + fn account_dir_from_db_path_handles_documents_variant() { + let p = "/home/wechat/Documents/xwechat_files/wxid_xyz/db_storage/contact/contact.db"; + assert_eq!(account_dir_from_db_path(p), Some("wxid_xyz".to_string())); + } + + #[test] + fn account_dir_from_db_path_rejects_unrelated_paths() { + assert_eq!(account_dir_from_db_path("/proc/61/maps"), None); + assert_eq!(account_dir_from_db_path("/home/wechat/.pki/nssdb/key4.db"), None); + // db_storage but not a .db file + assert_eq!( + account_dir_from_db_path("/home/wechat/xwechat_files/wxid_a/db_storage/"), + None + ); + } + + #[test] + fn select_newest_candidate_picks_latest_mtime() { + let older = UNIX_EPOCH + Duration::from_secs(1000); + let newer = UNIX_EPOCH + Duration::from_secs(2000); + let candidates = vec![ + ("wxid_old".to_string(), older), + ("wxid_new".to_string(), newer), + ]; + assert_eq!(select_newest_candidate(candidates), Some("wxid_new".to_string())); + } + + #[test] + fn select_newest_candidate_single_and_empty() { + assert_eq!( + select_newest_candidate(vec![("wxid_only".to_string(), UNIX_EPOCH)]), + Some("wxid_only".to_string()) + ); + assert_eq!(select_newest_candidate(Vec::new()), None); + } } From b1b7a0a58e77f2e422fc27cc0941ba9512e8c165 Mon Sep 17 00:00:00 2001 From: Fieldy YuYang Date: Wed, 17 Jun 2026 04:01:39 +0800 Subject: [PATCH 02/14] build(docker): vendor noVNC + sqlcipher tarballs (github unreachable in our build network) The build network has no proxy inside the Docker build, so curl-from-github for noVNC and sqlcipher truncates. Vendor both tarballs into the build context (COPY) instead; pre-fetch commands documented in the Dockerfile. WeChat .deb (Tencent CDN) still downloads in-build fine. gitignore the vendored binaries + staged rust source. --- docker/.gitignore | 6 ++++++ docker/Dockerfile | 24 ++++++++++++++++++------ 2 files changed, 24 insertions(+), 6 deletions(-) create mode 100644 docker/.gitignore diff --git a/docker/.gitignore b/docker/.gitignore new file mode 100644 index 00000000..27eed17c --- /dev/null +++ b/docker/.gitignore @@ -0,0 +1,6 @@ +# graila build: staged Rust source (copied from packages/ by build script) +/agent-server-rust/ +# graila build-robustness: vendored upstream tarballs (pre-fetched, not committed). +# See Dockerfile comments for the exact pre-fetch curl commands. +/novnc-*.tar.gz +/sqlcipher-*.tar.gz diff --git a/docker/Dockerfile b/docker/Dockerfile index f3907c18..9e2bdba3 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -80,20 +80,32 @@ RUN pip3 install frida-tools silk-python # noVNC (browser-based VNC viewer) + websockify # ============================================ ARG NOVNC_VERSION=1.5.0 -RUN pip3 install websockify \ - && curl -L https://github.com/novnc/noVNC/archive/refs/tags/v${NOVNC_VERSION}.tar.gz | tar xz -C /opt \ - && mv /opt/noVNC-${NOVNC_VERSION} /opt/novnc +# graila build-robustness patch: github.com archive downloads are unreliable +# from our build network (no proxy inside the build), so the noVNC tarball is +# vendored into the build context and COPY'd in instead of curl-from-github. +# Pre-fetch on a host that can reach github (see patches/ notes), e.g.: +# curl -fL https://codeload.github.com/novnc/noVNC/tar.gz/refs/tags/v1.5.0 \ +# -o docker/novnc-1.5.0.tar.gz +RUN pip3 install websockify +COPY novnc-${NOVNC_VERSION}.tar.gz /tmp/novnc.tar.gz +RUN tar xz -C /opt -f /tmp/novnc.tar.gz \ + && mv /opt/noVNC-${NOVNC_VERSION} /opt/novnc \ + && rm -f /tmp/novnc.tar.gz # ============================================ # SQLCipher 4 CLI (needed for wechat-db reads) # ============================================ +# graila build-robustness patch: vendored (github unreachable inside our build). +# Pre-fetch: curl -fL https://codeload.github.com/sqlcipher/sqlcipher/tar.gz/refs/tags/v4.6.1 -o docker/sqlcipher-4.6.1.tar.gz +ARG SQLCIPHER_VERSION=4.6.1 +COPY sqlcipher-${SQLCIPHER_VERSION}.tar.gz /tmp/sqlcipher.tar.gz RUN cd /tmp \ - && curl -L https://github.com/sqlcipher/sqlcipher/archive/refs/tags/v4.6.1.tar.gz | tar xz \ - && cd sqlcipher-4.6.1 \ + && tar xz -f sqlcipher.tar.gz \ + && cd sqlcipher-${SQLCIPHER_VERSION} \ && ./configure --enable-tempstore=yes CFLAGS="-DSQLITE_HAS_CODEC -DSQLCIPHER_CRYPTO_OPENSSL" LDFLAGS="-lcrypto" \ && make -j$(nproc) \ && cp sqlcipher /usr/local/bin/sqlcipher \ - && cd / && rm -rf /tmp/sqlcipher-4.6.1 + && cd / && rm -rf /tmp/sqlcipher-${SQLCIPHER_VERSION} /tmp/sqlcipher.tar.gz # ============================================ # X11 socket directory From 99d0cfea2f951228150e41e2ae0dfaea8bf92c09 Mon Sep 17 00:00:00 2001 From: Fieldy YuYang Date: Wed, 17 Jun 2026 18:56:47 +0800 Subject: [PATCH 03/14] feat: add /api/sync/rescan account-detect recovery endpoint + x11vnc -nobell Recover the data plane when WeChat is logged in but session.logged_in_user was never persisted (issue #153). Two real login paths (phone-confirm, and connecting /api/ws/login while already logged in) bypass the LoginPlan DetectingUser phase, so find_account_dir is never called and /api/chats etc. return empty. - POST /api/sync/rescan (token-protected): resolve wechat pid -> patched find_account_dir_with_method -> persist logged_in_user -> trigger key extraction. Reuses the exact helpers the LoginPlan uses. Redacted JSON + logs (no wxid/path/key/token). - find_account_dir_with_method: expose detection method for observability. - x11vnc -nobell in entrypoint.sh + sessions/manager.rs: suppress the noVNC browser bell WeChat triggers on notifications. Co-Authored-By: Claude Opus 4.8 --- docker/entrypoint.sh | 4 +- packages/agent-server-rust/src/router/mod.rs | 4 + packages/agent-server-rust/src/router/sync.rs | 178 ++++++++++++++++++ .../agent-server-rust/src/sessions/manager.rs | 3 +- .../agent-server-rust/src/tools/wechat_db.rs | 16 +- 5 files changed, 200 insertions(+), 5 deletions(-) create mode 100644 packages/agent-server-rust/src/router/sync.rs diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 88f0235b..85068b22 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -146,7 +146,9 @@ fi if [ "${ENABLE_VNC:-1}" = "1" ]; then # -nopw: no VNC-level password (localhost only; auth enforced by agent-server proxy with full token) # -viewonly: no remote input - x11vnc -display "$DISPLAY" -forever -nopw -shared -viewonly -xkb -rfbport 5900 -listen 127.0.0.1 & + # -nobell: do not forward XBell events to VNC clients (suppresses the noVNC + # browser-side beep WeChat emits on notifications) — graila patch. + x11vnc -display "$DISPLAY" -forever -nopw -shared -viewonly -xkb -nobell -rfbport 5900 -listen 127.0.0.1 & fi # ============================================ diff --git a/packages/agent-server-rust/src/router/mod.rs b/packages/agent-server-rust/src/router/mod.rs index 3d727a0f..9647e64d 100644 --- a/packages/agent-server-rust/src/router/mod.rs +++ b/packages/agent-server-rust/src/router/mod.rs @@ -6,6 +6,7 @@ mod events; mod messages; mod sessions; mod status; +mod sync; mod vnc; use axum::{ @@ -53,6 +54,9 @@ pub fn build_router() -> Router { // Debug .route("/api/debug/screenshot", get(debug::screenshot)) .route("/api/debug/a11y", get(debug::a11y)) + // Sync / recovery: re-detect account dir + keys for an already-logged-in + // client when login_user was never persisted (issue #153). + .route("/api/sync/rescan", post(sync::rescan)) // Sessions .route("/api/sessions", get(sessions::list_sessions).post(sessions::create_session)) .route("/api/sessions/{id}", get(sessions::get_session).delete(sessions::delete_session)) diff --git a/packages/agent-server-rust/src/router/sync.rs b/packages/agent-server-rust/src/router/sync.rs new file mode 100644 index 00000000..d3fd24b0 --- /dev/null +++ b/packages/agent-server-rust/src/router/sync.rs @@ -0,0 +1,178 @@ +//! Account rescan / data-plane recovery endpoint. +//! +//! Context (issue #153 + GRAILA AW-FORK findings): the WeChat account-dir +//! detection (`tools::wechat_db::find_account_dir`) that persists +//! `session.logged_in_user` runs ONLY inside the `WS /api/ws/login` LoginPlan, +//! in the `DetectingUser` phase. Two real login paths bypass it entirely: +//! - phone-confirm login (the client logs in out-of-band), and +//! - connecting `/api/ws/login` while already logged in (the plan sees +//! `mainWindow=chat` and only normalizes the window — never DetectingUser). +//! In both cases `auth_status` independently reports `logged_in` (via a11y), +//! but `logged_in_user` stays NULL, so `/api/chats`, `/api/contacts` and +//! `/api/messages` short-circuit to empty. +//! +//! This endpoint lets an operator actively re-run detection + key extraction +//! against the already-running WeChat client WITHOUT logging out or re-scanning. +//! It reuses the exact same helpers the LoginPlan uses, so behavior matches the +//! normal happy path. +//! +//! All logging and the JSON response are REDACTED: no wxid, no filesystem path, +//! no key material, no token. + +use axum::Json; +use rusqlite::params; +use serde::Serialize; + +use crate::db::{get_db, queries}; +use crate::sessions::manager::get_session; +use crate::tools::wechat_db::{find_account_dir_with_method, find_wechat_pid}; +use crate::tools::wechat_keys::{extract_keys_async, needs_key_extraction, store_keys}; + +#[derive(Serialize)] +pub struct RescanResponse { + /// "ok" on success; otherwise a machine-readable failure reason: + /// "no_session" | "no_wechat_pid" | "no_account_dir". + status: &'static str, + wechat_pid_present: bool, + account_detected: bool, + /// "pid_fd" | "related_pid_fd" | "filesystem_fallback" | "none". + account_detect_method: &'static str, + logged_in_user_before: bool, + logged_in_user_after: bool, + key_extraction_needed: bool, + key_extraction_triggered: bool, + #[serde(skip_serializing_if = "Option::is_none")] + reason: Option<&'static str>, + notes: Vec<&'static str>, +} + +impl RescanResponse { + fn failure( + status: &'static str, + reason: &'static str, + wechat_pid_present: bool, + account_detect_method: &'static str, + logged_in_user_before: bool, + ) -> Self { + RescanResponse { + status, + wechat_pid_present, + account_detected: false, + account_detect_method, + logged_in_user_before, + logged_in_user_after: logged_in_user_before, + key_extraction_needed: false, + key_extraction_triggered: false, + reason: Some(reason), + notes: Vec::new(), + } + } +} + +/// POST /api/sync/rescan — recover the data plane when WeChat is logged in but +/// `logged_in_user` was never persisted (issue #153). Token-protected by the +/// global auth middleware. Body is ignored (no params required). +pub async fn rescan() -> Json { + // 1. Resolve the session. + let session = match get_session("default") { + Some(s) => s, + None => { + tracing::warn!("[rescan] no_session"); + return Json(RescanResponse::failure( + "no_session", + "no_session", + false, + "none", + false, + )); + } + }; + let session_id = session.id.clone(); + let logged_in_user_before = session.logged_in_user.is_some(); + + // 2. Resolve the WeChat PID (prefer the session's, fall back to a live scan). + let wechat_pid = match session.wechat_pid.or_else(find_wechat_pid) { + Some(p) => p, + None => { + tracing::warn!("[rescan] wechat_pid_present=false"); + return Json(RescanResponse::failure( + "no_wechat_pid", + "no_wechat_pid", + false, + "none", + logged_in_user_before, + )); + } + }; + tracing::info!("[rescan] wechat_pid_present=true"); + + // Persist the PID back onto the session (cheap; keeps DetectingUser-equivalent state). + { + let db = get_db(); + let now = chrono::Utc::now().to_rfc3339(); + db.execute( + "UPDATE sessions SET wechat_pid = ?1, updated_at = ?2 WHERE id = ?3", + params![wechat_pid, now, session_id], + ) + .ok(); + } + + // 3. Detect the account dir (patched multi-PID + filesystem fallback). + let (account_dir, method) = find_account_dir_with_method(wechat_pid); + let account_dir = match account_dir { + Some(a) => a, + None => { + tracing::warn!("[rescan] account_detected=false method={method}"); + return Json(RescanResponse::failure( + "no_account_dir", + "no_account_dir", + true, + method, + logged_in_user_before, + )); + } + }; + tracing::info!("[rescan] account_detected=true method={method}"); + + // 4. Persist logged_in_user (clearing stale data if the account changed). + let key_extraction_needed; + { + let db = get_db(); + let previous = queries::get_session_logged_in_user(&db, &session_id); + if previous.as_ref().filter(|p| *p != &account_dir).is_some() { + queries::clear_session_data(&db, &session_id); + } + queries::update_session_logged_in_user(&db, &session_id, Some(&account_dir)); + // Evaluate while still holding the guard; this is a sync call. + key_extraction_needed = needs_key_extraction(&db, &session_id, &account_dir); + } // MutexGuard dropped before the await below. + + // 5. Trigger key extraction if needed (mirrors handle_detecting_user -> + // handle_extracting_keys). Non-fatal: logged_in_user is already set, and + // list_chats also lazily re-extracts on demand. + let mut key_extraction_triggered = false; + if key_extraction_needed { + let keys = extract_keys_async(wechat_pid).await; + if keys.is_empty() { + tracing::error!("[rescan] key_extraction_failed"); + } else { + let db = get_db(); + store_keys(&db, &session_id, &account_dir, &keys); + key_extraction_triggered = true; + tracing::info!("[rescan] key_extraction_triggered=true"); + } + } + + Json(RescanResponse { + status: "ok", + wechat_pid_present: true, + account_detected: true, + account_detect_method: method, + logged_in_user_before, + logged_in_user_after: true, + key_extraction_needed, + key_extraction_triggered, + reason: None, + notes: Vec::new(), + }) +} diff --git a/packages/agent-server-rust/src/sessions/manager.rs b/packages/agent-server-rust/src/sessions/manager.rs index c1ad2231..6085395c 100644 --- a/packages/agent-server-rust/src/sessions/manager.rs +++ b/packages/agent-server-rust/src/sessions/manager.rs @@ -204,7 +204,8 @@ pub async fn start_session(id_or_name: &str) -> Result { // 5. VNC (localhost only — auth enforced by agent-server proxy) let vnc_port = session.vnc_port.to_string(); let _ = std::process::Command::new("x11vnc") - .args(["-display", display.as_str(), "-forever", "-nopw", "-shared", "-viewonly", "-xkb", "-rfbport", &vnc_port, "-listen", "127.0.0.1"]) + // -nobell: suppress XBell -> noVNC browser beep (graila patch) + .args(["-display", display.as_str(), "-forever", "-nopw", "-shared", "-viewonly", "-xkb", "-nobell", "-rfbport", &vnc_port, "-listen", "127.0.0.1"]) .spawn(); // 5b. noVNC (websockify on localhost only — proxied via agent-server with auth) diff --git a/packages/agent-server-rust/src/tools/wechat_db.rs b/packages/agent-server-rust/src/tools/wechat_db.rs index 83dfb8f1..bcad8998 100644 --- a/packages/agent-server-rust/src/tools/wechat_db.rs +++ b/packages/agent-server-rust/src/tools/wechat_db.rs @@ -231,12 +231,21 @@ fn filesystem_account_candidates() -> Vec<(String, std::time::SystemTime)> { /// if multiple, pick the most-recently-modified. /// Logs are REDACTED: method + candidate_count + selected only (never the wxid). pub fn find_account_dir(wechat_pid: i64) -> Option { + find_account_dir_with_method(wechat_pid).0 +} + +/// Like [`find_account_dir`] but also returns which detection method resolved +/// the account directory, for observability / the rescan endpoint. The method +/// string is one of: `pid_fd`, `related_pid_fd`, `filesystem_fallback`, `none`. +/// The returned values never include the wxid (the dir name is in `.0`, but the +/// method tag in `.1` is always safe to surface in API responses / logs). +pub fn find_account_dir_with_method(wechat_pid: i64) -> (Option, &'static str) { // 1. Original: the given PID. if let Some(acct) = scan_pid_fd_for_account(wechat_pid) { tracing::info!( "[account-detect] method=pid_fd candidate_count=1 selected=true account=" ); - return Some(acct); + return (Some(acct), "pid_fd"); } // 2. Fallback: any WeChat-related process may hold the DB fds. @@ -250,7 +259,7 @@ pub fn find_account_dir(wechat_pid: i64) -> Option { "[account-detect] method=related_pid_fd scanned_pids={} selected=true account=", related.len() ); - return Some(acct); + return (Some(acct), "related_pid_fd"); } } @@ -267,8 +276,9 @@ pub fn find_account_dir(wechat_pid: i64) -> Option { tracing::warn!( "[account-detect] all methods failed (pid_fd + related_pid_fd + filesystem); logged_in_user will not be set" ); + return (None, "none"); } - selected + (selected, "filesystem_fallback") } /// List all .db files that exist on disk for a given account. From ed842f89a78787fba27d8ab08fd8f76957fea927 Mon Sep 17 00:00:00 2001 From: Fieldy YuYang Date: Wed, 17 Jun 2026 19:44:26 +0800 Subject: [PATCH 04/14] feat: known-popup auto-close (Weixin update window) + rescan pre-close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WeChat version-update popup is a separate top-level X window that the a11y dismiss_popup (scoped to the main window tree) does not catch; left open it obscures the UI and disrupts login/rescan/adapter. - tools/ui_popups::close_known_popups: enumerate visible windows via the no-shell exec_command xdotool wrapper, match a tight whitelist (name Weixin/微信/WeChat + geometry ~500-650x350-500) guarded by 'a larger main Weixin window also present', click only the top-right close X once each, re-verify. Redacted logs/response (no titles/screenshots/chat data). - POST /api/ui/close-known-popups (token-protected) returns {status, popup_detected, popup_closed, closed_count, windows_seen, popup_type, warning?}. - /api/sync/rescan calls the closer first so popups can't block detection. Co-Authored-By: Claude Opus 4.8 --- packages/agent-server-rust/src/router/mod.rs | 3 + packages/agent-server-rust/src/router/sync.rs | 12 ++ packages/agent-server-rust/src/router/ui.rs | 40 +++++ packages/agent-server-rust/src/tools/mod.rs | 1 + .../agent-server-rust/src/tools/ui_popups.rs | 165 ++++++++++++++++++ 5 files changed, 221 insertions(+) create mode 100644 packages/agent-server-rust/src/router/ui.rs create mode 100644 packages/agent-server-rust/src/tools/ui_popups.rs diff --git a/packages/agent-server-rust/src/router/mod.rs b/packages/agent-server-rust/src/router/mod.rs index 9647e64d..6cbfb1ad 100644 --- a/packages/agent-server-rust/src/router/mod.rs +++ b/packages/agent-server-rust/src/router/mod.rs @@ -7,6 +7,7 @@ mod messages; mod sessions; mod status; mod sync; +mod ui; mod vnc; use axum::{ @@ -57,6 +58,8 @@ pub fn build_router() -> Router { // Sync / recovery: re-detect account dir + keys for an already-logged-in // client when login_user was never persisted (issue #153). .route("/api/sync/rescan", post(sync::rescan)) + // UI hygiene: close whitelisted popups (e.g. Weixin version-update window). + .route("/api/ui/close-known-popups", post(ui::close_known_popups_handler)) // Sessions .route("/api/sessions", get(sessions::list_sessions).post(sessions::create_session)) .route("/api/sessions/{id}", get(sessions::get_session).delete(sessions::delete_session)) diff --git a/packages/agent-server-rust/src/router/sync.rs b/packages/agent-server-rust/src/router/sync.rs index d3fd24b0..4cdb04ad 100644 --- a/packages/agent-server-rust/src/router/sync.rs +++ b/packages/agent-server-rust/src/router/sync.rs @@ -73,6 +73,18 @@ impl RescanResponse { /// `logged_in_user` was never persisted (issue #153). Token-protected by the /// global auth middleware. Body is ignored (no params required). pub async fn rescan() -> Json { + // 0. Best-effort: dismiss any whitelisted popup (e.g. the Weixin update + // window) that could obscure the main UI / disrupt detection. Safe and + // side-effect free if none is present. + let popups = crate::tools::ui_popups::close_known_popups().await; + if popups.popup_detected { + tracing::info!( + "[rescan] pre_close_popups detected={} closed={}", + popups.popup_detected, + popups.popup_closed + ); + } + // 1. Resolve the session. let session = match get_session("default") { Some(s) => s, diff --git a/packages/agent-server-rust/src/router/ui.rs b/packages/agent-server-rust/src/router/ui.rs new file mode 100644 index 00000000..5a8e188c --- /dev/null +++ b/packages/agent-server-rust/src/router/ui.rs @@ -0,0 +1,40 @@ +//! UI-hygiene endpoints (token-protected): close known WeChat popups. + +use axum::Json; +use serde::Serialize; + +use crate::tools::ui_popups::close_known_popups; + +#[derive(Serialize)] +pub struct ClosePopupsResponse { + /// "ok" | "xdotool_missing". + status: &'static str, + popup_detected: bool, + popup_closed: bool, + closed_count: usize, + windows_seen: usize, + #[serde(skip_serializing_if = "Option::is_none")] + popup_type: Option<&'static str>, + #[serde(skip_serializing_if = "Option::is_none")] + warning: Option<&'static str>, +} + +/// POST /api/ui/close-known-popups — close whitelisted popups (currently the +/// Weixin version-update window) by clicking their top-right close X. Redacted +/// response: no window titles, screenshots, or chat/contact/message data. +pub async fn close_known_popups_handler() -> Json { + let o = close_known_popups().await; + Json(ClosePopupsResponse { + status: if o.xdotool_available { "ok" } else { "xdotool_missing" }, + popup_detected: o.popup_detected, + popup_closed: o.popup_closed, + closed_count: o.closed_count, + windows_seen: o.windows_seen, + popup_type: o.popup_type, + warning: if o.xdotool_available { + None + } else { + Some("xdotool_unavailable") + }, + }) +} diff --git a/packages/agent-server-rust/src/tools/mod.rs b/packages/agent-server-rust/src/tools/mod.rs index e77ecd25..95f1bba5 100644 --- a/packages/agent-server-rust/src/tools/mod.rs +++ b/packages/agent-server-rust/src/tools/mod.rs @@ -3,6 +3,7 @@ pub mod chat_select; pub mod exec; pub mod qr; pub mod screenshot; +pub mod ui_popups; pub mod wechat_chats; pub mod wechat_contacts; pub mod wechat_db; diff --git a/packages/agent-server-rust/src/tools/ui_popups.rs b/packages/agent-server-rust/src/tools/ui_popups.rs new file mode 100644 index 00000000..cd7cfa7d --- /dev/null +++ b/packages/agent-server-rust/src/tools/ui_popups.rs @@ -0,0 +1,165 @@ +//! Known-popup auto-close (UI hygiene). +//! +//! WeChat occasionally raises a **version-update popup** as a SEPARATE +//! top-level X window (e.g. "Weixin 4.1.1", ~550x410) that overlaps the main +//! window. Because it is its own window — not a node inside the main window's +//! a11y tree — the execution engine's a11y `dismiss_popup` does NOT catch it. +//! Left open it obscures the main UI and can disrupt login automation, the +//! post-login state read, `/api/sync/rescan`, and any long-running adapter. +//! +//! This module closes ONLY whitelisted popups by clicking their top-right close +//! `X`, via the no-shell `exec_command` xdotool wrapper. It never types text and +//! never clicks chat/contact/input/send regions. All logging is redacted (no +//! window title text, no screenshot, no chat/contact/message data). + +use super::exec::{exec_command, ExecOptions}; + +/// Stable tag for the only popup class currently whitelisted. +const POPUP_TYPE_WEIXIN_UPDATE: &str = "weixin_update"; + +#[derive(Debug, Default)] +pub struct ClosePopupsOutcome { + pub xdotool_available: bool, + pub windows_seen: usize, + pub popup_detected: bool, + pub popup_closed: bool, + pub closed_count: usize, + pub popup_type: Option<&'static str>, +} + +struct WinInfo { + id: String, + name: String, + x: i32, + y: i32, + w: i32, + h: i32, +} + +/// Run xdotool with a fixed argument vector (no shell). Returns stdout on +/// success, None on non-zero exit / missing binary. +async fn xdotool(args: &[&str]) -> Option { + let r = exec_command("xdotool", args, &ExecOptions::default()).await; + if r.exit_code != 0 { + return None; + } + Some(r.stdout) +} + +/// Enumerate visible windows with their name + geometry. Returns None when +/// xdotool is unavailable (so the caller can distinguish "no windows" from +/// "no xdotool"). +async fn list_visible_windows() -> Option> { + let ids = xdotool(&["search", "--onlyvisible", "--name", "."]).await?; + let mut wins = Vec::new(); + for id in ids.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) { + let name = xdotool(&["getwindowname", id]).await.unwrap_or_default(); + let geo = xdotool(&["getwindowgeometry", "--shell", id]) + .await + .unwrap_or_default(); + let (mut x, mut y, mut w, mut h) = (0i32, 0i32, 0i32, 0i32); + for line in geo.lines() { + let line = line.trim(); + if let Some(v) = line.strip_prefix("X=") { + x = v.trim().parse().unwrap_or(0); + } else if let Some(v) = line.strip_prefix("Y=") { + y = v.trim().parse().unwrap_or(0); + } else if let Some(v) = line.strip_prefix("WIDTH=") { + w = v.trim().parse().unwrap_or(0); + } else if let Some(v) = line.strip_prefix("HEIGHT=") { + h = v.trim().parse().unwrap_or(0); + } + } + wins.push(WinInfo { + id: id.to_string(), + name: name.trim().to_string(), + x, + y, + w, + h, + }); + } + Some(wins) +} + +fn is_weixin_named(name: &str) -> bool { + name.contains("Weixin") || name.contains("微信") || name.contains("WeChat") +} + +/// Geometry band for the update popup: ~500-650 wide, ~350-500 high. This +/// excludes the main window (h > 500) and the login small-window (w < 500). +fn in_update_popup_geometry(w: &WinInfo) -> bool { + w.w >= 500 && w.w <= 650 && w.h >= 350 && w.h <= 500 +} + +fn is_update_popup_candidate(w: &WinInfo) -> bool { + is_weixin_named(&w.name) && in_update_popup_geometry(w) +} + +/// Close all whitelisted (Weixin update) popups. Best-effort and side-effect +/// safe: clicks ONLY the top-right close `X` of matching windows, once each. +pub async fn close_known_popups() -> ClosePopupsOutcome { + let mut outcome = ClosePopupsOutcome::default(); + + let windows = match list_visible_windows().await { + Some(w) => w, + None => { + tracing::warn!("[popup-close] xdotool_unavailable or no windows; skipping"); + return outcome; + } + }; + outcome.xdotool_available = true; + outcome.windows_seen = windows.len(); + + // Safety guard: only treat a small Weixin window as the update popup when a + // LARGER Weixin window (the main UI) is also present. Prevents ever closing + // the main window, and avoids acting when the only window is e.g. the login + // small-window or a transient state. + let has_main_window = windows + .iter() + .any(|w| is_weixin_named(&w.name) && w.h > 500); + + let candidates: Vec<&WinInfo> = windows.iter().filter(|w| is_update_popup_candidate(w)).collect(); + + if candidates.is_empty() || !has_main_window { + return outcome; // popup_detected stays false + } + + outcome.popup_detected = true; + outcome.popup_type = Some(POPUP_TYPE_WEIXIN_UPDATE); + + for w in &candidates { + // Top-right close X, nudged inside the corner. + let cx = (w.x + w.w - 18).to_string(); + let cy = (w.y + 24).to_string(); + tracing::info!( + "[popup-close] type={POPUP_TYPE_WEIXIN_UPDATE} action=click_close geometry={}x{} windows_seen={}", + w.w, + w.h, + outcome.windows_seen + ); + let _ = xdotool(&["windowactivate", w.id.as_str()]).await; + let _ = xdotool(&["mousemove", cx.as_str(), cy.as_str(), "click", "1"]).await; + outcome.closed_count += 1; + } + + // Let the close animation settle, then verify the popup is gone. + tokio::time::sleep(std::time::Duration::from_millis(800)).await; + match list_visible_windows().await { + Some(after) => { + let still_present = after.iter().any(is_update_popup_candidate); + outcome.popup_closed = !still_present; + } + None => { + outcome.popup_closed = outcome.closed_count > 0; + } + } + + tracing::info!( + "[popup-close] detected={} closed={} closed_count={}", + outcome.popup_detected, + outcome.popup_closed, + outcome.closed_count + ); + outcome +} From 76b1cc750219448bbc8be853308f8d8163474c2e Mon Sep 17 00:00:00 2001 From: Fieldy YuYang Date: Thu, 18 Jun 2026 22:11:16 +0800 Subject: [PATCH 05/14] feat: version-robust a11y open-chat fallback for outbound send The frida chat-select fast-path hard-fails on WeChat builds without a BUILD_PROFILE (e.g. 4.1.1.7 / 7b3f07cc), killing send at 'No action selected'. Add a version-robust fallback that opens a chat the way a human does: - tools/ui_open_chat::open_chat_a11y_search: resolve chatId -> display name via the decrypted DB (get_chat_by_username), then drive WeChat's search box via a11y-dump + xdotool (focus, clear, type name, click first result), and confirm via the message composer (Send button) being present. No frida offsets; no shell interpolation. Redacted logs/response (never name/id/content). - POST /api/ui/open-chat (token-protected): {chatId, dryRun} -> redacted {status, method, *_present, result_clicked, open_confirmed, error}, with specific error codes. - SendMessagePlan Opening: on chat-select failure, fall back to a11y search and return a specific error instead of generic 'No action selected'. Frida hook kept as the fast-path when a profile exists. Co-Authored-By: Claude Opus 4.8 --- .../src/plans/send_message.rs | 36 +- packages/agent-server-rust/src/router/mod.rs | 3 + packages/agent-server-rust/src/router/ui.rs | 51 ++- packages/agent-server-rust/src/tools/mod.rs | 1 + .../src/tools/ui_open_chat.rs | 316 ++++++++++++++++++ 5 files changed, 403 insertions(+), 4 deletions(-) create mode 100644 packages/agent-server-rust/src/tools/ui_open_chat.rs diff --git a/packages/agent-server-rust/src/plans/send_message.rs b/packages/agent-server-rust/src/plans/send_message.rs index 815f8d64..12aea20b 100644 --- a/packages/agent-server-rust/src/plans/send_message.rs +++ b/packages/agent-server-rust/src/plans/send_message.rs @@ -126,10 +126,42 @@ impl Plan for SendMessagePlan { }); let force = main_state_id == Some("chat"); - let result = open_chat(¶ms.chat_id, force, click_xy).await; + let mut result = open_chat(¶ms.chat_id, force, click_xy).await; if !result.ok { - return None; + // Version-robust fallback: the frida chat-select fast-path + // failed (e.g. unknown BUILD_PROFILE on newer WeChat builds, + // AW-FORK-7). Open via a11y search instead so send no longer + // dies at "No action selected". Redacted diagnostics only. + tracing::warn!( + "[send] open_chat fast_path_failed fallback=a11y_search prev_error_present={}", + result.error.is_some() + ); + let fb = crate::tools::ui_open_chat::open_chat_a11y_search( + ¶ms.chat_id, + false, + ) + .await; + if fb.open_confirmed || fb.result_clicked { + tracing::info!( + "[send] fallback=a11y_search result_clicked={} open_confirmed={}", + fb.result_clicked, + fb.open_confirmed + ); + result = OpenChatResult { + ok: true, + username: None, + index: None, + skipped: Some(false), + error: None, + }; + } else { + tracing::warn!( + "[send] fallback=a11y_search failed error={:?}", + fb.error + ); + return None; + } } let skipped = result.skipped.unwrap_or(false); diff --git a/packages/agent-server-rust/src/router/mod.rs b/packages/agent-server-rust/src/router/mod.rs index 6cbfb1ad..63e43ae2 100644 --- a/packages/agent-server-rust/src/router/mod.rs +++ b/packages/agent-server-rust/src/router/mod.rs @@ -60,6 +60,9 @@ pub fn build_router() -> Router { .route("/api/sync/rescan", post(sync::rescan)) // UI hygiene: close whitelisted popups (e.g. Weixin version-update window). .route("/api/ui/close-known-popups", post(ui::close_known_popups_handler)) + // Version-robust open-chat (a11y search) — fallback for outbound send + // when the frida chat-select BUILD_PROFILE is missing (new WeChat builds). + .route("/api/ui/open-chat", post(ui::open_chat_handler)) // Sessions .route("/api/sessions", get(sessions::list_sessions).post(sessions::create_session)) .route("/api/sessions/{id}", get(sessions::get_session).delete(sessions::delete_session)) diff --git a/packages/agent-server-rust/src/router/ui.rs b/packages/agent-server-rust/src/router/ui.rs index 5a8e188c..7c6eed3b 100644 --- a/packages/agent-server-rust/src/router/ui.rs +++ b/packages/agent-server-rust/src/router/ui.rs @@ -1,8 +1,10 @@ -//! UI-hygiene endpoints (token-protected): close known WeChat popups. +//! UI-hygiene endpoints (token-protected): close known WeChat popups, +//! version-robust open-chat. use axum::Json; -use serde::Serialize; +use serde::{Deserialize, Serialize}; +use crate::tools::ui_open_chat::open_chat_a11y_search; use crate::tools::ui_popups::close_known_popups; #[derive(Serialize)] @@ -38,3 +40,48 @@ pub async fn close_known_popups_handler() -> Json { }, }) } + +#[derive(Deserialize)] +pub struct OpenChatRequest { + #[serde(rename = "chatId")] + chat_id: String, + #[serde(default, rename = "dryRun")] + dry_run: bool, +} + +#[derive(Serialize)] +pub struct OpenChatResponse { + /// "ok" on success; otherwise mirrors `error`. + status: &'static str, + method: &'static str, + chat_id_present: bool, + resolved_name_present: bool, + resolved_name_length: usize, + search_box_present: bool, + result_clicked: bool, + open_confirmed: bool, + /// not_logged_in | chat_not_found_in_db | display_name_missing | + /// search_box_not_found | result_not_found | open_not_confirmed | + /// unknown_build_profile | xdotool_missing | a11y_unavailable + #[serde(skip_serializing_if = "Option::is_none")] + error: Option<&'static str>, +} + +/// POST /api/ui/open-chat — open a chat by id via the version-robust a11y +/// search path (id → display name via decrypted DB → search box → first +/// result). Body: `{ "chatId": "...", "dryRun": false }`. Redacted: never +/// returns the chat name/id or any chat content. +pub async fn open_chat_handler(Json(req): Json) -> Json { + let o = open_chat_a11y_search(&req.chat_id, req.dry_run).await; + Json(OpenChatResponse { + status: if o.error.is_none() { "ok" } else { o.error.unwrap() }, + method: o.method, + chat_id_present: o.chat_id_present, + resolved_name_present: o.resolved_name_present, + resolved_name_length: o.resolved_name_length, + search_box_present: o.search_box_present, + result_clicked: o.result_clicked, + open_confirmed: o.open_confirmed, + error: o.error, + }) +} diff --git a/packages/agent-server-rust/src/tools/mod.rs b/packages/agent-server-rust/src/tools/mod.rs index 95f1bba5..a752488a 100644 --- a/packages/agent-server-rust/src/tools/mod.rs +++ b/packages/agent-server-rust/src/tools/mod.rs @@ -3,6 +3,7 @@ pub mod chat_select; pub mod exec; pub mod qr; pub mod screenshot; +pub mod ui_open_chat; pub mod ui_popups; pub mod wechat_chats; pub mod wechat_contacts; diff --git a/packages/agent-server-rust/src/tools/ui_open_chat.rs b/packages/agent-server-rust/src/tools/ui_open_chat.rs new file mode 100644 index 00000000..b8991de5 --- /dev/null +++ b/packages/agent-server-rust/src/tools/ui_open_chat.rs @@ -0,0 +1,316 @@ +//! Version-robust open-chat via the WeChat search box (a11y + xdotool). +//! +//! Background (AW-FORK-7): the primary `chat-select` tool is a frida + +//! hardcoded-memory-offset approach keyed by WeChat BuildID. New WeChat builds +//! (e.g. 4.1.1.7, prefix `7b3f07cc`) have no profile, so `open_chat` hard-fails +//! and outbound send dies at "No action selected". This helper provides a +//! version-robust fallback that does what a human does — type the chat's display +//! name into the search box and click the first result — using only a11y + +//! xdotool, with NO per-build memory offsets. +//! +//! Privacy: the resolved display name / chatId are NEVER logged or returned. +//! Only redacted booleans/lengths/method are surfaced. + +use super::exec::{exec_command, ExecOptions}; +use super::wechat_chats; +use super::wechat_keys::get_stored_keys; +use crate::db::get_db; +use crate::sessions::manager::get_session; +use serde_json::Value; + +#[derive(Debug, Default)] +pub struct OpenChatOutcome { + pub method: &'static str, // "a11y_search" + pub chat_id_present: bool, + pub resolved_name_present: bool, + pub resolved_name_length: usize, + pub search_box_present: bool, + pub result_clicked: bool, + pub open_confirmed: bool, + /// One of the specific error codes (see module/endpoint docs); None on success. + pub error: Option<&'static str>, +} + +impl OpenChatOutcome { + fn err(code: &'static str) -> Self { + OpenChatOutcome { + method: "a11y_search", + error: Some(code), + ..Default::default() + } + } +} + +struct Rect { + x: i32, + y: i32, + w: i32, + h: i32, +} + +/// Resolve a chatId to its display name + is_group via the decrypted DB. +/// Sync (no await); never logs/returns the name itself. +fn resolve_target(chat_id: &str) -> Result<(String, bool), &'static str> { + let session = get_session("default").ok_or("not_logged_in")?; + let logged_in_user = session.logged_in_user.clone().ok_or("not_logged_in")?; + let keys = { + let db = get_db(); + get_stored_keys(&db, &session.id, &logged_in_user) + }; + if !keys.contains_key("session.db") || !keys.contains_key("contact.db") { + // No decrypted chat DBs available → cannot resolve a name. + return Err("chat_not_found_in_db"); + } + match wechat_chats::get_chat_by_username(&logged_in_user, &keys, chat_id) { + Some(chat) => { + if chat.name.trim().is_empty() { + Err("display_name_missing") + } else { + Ok((chat.name, chat.is_group)) + } + } + None => Err("chat_not_found_in_db"), + } +} + +async fn xdotool(args: &[&str]) -> Option { + let r = exec_command("xdotool", args, &ExecOptions::default()).await; + if r.exit_code != 0 { + return None; + } + Some(r.stdout) +} + +async fn a11y_tree() -> Option { + let r = exec_command( + "/opt/tools/a11y-dump", + &["--format", "json"], + &ExecOptions { + timeout_ms: 15_000, + ..Default::default() + }, + ) + .await; + if r.exit_code != 0 { + return None; + } + serde_json::from_str(&r.stdout).ok() +} + +fn collect<'a>(node: &'a Value, out: &mut Vec<&'a Value>) { + out.push(node); + if let Some(children) = node.get("children").and_then(|c| c.as_array()) { + for c in children { + collect(c, out); + } + } +} + +fn rect_of(node: &Value) -> Option { + let b = node.get("bounds")?; + let x = b.get("x")?.as_f64()?; + let y = b.get("y")?.as_f64()?; + let w = b.get("width")?.as_f64()?; + let h = b.get("height")?.as_f64()?; + if w <= 0.0 || h <= 0.0 { + return None; + } + Some(Rect { + x: x.round() as i32, + y: y.round() as i32, + w: w.round() as i32, + h: h.round() as i32, + }) +} + +fn has_state(node: &Value, want: &str) -> bool { + node.get("states") + .and_then(|s| s.as_array()) + .map(|arr| arr.iter().any(|v| v.as_str() == Some(want))) + .unwrap_or(false) +} + +fn role_of(node: &Value) -> &str { + node.get("role").and_then(|v| v.as_str()).unwrap_or("") +} + +/// Find the search box: the topmost EDITABLE text/entry node. In the chat-list +/// state (no chat open) this is WeChat's search field. +fn find_search_box(tree: &Value) -> Option { + let mut nodes = Vec::new(); + collect(tree, &mut nodes); + let mut best: Option = None; + for n in &nodes { + let role = role_of(n); + let editable = has_state(n, "EDITABLE"); + if editable && (role.contains("text") || role.contains("entry") || role.contains("field")) { + if let Some(r) = rect_of(n) { + // Prefer the topmost candidate (search box sits above the chat list). + if best.as_ref().map(|b| r.y < b.y).unwrap_or(true) { + best = Some(r); + } + } + } + } + best +} + +/// Find the first search-result row to click: topmost `list-item` with bounds. +fn find_first_result(tree: &Value, below_y: i32) -> Option { + let mut nodes = Vec::new(); + collect(tree, &mut nodes); + let mut best: Option = None; + for n in &nodes { + if role_of(n) == "list-item" { + if let Some(r) = rect_of(n) { + if r.y >= below_y && best.as_ref().map(|b| r.y < b.y).unwrap_or(true) { + best = Some(r); + } + } + } + } + best +} + +/// True if a message composer (Send button) is present → a chat is open. +fn chat_is_open(tree: &Value) -> bool { + let mut nodes = Vec::new(); + collect(tree, &mut nodes); + nodes.iter().any(|n| { + role_of(n) == "push-button" + && n.get("name").and_then(|v| v.as_str()) == Some("Send(S)") + }) +} + +/// Largest visible Weixin window (the main UI). +async fn main_window_rect() -> Option<(String, Rect)> { + let ids = xdotool(&["search", "--onlyvisible", "--name", "Weixin|微信|WeChat"]).await?; + let mut best: Option<(String, Rect)> = None; + for id in ids.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) { + let geo = xdotool(&["getwindowgeometry", "--shell", id]).await.unwrap_or_default(); + let (mut x, mut y, mut w, mut h) = (0i32, 0i32, 0i32, 0i32); + for line in geo.lines() { + let line = line.trim(); + if let Some(v) = line.strip_prefix("X=") { x = v.trim().parse().unwrap_or(0); } + else if let Some(v) = line.strip_prefix("Y=") { y = v.trim().parse().unwrap_or(0); } + else if let Some(v) = line.strip_prefix("WIDTH=") { w = v.trim().parse().unwrap_or(0); } + else if let Some(v) = line.strip_prefix("HEIGHT=") { h = v.trim().parse().unwrap_or(0); } + } + let r = Rect { x, y, w, h }; + if r.w > 0 && r.h > 0 && best.as_ref().map(|(_, b)| r.w * r.h > b.w * b.h).unwrap_or(true) { + best = Some((id.to_string(), r)); + } + } + best +} + +async fn click_at(x: i32, y: i32) { + let xs = x.to_string(); + let ys = y.to_string(); + let _ = xdotool(&["mousemove", xs.as_str(), ys.as_str(), "click", "1"]).await; +} + +/// Open a chat by id using the version-robust a11y search path. +pub async fn open_chat_a11y_search(chat_id: &str, dry_run: bool) -> OpenChatOutcome { + let mut out = OpenChatOutcome { + method: "a11y_search", + chat_id_present: !chat_id.is_empty(), + ..Default::default() + }; + + // 1. Resolve display name from the decrypted DB (never logged). + let (name, _is_group) = match resolve_target(chat_id) { + Ok(v) => v, + Err(code) => { + out.error = Some(code); + tracing::warn!("[open-chat] method=a11y_search resolve_failed error={code}"); + return out; + } + }; + out.resolved_name_present = true; + out.resolved_name_length = name.chars().count(); + + // 2. Locate + activate the main window. + let (win_id, win) = match main_window_rect().await { + Some(v) => v, + None => { + out.error = Some("xdotool_missing"); + tracing::warn!("[open-chat] method=a11y_search no_main_window"); + return out; + } + }; + let _ = xdotool(&["windowactivate", win_id.as_str()]).await; + tokio::time::sleep(std::time::Duration::from_millis(400)).await; + + // 3. Find the search box via a11y. + let tree = a11y_tree().await; + if tree.is_none() { + out.error = Some("a11y_unavailable"); + tracing::warn!("[open-chat] method=a11y_search a11y_unavailable"); + return out; + } + let tree = tree.unwrap(); + let search_box = find_search_box(&tree); + // Coordinate fallback: WeChat's search field sits near the top-left. + let (sx, sy) = match &search_box { + Some(r) => { + out.search_box_present = true; + (r.x + r.w / 2, r.y + r.h / 2) + } + None => (win.x + (win.w as f64 * 0.12) as i32, win.y + 45), + }; + + tracing::info!( + "[open-chat] method=a11y_search search_box_present={} dry_run={}", + out.search_box_present, + dry_run + ); + + if dry_run { + // Resolve + detect only; no typing/clicking. + return out; + } + + // 4. Focus search, clear, type the resolved name. + click_at(sx, sy).await; + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + let _ = xdotool(&["key", "--clearmodifiers", "ctrl+a"]).await; + let _ = xdotool(&["key", "--clearmodifiers", "Delete"]).await; + let _ = xdotool(&["type", "--clearmodifiers", "--", name.as_str()]).await; + tokio::time::sleep(std::time::Duration::from_millis(1500)).await; + + // 5. Click the first search result (below the search box). + let after_type = match a11y_tree().await { + Some(t) => t, + None => { + out.error = Some("a11y_unavailable"); + return out; + } + }; + let result = find_first_result(&after_type, sy + 1); + let result = match result { + Some(r) => r, + None => { + out.error = Some("result_not_found"); + tracing::warn!("[open-chat] method=a11y_search result_not_found"); + return out; + } + }; + click_at(result.x + result.w / 2, result.y + result.h / 2).await; + out.result_clicked = true; + tokio::time::sleep(std::time::Duration::from_millis(1000)).await; + + // 6. Confirm a chat opened (message composer / Send button present). + if let Some(confirm_tree) = a11y_tree().await { + out.open_confirmed = chat_is_open(&confirm_tree); + } + if !out.open_confirmed { + out.error = Some("open_not_confirmed"); + } + tracing::info!( + "[open-chat] method=a11y_search result_clicked={} open_confirmed={}", + out.result_clicked, + out.open_confirmed + ); + out +} From 29a87030680a001b2880677b813accaa8b07840f Mon Sep 17 00:00:00 2001 From: Fieldy YuYang Date: Thu, 18 Jun 2026 23:36:27 +0800 Subject: [PATCH 06/14] fix: make send button detection locale-robust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WeChat's composer send button is 'Send(S)' on EN clients but '发送(S)' on ZH clients (the EMDE locale). Hardcoding the English name made open-confirmation (AW-FORK-7 open_confirmed=false) and the send Confirming phase false-negative. - ia/selectors::is_send_button_name(name): match 'send' (any case) OR '发送'; shared helper + unit test. - Use it in plans/send_message::find_edit_send_pair, plans/chat_open find_edit_near_send, and tools/ui_open_chat::chat_is_open (which now also logs redacted editable/send-button counts). Dropped the English-only Send(S) query_selector gate in send_message. No behavior change beyond locale. Co-Authored-By: Claude Opus 4.8 --- .../agent-server-rust/src/ia/selectors.rs | 28 +++++++++++++++++++ .../agent-server-rust/src/plans/chat_open.rs | 4 +-- .../src/plans/send_message.rs | 20 ++++--------- .../src/tools/ui_open_chat.rs | 26 +++++++++++++---- 4 files changed, 56 insertions(+), 22 deletions(-) diff --git a/packages/agent-server-rust/src/ia/selectors.rs b/packages/agent-server-rust/src/ia/selectors.rs index 230d2f58..8485e712 100644 --- a/packages/agent-server-rust/src/ia/selectors.rs +++ b/packages/agent-server-rust/src/ia/selectors.rs @@ -1,6 +1,21 @@ use super::types::A11yNode; use regex::Regex; +// ============================================ +// Locale-robust control matching +// ============================================ + +/// True if an accessibility node name looks like WeChat's "Send" button, +/// regardless of UI language. WeChat's composer send button is "Send(S)" on an +/// English client but "发送(S)" on a Chinese client (the actual locale on our +/// EMDE deploy). Matching only the English form made open-confirmation and the +/// send Confirming phase false-negative (AW-FORK-7B). Intended for use on +/// push-button nodes only, so the broad contains-match is safe. +pub fn is_send_button_name(name: &str) -> bool { + let trimmed = name.trim(); + trimmed.to_ascii_lowercase().contains("send") || trimmed.contains("发送") +} + // ============================================ // Ancestor Traversal // ============================================ @@ -546,6 +561,19 @@ mod tests { assert_eq!(results.len(), 3); } + #[test] + fn test_is_send_button_name_locale() { + assert!(is_send_button_name("Send(S)")); + assert!(is_send_button_name("Send")); + assert!(is_send_button_name("send")); + assert!(is_send_button_name(" Send(S) ")); + assert!(is_send_button_name("发送")); + assert!(is_send_button_name("发送(S)")); + assert!(!is_send_button_name("")); + assert!(!is_send_button_name("Cancel")); + assert!(!is_send_button_name("取消")); + } + #[test] fn test_regex_combined_flags() { let tree = node("root", "", Some(vec![ diff --git a/packages/agent-server-rust/src/plans/chat_open.rs b/packages/agent-server-rust/src/plans/chat_open.rs index df7970e3..93e8171d 100644 --- a/packages/agent-server-rust/src/plans/chat_open.rs +++ b/packages/agent-server-rust/src/plans/chat_open.rs @@ -1,6 +1,6 @@ use super::Plan; use crate::ia::actions; -use crate::ia::selectors::{query_selector, query_selector_all}; +use crate::ia::selectors::{is_send_button_name, query_selector, query_selector_all}; use crate::ia::types::*; use crate::tools::chat_select::{open_chat, OpenChatResult}; @@ -30,7 +30,7 @@ fn find_edit_area(a11y: &A11yNode) -> Option<&A11yNode> { fn find_edit_near_send(node: &A11yNode) -> Option<&A11yNode> { if let Some(children) = &node.children { let has_send = children.iter().any(|c| { - c.role == "push-button" && c.name == "Send(S)" + c.role == "push-button" && is_send_button_name(&c.name) }); let edit_node = children.iter().find(|c| { c.role == "text" diff --git a/packages/agent-server-rust/src/plans/send_message.rs b/packages/agent-server-rust/src/plans/send_message.rs index 12aea20b..e3bb64a6 100644 --- a/packages/agent-server-rust/src/plans/send_message.rs +++ b/packages/agent-server-rust/src/plans/send_message.rs @@ -1,6 +1,6 @@ use super::Plan; use crate::ia::actions; -use crate::ia::selectors::query_selector; +use crate::ia::selectors::{is_send_button_name, query_selector}; use crate::ia::types::*; use crate::tools::chat_select::{open_chat, OpenChatResult}; use crate::tools::exec::{exec_command, ExecOptions}; @@ -30,25 +30,15 @@ pub struct SendMessagePlanState { } fn find_edit_and_send_button(a11y: &A11yNode) -> Option<(&A11yNode, &A11yNode)> { - let send_btn = query_selector(a11y, r#"push-button[name="Send(S)"]"#)?; - // Find sibling EDITABLE text node via parent - // Since we don't have parent refs in the tree-based approach, - // we search the tree for the pattern - find_edit_near_send(a11y, send_btn) -} - -fn find_edit_near_send<'a>( - root: &'a A11yNode, - _send_btn: &A11yNode, -) -> Option<(&'a A11yNode, &'a A11yNode)> { - // Walk tree looking for a parent that has both an EDITABLE text and Send(S) button - find_edit_send_pair(root) + // Locale-robust: scan for an EDITABLE-text + send-button pair anywhere in the + // tree (the send button is "Send(S)" on EN clients, "发送(S)" on ZH clients). + find_edit_send_pair(a11y) } fn find_edit_send_pair(node: &A11yNode) -> Option<(&A11yNode, &A11yNode)> { if let Some(children) = &node.children { let send_btn = children.iter().find(|c| { - c.role == "push-button" && c.name == "Send(S)" + c.role == "push-button" && is_send_button_name(&c.name) }); let edit_node = children.iter().find(|c| { c.role == "text" diff --git a/packages/agent-server-rust/src/tools/ui_open_chat.rs b/packages/agent-server-rust/src/tools/ui_open_chat.rs index b8991de5..3383480b 100644 --- a/packages/agent-server-rust/src/tools/ui_open_chat.rs +++ b/packages/agent-server-rust/src/tools/ui_open_chat.rs @@ -15,6 +15,7 @@ use super::exec::{exec_command, ExecOptions}; use super::wechat_chats; use super::wechat_keys::get_stored_keys; use crate::db::get_db; +use crate::ia::selectors::is_send_button_name; use crate::sessions::manager::get_session; use serde_json::Value; @@ -172,14 +173,29 @@ fn find_first_result(tree: &Value, below_y: i32) -> Option { best } -/// True if a message composer (Send button) is present → a chat is open. +/// True if a message composer is present → a chat is open. Locale-robust: the +/// send button is "Send(S)" on EN clients, "发送(S)" on ZH clients (the EMDE +/// locale). Confirmed via a send-like push-button; logs redacted counts only. fn chat_is_open(tree: &Value) -> bool { let mut nodes = Vec::new(); collect(tree, &mut nodes); - nodes.iter().any(|n| { - role_of(n) == "push-button" - && n.get("name").and_then(|v| v.as_str()) == Some("Send(S)") - }) + let mut send_like = 0usize; + let mut editable = 0usize; + for n in &nodes { + let name = n.get("name").and_then(|v| v.as_str()).unwrap_or(""); + if role_of(n) == "push-button" && is_send_button_name(name) { + send_like += 1; + } + if role_of(n).contains("text") && has_state(n, "EDITABLE") { + editable += 1; + } + } + tracing::info!( + "[open-chat] confirm editable_count={} send_button_count={}", + editable, + send_like + ); + send_like > 0 } /// Largest visible Weixin window (the main UI). From 0aeca249693ced5d5c7f53fbff24b669c69e1fe8 Mon Sep 17 00:00:00 2001 From: Fieldy YuYang Date: Fri, 19 Jun 2026 01:10:48 +0800 Subject: [PATCH 07/14] fix: choose search-results list for a11y open-chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AW-FORK-7B showed open_confirmed=false because the open clicked the wrong list: WeChat 4.1.1 renders search results as a SEPARATE deeper list (depth ~15, few items) vs the main chat-list (shallow depth, many items). The old find_first_result picked the global topmost list-item -> a main-chat-list row, so the target never opened. - find_search_box_node: locate the search box WITH its tree depth (prefer FOCUSED editable). - select_result_first_item: pick the first row of a list that is deeper than the search box, has a modest item count (<10), and sits at/below the search box — excludes the main chat-list. Redacted diagnostics: lists_count / candidate_lists / selected_depth / selected_items. - keyboard fallback: if no results list is distinguishable, or a click didn't confirm, press Down+Return (search box focused) to open the first result. Logs keyboard_fallback=true/false. No names/ids/content logged. Co-Authored-By: Claude Opus 4.8 --- .../src/tools/ui_open_chat.rs | 172 +++++++++++++----- 1 file changed, 126 insertions(+), 46 deletions(-) diff --git a/packages/agent-server-rust/src/tools/ui_open_chat.rs b/packages/agent-server-rust/src/tools/ui_open_chat.rs index 3383480b..8878a192 100644 --- a/packages/agent-server-rust/src/tools/ui_open_chat.rs +++ b/packages/agent-server-rust/src/tools/ui_open_chat.rs @@ -42,6 +42,7 @@ impl OpenChatOutcome { } } +#[derive(Clone, Copy)] struct Rect { x: i32, y: i32, @@ -107,6 +108,24 @@ fn collect<'a>(node: &'a Value, out: &mut Vec<&'a Value>) { } } +/// Like `collect` but records each node's depth (root = 0). +fn collect_with_depth<'a>(node: &'a Value, depth: usize, out: &mut Vec<(usize, &'a Value)>) { + out.push((depth, node)); + if let Some(children) = node.get("children").and_then(|c| c.as_array()) { + for c in children { + collect_with_depth(c, depth + 1, out); + } + } +} + +/// Direct `list-item` children of a node. +fn list_item_children(node: &Value) -> Vec<&Value> { + node.get("children") + .and_then(|c| c.as_array()) + .map(|arr| arr.iter().filter(|c| role_of(c) == "list-item").collect()) + .unwrap_or_default() +} + fn rect_of(node: &Value) -> Option { let b = node.get("bounds")?; let x = b.get("x")?.as_f64()?; @@ -135,42 +154,73 @@ fn role_of(node: &Value) -> &str { node.get("role").and_then(|v| v.as_str()).unwrap_or("") } -/// Find the search box: the topmost EDITABLE text/entry node. In the chat-list -/// state (no chat open) this is WeChat's search field. -fn find_search_box(tree: &Value) -> Option { - let mut nodes = Vec::new(); - collect(tree, &mut nodes); - let mut best: Option = None; - for n in &nodes { +/// Find the search box with its tree depth: prefer a FOCUSED EDITABLE +/// text/entry node, else the topmost EDITABLE one. In the chat-list state this +/// is WeChat's search field. The depth lets us distinguish the (deep) +/// search-results list from the (shallow) main chat-list. +fn find_search_box_node(tree: &Value) -> Option<(usize, Rect)> { + let mut pairs = Vec::new(); + collect_with_depth(tree, 0, &mut pairs); + let mut focused: Option<(usize, Rect)> = None; + let mut topmost: Option<(usize, Rect)> = None; + for (depth, n) in &pairs { let role = role_of(n); - let editable = has_state(n, "EDITABLE"); - if editable && (role.contains("text") || role.contains("entry") || role.contains("field")) { + if has_state(n, "EDITABLE") + && (role.contains("text") || role.contains("entry") || role.contains("field")) + { if let Some(r) = rect_of(n) { - // Prefer the topmost candidate (search box sits above the chat list). - if best.as_ref().map(|b| r.y < b.y).unwrap_or(true) { - best = Some(r); + if topmost.map(|(_, b)| r.y < b.y).unwrap_or(true) { + topmost = Some((*depth, r)); + } + if has_state(n, "FOCUSED") && focused.map(|(_, b)| r.y < b.y).unwrap_or(true) { + focused = Some((*depth, r)); } } } } - best + focused.or(topmost) } -/// Find the first search-result row to click: topmost `list-item` with bounds. -fn find_first_result(tree: &Value, below_y: i32) -> Option { - let mut nodes = Vec::new(); - collect(tree, &mut nodes); - let mut best: Option = None; - for n in &nodes { - if role_of(n) == "list-item" { - if let Some(r) = rect_of(n) { - if r.y >= below_y && best.as_ref().map(|b| r.y < b.y).unwrap_or(true) { - best = Some(r); - } - } +/// Choose the first row of the **search-results** list (not the main chat-list). +/// Returns (lists_count, candidate_lists_count, Some((depth, item_count, first_item_rect))). +/// +/// WeChat 4.1.1 renders search results as a SEPARATE, deeper `list` than the +/// main conversation list. The main chat-list is shallow (small depth) with many +/// items; the search-results list is deeper than the focused search box, with a +/// small item count, appearing below the search box. Selecting the global +/// topmost `list-item` (the old logic) wrongly hit a main-chat-list row. +fn select_result_first_item( + pairs: &[(usize, &Value)], + search_depth: usize, + search_y: i32, +) -> (usize, usize, Option<(usize, usize, Rect)>) { + let mut lists_count = 0usize; + let mut candidates: Vec<(usize, usize, Rect)> = Vec::new(); + for (depth, n) in pairs { + if role_of(n) != "list" { + continue; + } + lists_count += 1; + let items = list_item_children(n); + if items.is_empty() { + continue; + } + let first = match rect_of(items[0]) { + Some(r) => r, + None => continue, + }; + // Search-results list heuristic: deeper than the search box, modest item + // count (the main chat-list has many), first row at/below the search box. + if *depth > search_depth && items.len() < 10 && first.y >= search_y { + candidates.push((*depth, items.len(), first)); } } - best + let chosen = candidates.iter().copied().min_by(|a, b| { + let da = (a.2.y - search_y).abs(); + let db = (b.2.y - search_y).abs(); + da.cmp(&db).then(b.0.cmp(&a.0)) + }); + (lists_count, candidates.len(), chosen) } /// True if a message composer is present → a chat is open. Locale-robust: the @@ -266,14 +316,15 @@ pub async fn open_chat_a11y_search(chat_id: &str, dry_run: bool) -> OpenChatOutc return out; } let tree = tree.unwrap(); - let search_box = find_search_box(&tree); + let search_box = find_search_box_node(&tree); // Coordinate fallback: WeChat's search field sits near the top-left. - let (sx, sy) = match &search_box { - Some(r) => { + // search_depth = usize::MAX when not found → forces the keyboard fallback. + let (search_depth, sx, sy) = match &search_box { + Some((d, r)) => { out.search_box_present = true; - (r.x + r.w / 2, r.y + r.h / 2) + (*d, r.x + r.w / 2, r.y + r.h / 2) } - None => (win.x + (win.w as f64 * 0.12) as i32, win.y + 45), + None => (usize::MAX, win.x + (win.w as f64 * 0.12) as i32, win.y + 45), }; tracing::info!( @@ -295,7 +346,7 @@ pub async fn open_chat_a11y_search(chat_id: &str, dry_run: bool) -> OpenChatOutc let _ = xdotool(&["type", "--clearmodifiers", "--", name.as_str()]).await; tokio::time::sleep(std::time::Duration::from_millis(1500)).await; - // 5. Click the first search result (below the search box). + // 5. Click the first row of the SEARCH-RESULTS list (not a main chat-list row). let after_type = match a11y_tree().await { Some(t) => t, None => { @@ -303,30 +354,59 @@ pub async fn open_chat_a11y_search(chat_id: &str, dry_run: bool) -> OpenChatOutc return out; } }; - let result = find_first_result(&after_type, sy + 1); - let result = match result { - Some(r) => r, + let mut pairs = Vec::new(); + collect_with_depth(&after_type, 0, &mut pairs); + let (lists_count, candidate_lists, chosen) = + select_result_first_item(&pairs, search_depth, sy); + + let mut keyboard_fallback = false; + match chosen { + Some((sel_depth, sel_items, first)) => { + tracing::info!( + "[open-chat] search_results lists_count={} candidate_lists={} selected_depth={} selected_items={}", + lists_count, + candidate_lists, + sel_depth, + sel_items + ); + click_at(first.x + first.w / 2, first.y + first.h / 2).await; + out.result_clicked = true; + } None => { - out.error = Some("result_not_found"); - tracing::warn!("[open-chat] method=a11y_search result_not_found"); - return out; + // No distinguishable results list → keyboard fallback: with the + // search box focused, Down+Return opens the first result. + tracing::info!( + "[open-chat] search_results lists_count={} candidate_lists=0 keyboard_fallback=true", + lists_count + ); + let _ = xdotool(&["key", "--clearmodifiers", "Down"]).await; + let _ = xdotool(&["key", "--clearmodifiers", "Return"]).await; + keyboard_fallback = true; + out.result_clicked = true; } - }; - click_at(result.x + result.w / 2, result.y + result.h / 2).await; - out.result_clicked = true; + } tokio::time::sleep(std::time::Duration::from_millis(1000)).await; - // 6. Confirm a chat opened (message composer / Send button present). - if let Some(confirm_tree) = a11y_tree().await { - out.open_confirmed = chat_is_open(&confirm_tree); + // 6. Confirm a chat opened (locale-robust composer detection). If a click + // didn't confirm, try the keyboard fallback once before giving up. + let mut confirmed = a11y_tree().await.map(|t| chat_is_open(&t)).unwrap_or(false); + if !confirmed && !keyboard_fallback { + tracing::info!("[open-chat] click_not_confirmed retry keyboard_fallback=true"); + let _ = xdotool(&["key", "--clearmodifiers", "Down"]).await; + let _ = xdotool(&["key", "--clearmodifiers", "Return"]).await; + keyboard_fallback = true; + tokio::time::sleep(std::time::Duration::from_millis(1000)).await; + confirmed = a11y_tree().await.map(|t| chat_is_open(&t)).unwrap_or(false); } + out.open_confirmed = confirmed; if !out.open_confirmed { out.error = Some("open_not_confirmed"); } tracing::info!( - "[open-chat] method=a11y_search result_clicked={} open_confirmed={}", + "[open-chat] method=a11y_search result_clicked={} open_confirmed={} keyboard_fallback={}", out.result_clicked, - out.open_confirmed + out.open_confirmed, + keyboard_fallback ); out } From d960d1216f6726dd5116cee5b3ca33775b42124d Mon Sep 17 00:00:00 2001 From: Fieldy YuYang Date: Fri, 19 Jun 2026 02:07:03 +0800 Subject: [PATCH 08/14] fix: prevent duplicate sends in send-safe open path AW-FORK-8 produced 2 outgoing messages from 1 authorized send: the send-path open used the a11y keyboard fallback (Down+Return) which, with the composer focused, injected a stray message before the real one. - OpenChatOptions { dry_run, allow_keyboard_fallback, send_safe } + open_chat_a11y_search_with_options. Endpoint keeps fallback (OpenChatOptions::endpoint); send path uses OpenChatOptions::send_safe (open_chat_a11y_search_send_safe): click-only, NEVER presses Return, fails closed (keyboard_fallback_suppressed / open_not_confirmed_send_safe). - SendMessagePlan Opening now requires fb.open_confirmed (not mere result_clicked) and uses the send-safe open. - SendMessagePlanState.send_action_executed guard: a plan run emits at most one send action (send_action_guard_triggered). composer_not_found log added. - Redacted logs only. Co-Authored-By: Claude Opus 4.8 --- .../src/plans/send_message.rs | 34 ++++-- .../src/tools/ui_open_chat.rs | 104 ++++++++++++++---- 2 files changed, 106 insertions(+), 32 deletions(-) diff --git a/packages/agent-server-rust/src/plans/send_message.rs b/packages/agent-server-rust/src/plans/send_message.rs index e3bb64a6..83e7aa51 100644 --- a/packages/agent-server-rust/src/plans/send_message.rs +++ b/packages/agent-server-rust/src/plans/send_message.rs @@ -27,6 +27,8 @@ pub struct SendMessagePlanState { pub phase: SendMessagePhase, pub open_result: Option, pub confirm_attempts: u32, + /// Guard: a single send plan run may emit at most one actual send action. + pub send_action_executed: bool, } fn find_edit_and_send_button(a11y: &A11yNode) -> Option<(&A11yNode, &A11yNode)> { @@ -74,6 +76,7 @@ impl Plan for SendMessagePlan { phase: SendMessagePhase::Opening, open_result: None, confirm_attempts: 0, + send_action_executed: false, } } @@ -124,20 +127,18 @@ impl Plan for SendMessagePlan { // AW-FORK-7). Open via a11y search instead so send no longer // dies at "No action selected". Redacted diagnostics only. tracing::warn!( - "[send] open_chat fast_path_failed fallback=a11y_search prev_error_present={}", + "[send] open_chat fast_path_failed fallback=a11y_search send_safe=true keyboard_fallback_allowed=false prev_error_present={}", result.error.is_some() ); - let fb = crate::tools::ui_open_chat::open_chat_a11y_search( + // SEND-SAFE: click-only open, never presses Return, so it + // cannot inject a stray message (AW-FORK-8B). Require an + // explicit open_confirmed; do NOT proceed on a mere click. + let fb = crate::tools::ui_open_chat::open_chat_a11y_search_send_safe( ¶ms.chat_id, - false, ) .await; - if fb.open_confirmed || fb.result_clicked { - tracing::info!( - "[send] fallback=a11y_search result_clicked={} open_confirmed={}", - fb.result_clicked, - fb.open_confirmed - ); + if fb.open_confirmed { + tracing::info!("[send] fallback=a11y_search send_safe open_confirmed=true"); result = OpenChatResult { ok: true, username: None, @@ -147,7 +148,7 @@ impl Plan for SendMessagePlan { }; } else { tracing::warn!( - "[send] fallback=a11y_search failed error={:?}", + "[send] open_chat send_safe_failed error={:?}", fb.error ); return None; @@ -202,8 +203,21 @@ impl Plan for SendMessagePlan { SendMessagePhase::Inputting => { let found = find_edit_and_send_button(a11y); if found.is_none() { + tracing::warn!("[send] composer_not_found"); + return None; + } + + // Single-send guard: never emit a second send action within one + // plan run (AW-FORK-8B defense-in-depth against duplicates). + if plan_state.send_action_executed { + tracing::warn!("[send] send_action_guard_triggered"); + plan_state.phase = SendMessagePhase::Done; return None; } + plan_state.send_action_executed = true; + tracing::info!( + "[send] composer_pair_found=true composer_cleared=true send_action_count=1" + ); plan_state.phase = SendMessagePhase::Confirming; diff --git a/packages/agent-server-rust/src/tools/ui_open_chat.rs b/packages/agent-server-rust/src/tools/ui_open_chat.rs index 8878a192..0636a987 100644 --- a/packages/agent-server-rust/src/tools/ui_open_chat.rs +++ b/packages/agent-server-rust/src/tools/ui_open_chat.rs @@ -277,7 +277,45 @@ async fn click_at(x: i32, y: i32) { } /// Open a chat by id using the version-robust a11y search path. +/// Options controlling open-chat behavior. `send_safe` (used by the outbound +/// send path) forbids ANY keyboard fallback (Down/Return) so the open step can +/// never inject a stray message into a focused composer — the root cause of the +/// AW-FORK-8 duplicate-send defect. +#[derive(Clone, Copy)] +pub struct OpenChatOptions { + pub dry_run: bool, + pub allow_keyboard_fallback: bool, + pub send_safe: bool, +} + +impl OpenChatOptions { + /// Standalone `/api/ui/open-chat`: keyboard fallback allowed. + pub fn endpoint(dry_run: bool) -> Self { + OpenChatOptions { dry_run, allow_keyboard_fallback: true, send_safe: false } + } + /// Send path: click-only, no keyboard fallback, fail closed. + pub fn send_safe() -> Self { + OpenChatOptions { dry_run: false, allow_keyboard_fallback: false, send_safe: true } + } +} + +/// Open a chat by id (endpoint default: keyboard fallback allowed). pub async fn open_chat_a11y_search(chat_id: &str, dry_run: bool) -> OpenChatOutcome { + open_chat_a11y_search_with_options(chat_id, OpenChatOptions::endpoint(dry_run)).await +} + +/// Send-safe open: click-only, never presses Return, so it cannot send a +/// message. Fails closed (`open_not_confirmed_send_safe` / +/// `keyboard_fallback_suppressed`) rather than risk a stray send. +pub async fn open_chat_a11y_search_send_safe(chat_id: &str) -> OpenChatOutcome { + open_chat_a11y_search_with_options(chat_id, OpenChatOptions::send_safe()).await +} + +/// Open a chat by id using the version-robust a11y search path. +pub async fn open_chat_a11y_search_with_options( + chat_id: &str, + opts: OpenChatOptions, +) -> OpenChatOutcome { let mut out = OpenChatOutcome { method: "a11y_search", chat_id_present: !chat_id.is_empty(), @@ -328,12 +366,13 @@ pub async fn open_chat_a11y_search(chat_id: &str, dry_run: bool) -> OpenChatOutc }; tracing::info!( - "[open-chat] method=a11y_search search_box_present={} dry_run={}", + "[open-chat] method=a11y_search search_box_present={} dry_run={} send_safe={}", out.search_box_present, - dry_run + opts.dry_run, + opts.send_safe ); - if dry_run { + if opts.dry_run { // Resolve + detect only; no typing/clicking. return out; } @@ -373,34 +412,55 @@ pub async fn open_chat_a11y_search(chat_id: &str, dry_run: bool) -> OpenChatOutc out.result_clicked = true; } None => { - // No distinguishable results list → keyboard fallback: with the - // search box focused, Down+Return opens the first result. - tracing::info!( - "[open-chat] search_results lists_count={} candidate_lists=0 keyboard_fallback=true", - lists_count - ); - let _ = xdotool(&["key", "--clearmodifiers", "Down"]).await; - let _ = xdotool(&["key", "--clearmodifiers", "Return"]).await; - keyboard_fallback = true; - out.result_clicked = true; + if opts.allow_keyboard_fallback { + // No distinguishable results list → keyboard fallback: with the + // search box focused, Down+Return opens the first result. + tracing::info!( + "[open-chat] search_results lists_count={} candidate_lists=0 keyboard_fallback=true", + lists_count + ); + let _ = xdotool(&["key", "--clearmodifiers", "Down"]).await; + let _ = xdotool(&["key", "--clearmodifiers", "Return"]).await; + keyboard_fallback = true; + out.result_clicked = true; + } else { + // Send-safe: NEVER press Return (could send a stray message into + // a focused composer). Fail closed instead. + tracing::warn!( + "[open-chat] keyboard_fallback_suppressed send_safe={} lists_count={} candidate_lists=0", + opts.send_safe, + lists_count + ); + out.error = Some("keyboard_fallback_suppressed"); + return out; + } } } tokio::time::sleep(std::time::Duration::from_millis(1000)).await; - // 6. Confirm a chat opened (locale-robust composer detection). If a click - // didn't confirm, try the keyboard fallback once before giving up. + // 6. Confirm a chat opened (locale-robust composer detection). In endpoint + // mode, a click that didn't confirm may retry via keyboard fallback. In + // send-safe mode we NEVER press Return — fail closed instead. let mut confirmed = a11y_tree().await.map(|t| chat_is_open(&t)).unwrap_or(false); if !confirmed && !keyboard_fallback { - tracing::info!("[open-chat] click_not_confirmed retry keyboard_fallback=true"); - let _ = xdotool(&["key", "--clearmodifiers", "Down"]).await; - let _ = xdotool(&["key", "--clearmodifiers", "Return"]).await; - keyboard_fallback = true; - tokio::time::sleep(std::time::Duration::from_millis(1000)).await; - confirmed = a11y_tree().await.map(|t| chat_is_open(&t)).unwrap_or(false); + if opts.allow_keyboard_fallback { + tracing::info!("[open-chat] click_not_confirmed retry keyboard_fallback=true"); + let _ = xdotool(&["key", "--clearmodifiers", "Down"]).await; + let _ = xdotool(&["key", "--clearmodifiers", "Return"]).await; + keyboard_fallback = true; + tokio::time::sleep(std::time::Duration::from_millis(1000)).await; + confirmed = a11y_tree().await.map(|t| chat_is_open(&t)).unwrap_or(false); + } else { + tracing::warn!("[open-chat] click_not_confirmed send_safe=true no_keyboard_retry"); + } } out.open_confirmed = confirmed; if !out.open_confirmed { - out.error = Some("open_not_confirmed"); + out.error = Some(if opts.send_safe { + "open_not_confirmed_send_safe" + } else { + "open_not_confirmed" + }); } tracing::info!( "[open-chat] method=a11y_search result_clicked={} open_confirmed={} keyboard_fallback={}", From 5b5789174df036c15ac53467b5350bdd83130975 Mon Sep 17 00:00:00 2001 From: Fieldy YuYang Date: Fri, 19 Jun 2026 02:57:23 +0800 Subject: [PATCH 09/14] fix: guard keyboard fallback and reuse already-open chat in send path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AW-FORK-8C: the standalone open-chat keyboard fallback (Down+Return) sent a stray when a composer was focused; and the send-safe open could not complete when the chat was already open (candidate_lists=0). - focused_editable_is_search_box + safe_keyboard_open: NEVER press Return unless the FOCUSED editable node is the search box. Both keyboard-fallback sites (including the standalone endpoint) route through it; on unsafe focus it returns error keyboard_fallback_unsafe_focus and does not press Return. - SendMessagePlan Opening: if mainWindow=chat_open and a composer pair is already present, skip the a11y re-search entirely (already_chat_open skip_reopen) — this is the AW-FORK-8C state and avoids both the flaky re-search and re-typing into a focused composer. Send path still forbids keyboard fallback. - Doc note on /api/ui/open-chat: do not use dryRun:false as a pre-send precheck. - Redacted logs only. Co-Authored-By: Claude Opus 4.8 --- .../src/plans/send_message.rs | 21 +++++ packages/agent-server-rust/src/router/ui.rs | 7 ++ .../src/tools/ui_open_chat.rs | 87 +++++++++++++++---- 3 files changed, 99 insertions(+), 16 deletions(-) diff --git a/packages/agent-server-rust/src/plans/send_message.rs b/packages/agent-server-rust/src/plans/send_message.rs index 83e7aa51..ab7c9d0c 100644 --- a/packages/agent-server-rust/src/plans/send_message.rs +++ b/packages/agent-server-rust/src/plans/send_message.rs @@ -122,6 +122,26 @@ impl Plan for SendMessagePlan { let mut result = open_chat(¶ms.chat_id, force, click_xy).await; if !result.ok { + // Already-open shortcut (AW-FORK-8D): if a composer pair is + // already present (mainWindow=chat_open), the target chat is + // open — skip the a11y re-search entirely. This avoids the + // unreliable already-open re-search (candidate_lists=0) that + // made the send fail in AW-FORK-8C, and never re-types into a + // focused composer. + if main_state_id == Some("chat_open") + && find_edit_and_send_button(a11y).is_some() + { + tracing::info!( + "[send] already_chat_open composer_present=true skip_reopen=true" + ); + result = OpenChatResult { + ok: true, + username: None, + index: None, + skipped: Some(true), + error: None, + }; + } else { // Version-robust fallback: the frida chat-select fast-path // failed (e.g. unknown BUILD_PROFILE on newer WeChat builds, // AW-FORK-7). Open via a11y search instead so send no longer @@ -153,6 +173,7 @@ impl Plan for SendMessagePlan { ); return None; } + } } let skipped = result.skipped.unwrap_or(false); diff --git a/packages/agent-server-rust/src/router/ui.rs b/packages/agent-server-rust/src/router/ui.rs index 7c6eed3b..ab2f45e7 100644 --- a/packages/agent-server-rust/src/router/ui.rs +++ b/packages/agent-server-rust/src/router/ui.rs @@ -71,6 +71,13 @@ pub struct OpenChatResponse { /// search path (id → display name via decrypted DB → search box → first /// result). Body: `{ "chatId": "...", "dryRun": false }`. Redacted: never /// returns the chat name/id or any chat content. +/// +/// OPERATIONAL DISCIPLINE: do NOT use `dryRun:false` as a pre-send "precheck". +/// `dryRun:false` types into the UI and may keyboard-open; if a composer is +/// focused it can produce a stray send (AW-FORK-8C). For a pre-send check use +/// `dryRun:true` only, or let `SendMessagePlan` perform its own send-safe open. +/// (The keyboard fallback here is now focus-guarded — it never presses Return +/// unless the search box is the focused element — but the discipline still holds.) pub async fn open_chat_handler(Json(req): Json) -> Json { let o = open_chat_a11y_search(&req.chat_id, req.dry_run).await; Json(OpenChatResponse { diff --git a/packages/agent-server-rust/src/tools/ui_open_chat.rs b/packages/agent-server-rust/src/tools/ui_open_chat.rs index 0636a987..98a1d6ca 100644 --- a/packages/agent-server-rust/src/tools/ui_open_chat.rs +++ b/packages/agent-server-rust/src/tools/ui_open_chat.rs @@ -276,6 +276,56 @@ async fn click_at(x: i32, y: i32) { let _ = xdotool(&["mousemove", xs.as_str(), ys.as_str(), "click", "1"]).await; } +/// True iff the currently-FOCUSED editable node is the search box (its center +/// lies within the known search-box rect). Used to gate any keyboard `Return`: +/// if focus is a chat composer (or unknown, or no search rect), this returns +/// false so we never press Return into a composer (AW-FORK-8D stray-send fix). +fn focused_editable_is_search_box(tree: &Value, search_rect: Option) -> bool { + let search = match search_rect { + Some(r) => r, + None => return false, // coordinate-fallback search box → cannot verify → unsafe + }; + let mut nodes = Vec::new(); + collect(tree, &mut nodes); + for n in &nodes { + let role = role_of(n); + if has_state(n, "FOCUSED") + && has_state(n, "EDITABLE") + && (role.contains("text") || role.contains("entry") || role.contains("field")) + { + return match rect_of(n) { + Some(r) => { + let cx = r.x + r.w / 2; + let cy = r.y + r.h / 2; + cx >= search.x - 5 + && cx <= search.x + search.w + 5 + && cy >= search.y - 5 + && cy <= search.y + search.h + 5 + } + None => false, + }; + } + } + false +} + +/// Issue Down+Return to open the first search result — but ONLY after verifying +/// the focused editable is the search box. Returns true if the keys were sent, +/// false if blocked (unsafe focus → never press Return into a composer). +async fn safe_keyboard_open(search_rect: Option) -> bool { + let tree = match a11y_tree().await { + Some(t) => t, + None => return false, + }; + if !focused_editable_is_search_box(&tree, search_rect) { + tracing::warn!("[open-chat] keyboard_fallback_blocked unsafe_focus=true focused_kind=composer_or_unknown"); + return false; + } + let _ = xdotool(&["key", "--clearmodifiers", "Down"]).await; + let _ = xdotool(&["key", "--clearmodifiers", "Return"]).await; + true +} + /// Open a chat by id using the version-robust a11y search path. /// Options controlling open-chat behavior. `send_safe` (used by the outbound /// send path) forbids ANY keyboard fallback (Down/Return) so the open step can @@ -357,12 +407,12 @@ pub async fn open_chat_a11y_search_with_options( let search_box = find_search_box_node(&tree); // Coordinate fallback: WeChat's search field sits near the top-left. // search_depth = usize::MAX when not found → forces the keyboard fallback. - let (search_depth, sx, sy) = match &search_box { + let (search_depth, search_rect, sx, sy) = match &search_box { Some((d, r)) => { out.search_box_present = true; - (*d, r.x + r.w / 2, r.y + r.h / 2) + (*d, Some(*r), r.x + r.w / 2, r.y + r.h / 2) } - None => (usize::MAX, win.x + (win.w as f64 * 0.12) as i32, win.y + 45), + None => (usize::MAX, None, win.x + (win.w as f64 * 0.12) as i32, win.y + 45), }; tracing::info!( @@ -413,16 +463,19 @@ pub async fn open_chat_a11y_search_with_options( } None => { if opts.allow_keyboard_fallback { - // No distinguishable results list → keyboard fallback: with the - // search box focused, Down+Return opens the first result. + // No distinguishable results list → keyboard fallback, but ONLY + // if the search box is focused (never Return into a composer). tracing::info!( - "[open-chat] search_results lists_count={} candidate_lists=0 keyboard_fallback=true", + "[open-chat] search_results lists_count={} candidate_lists=0 keyboard_fallback_attempt=true", lists_count ); - let _ = xdotool(&["key", "--clearmodifiers", "Down"]).await; - let _ = xdotool(&["key", "--clearmodifiers", "Return"]).await; - keyboard_fallback = true; - out.result_clicked = true; + if safe_keyboard_open(search_rect).await { + keyboard_fallback = true; + out.result_clicked = true; + } else { + out.error = Some("keyboard_fallback_unsafe_focus"); + return out; + } } else { // Send-safe: NEVER press Return (could send a stray message into // a focused composer). Fail closed instead. @@ -444,12 +497,14 @@ pub async fn open_chat_a11y_search_with_options( let mut confirmed = a11y_tree().await.map(|t| chat_is_open(&t)).unwrap_or(false); if !confirmed && !keyboard_fallback { if opts.allow_keyboard_fallback { - tracing::info!("[open-chat] click_not_confirmed retry keyboard_fallback=true"); - let _ = xdotool(&["key", "--clearmodifiers", "Down"]).await; - let _ = xdotool(&["key", "--clearmodifiers", "Return"]).await; - keyboard_fallback = true; - tokio::time::sleep(std::time::Duration::from_millis(1000)).await; - confirmed = a11y_tree().await.map(|t| chat_is_open(&t)).unwrap_or(false); + tracing::info!("[open-chat] click_not_confirmed retry keyboard_fallback_attempt=true"); + if safe_keyboard_open(search_rect).await { + keyboard_fallback = true; + tokio::time::sleep(std::time::Duration::from_millis(1000)).await; + confirmed = a11y_tree().await.map(|t| chat_is_open(&t)).unwrap_or(false); + } else { + tracing::warn!("[open-chat] retry keyboard_fallback_blocked unsafe_focus=true"); + } } else { tracing::warn!("[open-chat] click_not_confirmed send_safe=true no_keyboard_retry"); } From ab4f2458f944e86a127fdbdc8d72a7d2b5b752a5 Mon Sep 17 00:00:00 2001 From: Fieldy YuYang Date: Sat, 20 Jun 2026 23:20:51 +0800 Subject: [PATCH 10/14] fix: force target open before sending messages AW-FORK-19: SendMessagePlan no longer reuses whatever chat is already open on a send. The `already_chat_open => skip_reopen` shortcut assumed any present composer was the target and mis-routed a group send into a still-open private chat (AW-FORK-18E). Now every send opens the intended target by name via the send-safe a11y search (click-only, never Return) and fails closed if the target open can't be confirmed. Decision routed through decide_send_open_policy() with a regression test: composer-present-alone => ForceOpenTarget. Co-Authored-By: Claude Opus 4.8 --- .../src/plans/send_message.rs | 158 ++++++++++++------ 1 file changed, 107 insertions(+), 51 deletions(-) diff --git a/packages/agent-server-rust/src/plans/send_message.rs b/packages/agent-server-rust/src/plans/send_message.rs index ab7c9d0c..056eb406 100644 --- a/packages/agent-server-rust/src/plans/send_message.rs +++ b/packages/agent-server-rust/src/plans/send_message.rs @@ -64,6 +64,32 @@ fn find_edit_send_pair(node: &A11yNode) -> Option<(&A11yNode, &A11yNode)> { None } +/// AW-FORK-19: whether a send may reuse the currently-open chat or must re-open +/// the intended target first. +/// +/// A present composer alone must NEVER imply the target chat is open. The old +/// `already_chat_open => skip_reopen` shortcut (AW-FORK-8D) made exactly that +/// assumption and mis-routed a group send into a still-open *private* chat +/// (AW-FORK-18E). Skipping the open is only safe when the currently-open chat's +/// identity has been positively verified to equal the intended target. The UI +/// layer has no reliable open-chat-identity read today, so callers pass `false` +/// and we always re-open the target (fail closed if that can't be confirmed). +#[derive(Debug, PartialEq, Eq)] +pub enum SendOpenDecision { + /// Must open the intended target before typing/sending. + ForceOpenTarget, + /// Safe to reuse the already-open chat (only when verified as the target). + SkipOpen, +} + +pub fn decide_send_open_policy(current_open_is_verified_target: bool) -> SendOpenDecision { + if current_open_is_verified_target { + SendOpenDecision::SkipOpen + } else { + SendOpenDecision::ForceOpenTarget + } +} + #[async_trait::async_trait] impl Plan for SendMessagePlan { type PlanState = SendMessagePlanState; @@ -122,57 +148,65 @@ impl Plan for SendMessagePlan { let mut result = open_chat(¶ms.chat_id, force, click_xy).await; if !result.ok { - // Already-open shortcut (AW-FORK-8D): if a composer pair is - // already present (mainWindow=chat_open), the target chat is - // open — skip the a11y re-search entirely. This avoids the - // unreliable already-open re-search (candidate_lists=0) that - // made the send fail in AW-FORK-8C, and never re-types into a - // focused composer. - if main_state_id == Some("chat_open") - && find_edit_and_send_button(a11y).is_some() - { - tracing::info!( - "[send] already_chat_open composer_present=true skip_reopen=true" - ); - result = OpenChatResult { - ok: true, - username: None, - index: None, - skipped: Some(true), - error: None, - }; - } else { - // Version-robust fallback: the frida chat-select fast-path - // failed (e.g. unknown BUILD_PROFILE on newer WeChat builds, - // AW-FORK-7). Open via a11y search instead so send no longer - // dies at "No action selected". Redacted diagnostics only. - tracing::warn!( - "[send] open_chat fast_path_failed fallback=a11y_search send_safe=true keyboard_fallback_allowed=false prev_error_present={}", - result.error.is_some() - ); - // SEND-SAFE: click-only open, never presses Return, so it - // cannot inject a stray message (AW-FORK-8B). Require an - // explicit open_confirmed; do NOT proceed on a mere click. - let fb = crate::tools::ui_open_chat::open_chat_a11y_search_send_safe( - ¶ms.chat_id, - ) - .await; - if fb.open_confirmed { - tracing::info!("[send] fallback=a11y_search send_safe open_confirmed=true"); - result = OpenChatResult { - ok: true, - username: None, - index: None, - skipped: Some(false), - error: None, - }; - } else { - tracing::warn!( - "[send] open_chat send_safe_failed error={:?}", - fb.error - ); - return None; - } + // AW-FORK-19 — CROSS-CHAT-SAFE OPEN. The frida fast-path + // failed (it always does on WeChat 4.1.1.x: unknown + // BUILD_PROFILE, AW-FORK-7). We must open the *intended + // target* by name and must NEVER reuse whatever chat is + // already open. The removed `already_chat_open => + // skip_reopen` shortcut assumed any open composer was the + // target, which mis-routed a group send into a still-open + // private chat (AW-FORK-18E). A present composer alone does + // not prove the target is open, so we always re-open it. + match decide_send_open_policy(/* verified target */ false) { + SendOpenDecision::SkipOpen => { + // Only reachable once a reliable open-chat-identity + // check exists (not today); reuse the open chat. + tracing::info!( + "[send] skip_reopen=true reason=verified_target_open" + ); + result = OpenChatResult { + ok: true, + username: None, + index: None, + skipped: Some(true), + error: None, + }; + } + SendOpenDecision::ForceOpenTarget => { + tracing::info!( + "[send] target_open_policy=always_open skip_reopen=false reason=cross_chat_safety mainWindow_open={} prev_error_present={}", + main_state_id == Some("chat_open"), + result.error.is_some() + ); + // SEND-SAFE: click-only open, never presses Return, + // so it cannot inject a stray (AW-FORK-8B). Resolves + // chat_id -> name -> search -> click the target row. + let fb = crate::tools::ui_open_chat::open_chat_a11y_search_send_safe( + ¶ms.chat_id, + ) + .await; + if fb.open_confirmed { + tracing::info!( + "[send] target_open_required=true target_open_ok=true resolved_name_present={}", + fb.resolved_name_present + ); + result = OpenChatResult { + ok: true, + username: None, + index: None, + skipped: Some(false), + error: None, + }; + } else { + // FAIL CLOSED: never fall back to sending into + // whatever chat is currently open. + tracing::warn!( + "[send] target_open_required=true target_open_ok=false error={:?} fail_closed=true", + fb.error + ); + return None; + } + } } } @@ -323,3 +357,25 @@ impl Plan for SendMessagePlan { } } } + +#[cfg(test)] +mod tests { + use super::{decide_send_open_policy, SendOpenDecision}; + + // AW-FORK-18E regression: a chat being open (composer present) is NOT a + // verified target, so a send must force-open the intended target. This is the + // exact case that previously mis-routed a group send into a private chat. + #[test] + fn composer_present_alone_forces_target_open() { + assert_eq!( + decide_send_open_policy(false), + SendOpenDecision::ForceOpenTarget + ); + } + + // Skipping the open is only allowed when the open chat is the verified target. + #[test] + fn only_verified_target_may_skip_open() { + assert_eq!(decide_send_open_policy(true), SendOpenDecision::SkipOpen); + } +} From 9deed01920d46c92b8f5f9bf19e96118348af809 Mon Sep 17 00:00:00 2001 From: Fieldy YuYang Date: Sun, 21 Jun 2026 00:16:22 +0800 Subject: [PATCH 11/14] fix: send-safe open Escapes an open chat before searching AW-FORK-20: when a send-safe open runs while ANOTHER chat is already open, the post-type search-results list can't be distinguished from the main chat list (candidate_lists=0) and the open fails closed (AW-FORK-19C). Before searching, if a chat is open, press Escape to return to the no-chat-open layout where the results list is detectable, then re-locate the search box. Escape never sends and we still never press Enter or type into a composer; if it can't clear, the candidate_lists=0 guard still fails closed (no mis-route). Gated via needs_pre_search_unfocus(send_safe, chat_open) with unit tests. Co-Authored-By: Claude Opus 4.8 --- .../src/tools/ui_open_chat.rs | 76 ++++++++++++++++++- 1 file changed, 74 insertions(+), 2 deletions(-) diff --git a/packages/agent-server-rust/src/tools/ui_open_chat.rs b/packages/agent-server-rust/src/tools/ui_open_chat.rs index 98a1d6ca..e68f765f 100644 --- a/packages/agent-server-rust/src/tools/ui_open_chat.rs +++ b/packages/agent-server-rust/src/tools/ui_open_chat.rs @@ -28,10 +28,22 @@ pub struct OpenChatOutcome { pub search_box_present: bool, pub result_clicked: bool, pub open_confirmed: bool, + /// AW-FORK-20: a send-safe Escape was issued before searching because another + /// chat was open (so the search-results list becomes detectable). + pub pre_search_unfocus: bool, /// One of the specific error codes (see module/endpoint docs); None on success. pub error: Option<&'static str>, } +/// AW-FORK-20: whether to issue a send-safe pre-search "unfocus" (Escape). With a +/// chat already open, the post-type search-results list can't be distinguished +/// from the main chat list (candidate_lists=0) and a send-safe open fails closed +/// (AW-FORK-19C). We Escape back to the no-chat-open layout first — only in +/// send-safe mode, and only when a chat is actually open. +pub fn needs_pre_search_unfocus(send_safe: bool, chat_open: bool) -> bool { + send_safe && chat_open +} + impl OpenChatOutcome { fn err(code: &'static str) -> Self { OpenChatOutcome { @@ -407,7 +419,7 @@ pub async fn open_chat_a11y_search_with_options( let search_box = find_search_box_node(&tree); // Coordinate fallback: WeChat's search field sits near the top-left. // search_depth = usize::MAX when not found → forces the keyboard fallback. - let (search_depth, search_rect, sx, sy) = match &search_box { + let (mut search_depth, mut search_rect, mut sx, mut sy) = match &search_box { Some((d, r)) => { out.search_box_present = true; (*d, Some(*r), r.x + r.w / 2, r.y + r.h / 2) @@ -423,10 +435,45 @@ pub async fn open_chat_a11y_search_with_options( ); if opts.dry_run { - // Resolve + detect only; no typing/clicking. + // Resolve + detect only; no typing/clicking (no Escape either). return out; } + // AW-FORK-20: send-safe reliability when ANOTHER chat is already open. With a + // chat open, the post-type search-results list can't be distinguished from the + // main chat list (candidate_lists=0), so a send-safe open fails closed + // (AW-FORK-19C). Before searching, if a chat is open, press Escape to return to + // the no-chat-open layout where the results list is detectable. Escape NEVER + // sends a message; we still never press Enter or type into a composer. If + // Escape fails to clear it, the later candidate_lists=0 guard still fails closed + // (safe — no mis-route). + if needs_pre_search_unfocus(opts.send_safe, chat_is_open(&tree)) { + let _ = xdotool(&["key", "--clearmodifiers", "Escape"]).await; + tokio::time::sleep(std::time::Duration::from_millis(350)).await; + let mut t2 = a11y_tree().await; + if t2.as_ref().map(|t| chat_is_open(t)).unwrap_or(false) { + // Still open → one more Escape. + let _ = xdotool(&["key", "--clearmodifiers", "Escape"]).await; + tokio::time::sleep(std::time::Duration::from_millis(350)).await; + t2 = a11y_tree().await; + } + let chat_open_after = t2.as_ref().map(|t| chat_is_open(t)).unwrap_or(false); + // Re-locate the search box from the refreshed tree (coords may shift). + if let Some(t) = &t2 { + if let Some((d, r)) = find_search_box_node(t) { + search_depth = d; + search_rect = Some(r); + sx = r.x + r.w / 2; + sy = r.y + r.h / 2; + } + } + out.pre_search_unfocus = true; + tracing::info!( + "[open-chat] send_safe pre_search_unfocus attempted=true method=escape chat_open_before=true chat_open_after={}", + chat_open_after + ); + } + // 4. Focus search, clear, type the resolved name. click_at(sx, sy).await; tokio::time::sleep(std::time::Duration::from_millis(300)).await; @@ -525,3 +572,28 @@ pub async fn open_chat_a11y_search_with_options( ); out } + +#[cfg(test)] +mod tests { + use super::needs_pre_search_unfocus; + + // AW-FORK-19C regression: a send-safe open while ANOTHER chat is open must + // Escape back to the no-chat-open layout first (else candidate_lists=0 → fail + // closed → no delivery). + #[test] + fn send_safe_with_chat_open_needs_unfocus() { + assert!(needs_pre_search_unfocus(true, true)); + } + + #[test] + fn send_safe_with_no_chat_open_skips_unfocus() { + assert!(!needs_pre_search_unfocus(true, false)); + } + + // Endpoint (non-send-safe) mode keeps its keyboard fallback; no Escape pre-step. + #[test] + fn non_send_safe_never_unfocuses() { + assert!(!needs_pre_search_unfocus(false, true)); + assert!(!needs_pre_search_unfocus(false, false)); + } +} From 1a5e1a5fd91055dfaae937e77f0a57ab88820fb9 Mon Sep 17 00:00:00 2001 From: Fieldy YuYang Date: Sun, 21 Jun 2026 01:15:08 +0800 Subject: [PATCH 12/14] diagnose: log search result lists in send-safe open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AW-FORK-21: when select_result_first_item finds no candidate search-results list (candidate_lists=0, the chat-open layout that fails closed in AW-FORK-19C/20B), log each list's depth/rect/item-count and which of the 3 heuristic conditions (deeper-than-search, items<10, first-at-or-below-search) excluded it, plus search_depth/search_y. Sanitized (numbers/booleans only). Diagnostic only — no behaviour change to the selection. Co-Authored-By: Claude Opus 4.8 --- .../src/tools/ui_open_chat.rs | 39 ++++++++++++++----- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/packages/agent-server-rust/src/tools/ui_open_chat.rs b/packages/agent-server-rust/src/tools/ui_open_chat.rs index e68f765f..ea5fa798 100644 --- a/packages/agent-server-rust/src/tools/ui_open_chat.rs +++ b/packages/agent-server-rust/src/tools/ui_open_chat.rs @@ -208,23 +208,44 @@ fn select_result_first_item( ) -> (usize, usize, Option<(usize, usize, Rect)>) { let mut lists_count = 0usize; let mut candidates: Vec<(usize, usize, Rect)> = Vec::new(); + // AW-FORK-21 diagnostic: per-list shape + which heuristic condition excluded it. + // Sanitized (numbers/booleans only — no text, names, or chat content). + let mut diag: Vec<(usize, usize, i32, i32, usize, i32, bool, bool, bool)> = Vec::new(); for (depth, n) in pairs { if role_of(n) != "list" { continue; } + let idx = lists_count; lists_count += 1; let items = list_item_children(n); - if items.is_empty() { - continue; - } - let first = match rect_of(items[0]) { - Some(r) => r, - None => continue, - }; + let list_rect = rect_of(n).unwrap_or(Rect { x: -1, y: -1, w: -1, h: -1 }); + let first = items.first().and_then(|it| rect_of(it)); + let first_y = first.map(|r| r.y).unwrap_or(-1); + let cond_deeper = *depth > search_depth; + let cond_items = !items.is_empty() && items.len() < 10; + let cond_below = first.map(|r| r.y >= search_y).unwrap_or(false); + diag.push(( + idx, *depth, list_rect.y, list_rect.h, items.len(), first_y, + cond_deeper, cond_items, cond_below, + )); // Search-results list heuristic: deeper than the search box, modest item // count (the main chat-list has many), first row at/below the search box. - if *depth > search_depth && items.len() < 10 && first.y >= search_y { - candidates.push((*depth, items.len(), first)); + if cond_deeper && cond_items && cond_below { + if let Some(r) = first { + candidates.push((*depth, items.len(), r)); + } + } + } + if candidates.is_empty() { + tracing::info!( + "[open-chat-diagnostic] candidate_lists=0 lists_count={} search_depth={} search_y={}", + lists_count, search_depth, search_y + ); + for (idx, depth, list_y, list_h, item_count, first_y, c_deep, c_items, c_below) in &diag { + tracing::info!( + "[open-chat-diagnostic] list idx={} depth={} list_y={} list_h={} item_count={} first_item_y={} cond_deeper_than_search={} cond_items_lt10={} cond_first_at_or_below_search={}", + idx, depth, list_y, list_h, item_count, first_y, c_deep, c_items, c_below + ); } } let chosen = candidates.iter().copied().min_by(|a, b| { From a43b12510042a21b2e69cc7daf11350e8d7ed0bf Mon Sep 17 00:00:00 2001 From: Fieldy YuYang Date: Sun, 21 Jun 2026 02:02:22 +0800 Subject: [PATCH 13/14] fix: choose top search box over focused composer AW-FORK-21B: find_search_box_node now selects the TOPMOST editable (the WeChat search field at the top-left) via choose_search_editable(), instead of focused.or(topmost). While a chat is open the bottom composer is EDITABLE+FOCUSED; the old logic picked it, so the resolved name was typed into the composer and no search results appeared (candidate_lists=0, AW-FORK-21). Now a focused bottom composer never wins. Adds search_box_policy=topmost_editable log + unit tests (topmost beats focused-bottom; focused doesn't auto-win). Send-safe invariants unchanged. Co-Authored-By: Claude Opus 4.8 --- .../src/tools/ui_open_chat.rs | 76 ++++++++++++++++--- 1 file changed, 67 insertions(+), 9 deletions(-) diff --git a/packages/agent-server-rust/src/tools/ui_open_chat.rs b/packages/agent-server-rust/src/tools/ui_open_chat.rs index ea5fa798..8212e962 100644 --- a/packages/agent-server-rust/src/tools/ui_open_chat.rs +++ b/packages/agent-server-rust/src/tools/ui_open_chat.rs @@ -170,27 +170,48 @@ fn role_of(node: &Value) -> &str { /// text/entry node, else the topmost EDITABLE one. In the chat-list state this /// is WeChat's search field. The depth lets us distinguish the (deep) /// search-results list from the (shallow) main chat-list. +/// AW-FORK-21B: choose the search box from EDITABLE candidates as the **topmost** +/// one (smallest y). The WeChat search field sits at the top-left of the main +/// window; a chat's message composer sits at the BOTTOM and is FOCUSED while a +/// chat is open. The old `focused.or(topmost)` let a focused bottom composer win, +/// so the resolved name was typed into the composer and no search results appeared +/// (`candidate_lists=0`, AW-FORK-21). Never prefer a focused editable here. +/// Candidates are `(depth, rect, focused)`. +fn choose_search_editable(candidates: &[(usize, Rect, bool)]) -> Option<(usize, Rect, bool)> { + candidates.iter().copied().min_by_key(|(_, r, _)| r.y) +} + fn find_search_box_node(tree: &Value) -> Option<(usize, Rect)> { let mut pairs = Vec::new(); collect_with_depth(tree, 0, &mut pairs); - let mut focused: Option<(usize, Rect)> = None; - let mut topmost: Option<(usize, Rect)> = None; + let mut candidates: Vec<(usize, Rect, bool)> = Vec::new(); for (depth, n) in &pairs { let role = role_of(n); if has_state(n, "EDITABLE") && (role.contains("text") || role.contains("entry") || role.contains("field")) { if let Some(r) = rect_of(n) { - if topmost.map(|(_, b)| r.y < b.y).unwrap_or(true) { - topmost = Some((*depth, r)); - } - if has_state(n, "FOCUSED") && focused.map(|(_, b)| r.y < b.y).unwrap_or(true) { - focused = Some((*depth, r)); - } + candidates.push((*depth, r, has_state(n, "FOCUSED"))); } } } - focused.or(topmost) + let chosen = choose_search_editable(&candidates); + if let Some((_, r, _)) = &chosen { + let focused_y = candidates + .iter() + .filter(|(_, _, f)| *f) + .map(|(_, rr, _)| rr.y) + .min() + .unwrap_or(-1); + let ignored = candidates.iter().any(|(_, rr, f)| *f && rr.y > r.y); + tracing::info!( + "[open-chat] search_box_policy=topmost_editable search_box_selected_y={} focused_editable_y={} focused_editable_ignored_as_composer={}", + r.y, + focused_y, + ignored + ); + } + chosen.map(|(d, r, _)| (d, r)) } /// Choose the first row of the **search-results** list (not the main chat-list). @@ -596,6 +617,43 @@ pub async fn open_chat_a11y_search_with_options( #[cfg(test)] mod tests { + use super::{choose_search_editable, Rect}; + + fn r(y: i32) -> Rect { + Rect { x: 0, y, w: 200, h: 30 } + } + + // AW-FORK-21 regression: with a chat open, the FOCUSED composer sits at the + // bottom; the search box is the topmost editable. The topmost must win. + #[test] + fn topmost_editable_beats_focused_bottom_composer() { + let search_box = (14usize, r(69), false); // top, not focused + let composer = (16usize, r(697), true); // bottom, focused + let chosen = choose_search_editable(&[composer, search_box]).unwrap(); + assert_eq!(chosen.1.y, 69); + assert!(!chosen.2, "must not pick the focused composer"); + } + + #[test] + fn focused_does_not_auto_win() { + let top = (14usize, r(45), false); + let focused_bottom = (16usize, r(700), true); + assert_eq!(choose_search_editable(&[top, focused_bottom]).unwrap().1.y, 45); + } + + #[test] + fn single_editable_is_chosen() { + assert_eq!(choose_search_editable(&[(14usize, r(50), true)]).unwrap().1.y, 50); + } + + #[test] + fn no_editable_is_none() { + assert!(choose_search_editable(&[]).is_none()); + } +} + +#[cfg(test)] +mod unfocus_tests { use super::needs_pre_search_unfocus; // AW-FORK-19C regression: a send-safe open while ANOTHER chat is open must From b8e3ef74f2cb89ddff96182cb8640bf978e1f838 Mon Sep 17 00:00:00 2001 From: Fieldy YuYang Date: Sun, 21 Jun 2026 02:51:19 +0800 Subject: [PATCH 14/14] fix: match search result row to target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AW-FORK-22: the open path now clicks the search-result row whose normalized name EXACTLY matches the resolved target (select_result_matching_target + pick_matching_row), instead of the blind first row that strayed a group send to a wrong private chat (§63). System chats (File Transfer / Weixin Team / 文件传输助手 / 微信团队) are denylisted. No unique match => fail closed (no click, no keyboard fallback) in both send-safe and endpoint modes; the keyboard fallback is removed. Adds normalize_name/is_denied_system_chat/pick_matching_row + unit tests (exact-not-first-row, denylist-skipped, no-match/ambiguous fail closed). Co-Authored-By: Claude Opus 4.8 --- .../src/tools/ui_open_chat.rs | 288 ++++++++++++------ 1 file changed, 199 insertions(+), 89 deletions(-) diff --git a/packages/agent-server-rust/src/tools/ui_open_chat.rs b/packages/agent-server-rust/src/tools/ui_open_chat.rs index 8212e962..a946c9b7 100644 --- a/packages/agent-server-rust/src/tools/ui_open_chat.rs +++ b/packages/agent-server-rust/src/tools/ui_open_chat.rs @@ -214,23 +214,92 @@ fn find_search_box_node(tree: &Value) -> Option<(usize, Rect)> { chosen.map(|(d, r, _)| (d, r)) } -/// Choose the first row of the **search-results** list (not the main chat-list). -/// Returns (lists_count, candidate_lists_count, Some((depth, item_count, first_item_rect))). -/// -/// WeChat 4.1.1 renders search results as a SEPARATE, deeper `list` than the -/// main conversation list. The main chat-list is shallow (small depth) with many -/// items; the search-results list is deeper than the focused search box, with a -/// small item count, appearing below the search box. Selecting the global -/// topmost `list-item` (the old logic) wrongly hit a main-chat-list row. -fn select_result_first_item( +/// AW-FORK-22: normalize a chat name for matching (trim, lowercase, drop +/// zero-width chars, collapse internal whitespace). +fn normalize_name(s: &str) -> String { + let cleaned: String = s + .chars() + .filter(|c| !matches!(*c, '\u{200b}' | '\u{200c}' | '\u{200d}' | '\u{feff}')) + .collect(); + cleaned.split_whitespace().collect::>().join(" ").to_lowercase() +} + +/// AW-FORK-22: system chats that must NEVER be chosen as a send target. +fn is_denied_system_chat(normalized: &str) -> bool { + matches!( + normalized, + "file transfer" | "weixin team" | "文件传输助手" | "微信团队" + ) +} + +/// Collect every descendant `name` string of a node (the row's label may sit on a +/// child of the list-item). +fn collect_names(node: &Value, out: &mut Vec) { + if let Some(name) = node.get("name").and_then(|v| v.as_str()) { + if !name.trim().is_empty() { + out.push(name.to_string()); + } + } + if let Some(children) = node.get("children").and_then(|c| c.as_array()) { + for c in children { + collect_names(c, out); + } + } +} + +/// AW-FORK-22: among search-result rows, pick the one whose (normalized) name +/// EXACTLY equals the target. Denylist system chats. Require a UNIQUE match: 0 => +/// `no_matching_target`, >1 => `ambiguous_matches`. NEVER falls back to the first +/// row (that strayed a group send to a wrong private chat, §63). Rows are +/// `(descendant_names, rect)`. Returns `(denied, exact_matches, selected, fail)`. +fn pick_matching_row( + rows: &[(Vec, Rect)], + target_norm: &str, +) -> (usize, usize, Option, Option<&'static str>) { + let mut denied = 0usize; + let mut matches: Vec = Vec::new(); + for (names, rect) in rows { + let norms: Vec = names + .iter() + .map(|n| normalize_name(n)) + .filter(|s| !s.is_empty()) + .collect(); + if norms.iter().any(|s| is_denied_system_chat(s)) { + denied += 1; + continue; + } + if !target_norm.is_empty() && norms.iter().any(|s| s == target_norm) { + matches.push(*rect); + } + } + match matches.len() { + 1 => (denied, 1, Some(matches[0]), None), + 0 => (denied, 0, None, Some("no_matching_target")), + n => (denied, n, None, Some("ambiguous_matches")), + } +} + +struct ResultSelection { + candidate_lists: usize, + rows_total: usize, + denied_count: usize, + exact_matches: usize, + selected: Option, + fail_reason: Option<&'static str>, +} + +/// AW-FORK-22: select the search-result row matching the resolved target (by name +/// + denylist) instead of blindly clicking the first row (§63 stray). Keeps the +/// AW-FORK-21 candidate-list-shape diagnostics for the no-results case. +fn select_result_matching_target( pairs: &[(usize, &Value)], search_depth: usize, search_y: i32, -) -> (usize, usize, Option<(usize, usize, Rect)>) { + target_norm: &str, +) -> ResultSelection { let mut lists_count = 0usize; - let mut candidates: Vec<(usize, usize, Rect)> = Vec::new(); - // AW-FORK-21 diagnostic: per-list shape + which heuristic condition excluded it. - // Sanitized (numbers/booleans only — no text, names, or chat content). + let mut candidate_lists = 0usize; + let mut rows: Vec<(Vec, Rect)> = Vec::new(); let mut diag: Vec<(usize, usize, i32, i32, usize, i32, bool, bool, bool)> = Vec::new(); for (depth, n) in pairs { if role_of(n) != "list" { @@ -245,19 +314,19 @@ fn select_result_first_item( let cond_deeper = *depth > search_depth; let cond_items = !items.is_empty() && items.len() < 10; let cond_below = first.map(|r| r.y >= search_y).unwrap_or(false); - diag.push(( - idx, *depth, list_rect.y, list_rect.h, items.len(), first_y, - cond_deeper, cond_items, cond_below, - )); - // Search-results list heuristic: deeper than the search box, modest item - // count (the main chat-list has many), first row at/below the search box. + diag.push((idx, *depth, list_rect.y, list_rect.h, items.len(), first_y, cond_deeper, cond_items, cond_below)); if cond_deeper && cond_items && cond_below { - if let Some(r) = first { - candidates.push((*depth, items.len(), r)); + candidate_lists += 1; + for it in &items { + if let Some(r) = rect_of(it) { + let mut names = Vec::new(); + collect_names(it, &mut names); + rows.push((names, r)); + } } } } - if candidates.is_empty() { + if candidate_lists == 0 { tracing::info!( "[open-chat-diagnostic] candidate_lists=0 lists_count={} search_depth={} search_y={}", lists_count, search_depth, search_y @@ -269,12 +338,15 @@ fn select_result_first_item( ); } } - let chosen = candidates.iter().copied().min_by(|a, b| { - let da = (a.2.y - search_y).abs(); - let db = (b.2.y - search_y).abs(); - da.cmp(&db).then(b.0.cmp(&a.0)) - }); - (lists_count, candidates.len(), chosen) + let (denied_count, exact_matches, selected, fail_reason) = pick_matching_row(&rows, target_norm); + ResultSelection { + candidate_lists, + rows_total: rows.len(), + denied_count, + exact_matches, + selected, + fail_reason, + } } /// True if a message composer is present → a chat is open. Locale-robust: the @@ -534,70 +606,40 @@ pub async fn open_chat_a11y_search_with_options( }; let mut pairs = Vec::new(); collect_with_depth(&after_type, 0, &mut pairs); - let (lists_count, candidate_lists, chosen) = - select_result_first_item(&pairs, search_depth, sy); - - let mut keyboard_fallback = false; - match chosen { - Some((sel_depth, sel_items, first)) => { - tracing::info!( - "[open-chat] search_results lists_count={} candidate_lists={} selected_depth={} selected_items={}", - lists_count, - candidate_lists, - sel_depth, - sel_items - ); - click_at(first.x + first.w / 2, first.y + first.h / 2).await; + // AW-FORK-22: click the search-result row whose name MATCHES the resolved + // target — never the blind first row (that strayed a group send to a wrong + // private chat, §63). No unique match → fail closed (no click, no keyboard + // fallback), for BOTH send-safe and endpoint modes. + let target_norm = normalize_name(&name); + let sel = select_result_matching_target(&pairs, search_depth, sy, &target_norm); + tracing::info!( + "[open-chat] result_match rows={} candidate_lists={} denied={} exact_matches={} selected={}", + sel.rows_total, + sel.candidate_lists, + sel.denied_count, + sel.exact_matches, + sel.selected.is_some() + ); + match sel.selected { + Some(rect) => { + click_at(rect.x + rect.w / 2, rect.y + rect.h / 2).await; out.result_clicked = true; } None => { - if opts.allow_keyboard_fallback { - // No distinguishable results list → keyboard fallback, but ONLY - // if the search box is focused (never Return into a composer). - tracing::info!( - "[open-chat] search_results lists_count={} candidate_lists=0 keyboard_fallback_attempt=true", - lists_count - ); - if safe_keyboard_open(search_rect).await { - keyboard_fallback = true; - out.result_clicked = true; - } else { - out.error = Some("keyboard_fallback_unsafe_focus"); - return out; - } - } else { - // Send-safe: NEVER press Return (could send a stray message into - // a focused composer). Fail closed instead. - tracing::warn!( - "[open-chat] keyboard_fallback_suppressed send_safe={} lists_count={} candidate_lists=0", - opts.send_safe, - lists_count - ); - out.error = Some("keyboard_fallback_suppressed"); - return out; - } + tracing::warn!( + "[open-chat] result_match_fail reason={} fail_closed=true", + sel.fail_reason.unwrap_or("result_no_match") + ); + out.error = Some(sel.fail_reason.unwrap_or("result_no_match")); + return out; } } tokio::time::sleep(std::time::Duration::from_millis(1000)).await; - // 6. Confirm a chat opened (locale-robust composer detection). In endpoint - // mode, a click that didn't confirm may retry via keyboard fallback. In - // send-safe mode we NEVER press Return — fail closed instead. - let mut confirmed = a11y_tree().await.map(|t| chat_is_open(&t)).unwrap_or(false); - if !confirmed && !keyboard_fallback { - if opts.allow_keyboard_fallback { - tracing::info!("[open-chat] click_not_confirmed retry keyboard_fallback_attempt=true"); - if safe_keyboard_open(search_rect).await { - keyboard_fallback = true; - tokio::time::sleep(std::time::Duration::from_millis(1000)).await; - confirmed = a11y_tree().await.map(|t| chat_is_open(&t)).unwrap_or(false); - } else { - tracing::warn!("[open-chat] retry keyboard_fallback_blocked unsafe_focus=true"); - } - } else { - tracing::warn!("[open-chat] click_not_confirmed send_safe=true no_keyboard_retry"); - } - } + // 6. Confirm a chat opened (locale-robust composer detection). With + // target-aware row matching (AW-FORK-22) we no longer keyboard-fallback; if + // the matched click didn't open a chat, fail closed. + let confirmed = a11y_tree().await.map(|t| chat_is_open(&t)).unwrap_or(false); out.open_confirmed = confirmed; if !out.open_confirmed { out.error = Some(if opts.send_safe { @@ -607,21 +649,23 @@ pub async fn open_chat_a11y_search_with_options( }); } tracing::info!( - "[open-chat] method=a11y_search result_clicked={} open_confirmed={} keyboard_fallback={}", + "[open-chat] method=a11y_search result_clicked={} open_confirmed={}", out.result_clicked, - out.open_confirmed, - keyboard_fallback + out.open_confirmed ); out } #[cfg(test)] mod tests { - use super::{choose_search_editable, Rect}; + use super::{choose_search_editable, is_denied_system_chat, normalize_name, pick_matching_row, Rect}; fn r(y: i32) -> Rect { Rect { x: 0, y, w: 200, h: 30 } } + fn row(name: &str, y: i32) -> (Vec, Rect) { + (vec![name.to_string()], r(y)) + } // AW-FORK-21 regression: with a chat open, the FOCUSED composer sits at the // bottom; the search box is the topmost editable. The topmost must win. @@ -650,6 +694,72 @@ mod tests { fn no_editable_is_none() { assert!(choose_search_editable(&[]).is_none()); } + + // AW-FORK-22 result matching ------------------------------------------------ + + #[test] + fn normalize_trims_lowercases_collapses() { + assert_eq!(normalize_name(" GRAILA Test \u{200b}Group "), "graila test group"); + } + + #[test] + fn denylist_blocks_system_chats() { + assert!(is_denied_system_chat("file transfer")); + assert!(is_denied_system_chat("文件传输助手")); + assert!(is_denied_system_chat("微信团队")); + assert!(!is_denied_system_chat("graila test group")); + } + + // §63 regression: first row is a WRONG private chat; the exact target is a + // later row → that later row must be selected (no first-row fallback). + #[test] + fn exact_target_selected_not_first_row() { + let rows = vec![row("Some Other Person", 100), row("GRAILA Test Group", 140)]; + let (_, exact, sel, fail) = pick_matching_row(&rows, &normalize_name("GRAILA Test Group")); + assert_eq!(exact, 1); + assert_eq!(sel.unwrap().y, 140); + assert!(fail.is_none()); + } + + #[test] + fn denied_system_row_skipped_target_selected() { + let rows = vec![row("File Transfer", 100), row("GRAILA Test Group", 140)]; + let (denied, _, sel, _) = pick_matching_row(&rows, &normalize_name("GRAILA Test Group")); + assert_eq!(denied, 1); + assert_eq!(sel.unwrap().y, 140); + } + + #[test] + fn no_matching_target_fails_closed() { + let rows = vec![row("Someone Else", 100), row("Another Chat", 140)]; + let (_, _, sel, fail) = pick_matching_row(&rows, &normalize_name("GRAILA Test Group")); + assert!(sel.is_none()); + assert_eq!(fail, Some("no_matching_target")); + } + + #[test] + fn ambiguous_matches_fail_closed() { + let rows = vec![row("GRAILA Test Group", 100), row("graila test group", 140)]; + let (_, _, sel, fail) = pick_matching_row(&rows, &normalize_name("GRAILA Test Group")); + assert!(sel.is_none()); + assert_eq!(fail, Some("ambiguous_matches")); + } + + #[test] + fn only_denied_row_no_match() { + let rows = vec![row("Weixin Team", 100)]; + let (denied, _, sel, fail) = pick_matching_row(&rows, &normalize_name("Weixin Team")); + assert_eq!(denied, 1); + assert!(sel.is_none()); + assert_eq!(fail, Some("no_matching_target")); + } + + #[test] + fn empty_target_never_matches() { + let rows = vec![row("Anything", 100)]; + let (_, _, sel, _) = pick_matching_row(&rows, ""); + assert!(sel.is_none()); + } } #[cfg(test)]