Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docker/.gitignore
Original file line number Diff line number Diff line change
@@ -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
24 changes: 18 additions & 6 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion docker/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

# ============================================
Expand Down
28 changes: 28 additions & 0 deletions packages/agent-server-rust/src/ia/selectors.rs
Original file line number Diff line number Diff line change
@@ -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
// ============================================
Expand Down Expand Up @@ -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![
Expand Down
4 changes: 2 additions & 2 deletions packages/agent-server-rust/src/plans/chat_open.rs
Original file line number Diff line number Diff line change
@@ -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};

Expand Down Expand Up @@ -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"
Expand Down
13 changes: 13 additions & 0 deletions packages/agent-server-rust/src/plans/login.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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![
Expand Down
147 changes: 130 additions & 17 deletions packages/agent-server-rust/src/plans/send_message.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand All @@ -27,28 +27,20 @@ pub struct SendMessagePlanState {
pub phase: SendMessagePhase,
pub open_result: Option<OpenChatResult>,
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)> {
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"
Expand All @@ -72,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;
Expand All @@ -84,6 +102,7 @@ impl Plan for SendMessagePlan {
phase: SendMessagePhase::Opening,
open_result: None,
confirm_attempts: 0,
send_action_executed: false,
}
}

Expand Down Expand Up @@ -126,10 +145,69 @@ impl Plan for SendMessagePlan {
});

let force = main_state_id == Some("chat");
let result = open_chat(&params.chat_id, force, click_xy).await;
let mut result = open_chat(&params.chat_id, force, click_xy).await;

if !result.ok {
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(
&params.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;
}
}
}
}

let skipped = result.skipped.unwrap_or(false);
Expand Down Expand Up @@ -180,9 +258,22 @@ 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;

// File
Expand Down Expand Up @@ -266,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);
}
}
10 changes: 10 additions & 0 deletions packages/agent-server-rust/src/router/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ mod events;
mod messages;
mod sessions;
mod status;
mod sync;
mod ui;
mod vnc;

use axum::{
Expand Down Expand Up @@ -53,6 +55,14 @@ 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))
// 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))
Expand Down
Loading