diff --git a/Cargo.toml b/Cargo.toml index 3d986a4..89a184a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -177,6 +177,7 @@ windows-sys = { version = "0.52", features = [ "Win32_System_Threading", # OpenProcess / QueryFullProcessImageNameW / GetProcessTimes (proxy_proc.rs); # also OpenProcessToken (profile_activation.rs is_running_elevated — bugfix # 2026-04-29-aikey-hook-update-eacces-and-sudo-silent-failure) + "Win32_System_Diagnostics_ToolHelp", # exact trust-local process-tree discovery before taskkill /T "Win32_Security", # GetTokenInformation / TOKEN_ELEVATION (profile_activation.rs is_running_elevated) "Win32_System_Time", # FileTimeToSystemTime / SystemTimeToTzSpecificLocalTime (commands_statusline.rs event_time_hm) "Win32_System_Console", # GetStdHandle / ReadConsoleW / GetConsoleMode / SetConsoleMode / GetConsoleScreenBufferInfo diff --git a/src/commands_project.rs b/src/commands_project.rs index 002d36f..ebe501d 100644 --- a/src/commands_project.rs +++ b/src/commands_project.rs @@ -673,6 +673,22 @@ fn doctor_plugin_registry() -> Vec { ] } +/// Resolve a registry binary path for the current platform. Windows plugin +/// installers ship PE binaries with an `.exe` suffix; checking the extensionless +/// registry path made doctor report installed plugins as absent on Windows. +fn doctor_plugin_bin_path( + home: &std::path::Path, + plugin: &DoctorPlugin, + windows: bool, +) -> std::path::PathBuf { + let path = home.join(plugin.rel_path); + if windows { + path.with_extension("exe") + } else { + path + } +} + /// Runs the doctor checks. Returns `true` iff there is ≥1 configured per-account /// egress and ALL of them failed connectivity — the caller maps that to a /// non-zero exit ("失败要显眼"). All other check failures stay advisory (doctor @@ -1300,7 +1316,7 @@ pub fn handle_doctor(json_mode: bool) -> Result // "doctor doesn't check it". Not-installed renders dim/informational and // never bubbles to the overall pass/fail (these are opt-in). { - let home = std::path::PathBuf::from(std::env::var("HOME").unwrap_or_default()); + let home = dirs::home_dir().unwrap_or_default(); let plugins = doctor_plugin_registry(); // Paths come from the registry (single source shared with the // consistency test); trust-local keeps its bespoke daemon+observer @@ -1311,8 +1327,11 @@ pub fn handle_doctor(json_mode: bool) -> Result .expect("registry has the trust-local daemon entry"); // ── degrade-detector / trust-local (daemon) ── - let trust_local_bin = home.join(trust_local_plugin.rel_path); - if trust_local_bin.exists() { + // Use the shared service module as the install-state truth source. + // On Windows it resolves %USERPROFILE%\.aikey\bin\trust-local.exe; + // the old `$HOME/.aikey/bin/trust-local` check bypassed auto-repair, + // printed "not installed", and left :8801 down (QA20). + if crate::trust_local_service::is_installed() { // (a) trust-local service liveness. let trust_local_url = "http://127.0.0.1:8801/healthz"; let tl_ok = ureq::get(trust_local_url) @@ -1362,8 +1381,7 @@ pub fn handle_doctor(json_mode: bool) -> Result // as `proxy.observer.built` (good) or // `proxy.observer.build_failed` (bad) within the first // few hundred lines after a restart. - let log_path = std::path::Path::new(&std::env::var("HOME").unwrap_or_default()) - .join(".aikey/logs/aikey-proxy/current.jsonl"); + let log_path = home.join(".aikey/logs/aikey-proxy/current.jsonl"); let observer_state = if log_path.exists() { std::fs::read_to_string(&log_path).ok().and_then(|s| { // Take the LAST observer line (most recent @@ -1430,7 +1448,7 @@ pub fn handle_doctor(json_mode: bool) -> Result // that the proxy *can* spawn it. Looped from the registry's // non-daemon entries so adding a filter app is a one-line table edit. for p in plugins.iter().filter(|p| !p.is_daemon) { - if home.join(p.rel_path).exists() { + if doctor_plugin_bin_path(&home, p, cfg!(windows)).exists() { emit(p.label, true, "installed (proxy-spawned filter)", None); } else { let hint = format!("enable: aikey app install {}", p.install_slug); @@ -3068,6 +3086,21 @@ mod doctor_detail_tests { let plugins = doctor_plugin_registry(); assert_eq!(plugins.iter().filter(|p| p.is_daemon).count(), 1); } + + #[test] + fn doctor_plugin_paths_add_exe_on_windows() { + let home = std::path::Path::new(r"C:\Users\Administrator"); + let plugins = doctor_plugin_registry(); + let trust_local = plugins.iter().find(|p| p.is_daemon).unwrap(); + assert!( + doctor_plugin_bin_path(home, trust_local, true).ends_with("trust-local.exe"), + "Windows doctor must look for the installed PE binary" + ); + assert!( + doctor_plugin_bin_path(home, trust_local, false).ends_with("trust-local"), + "Unix doctor must keep the extensionless binary path" + ); + } } // --------------------------------------------------------------------------- diff --git a/src/trust_local_service.rs b/src/trust_local_service.rs index e347ae0..428585f 100644 --- a/src/trust_local_service.rs +++ b/src/trust_local_service.rs @@ -72,8 +72,10 @@ pub fn start() -> Result<(), String> { platform_run("start").and_then(|()| probe_healthz()) } -/// Stop the daemon. No reachability probe — we trust the service manager to -/// deliver SIGTERM (matches the pre-refactor `dispatch` behavior). +/// Stop the daemon. Windows additionally verifies that the exact installed +/// binary's process tree is gone: Task Scheduler `/End` terminates only the +/// hidden launcher on some Win10 hosts and can orphan the PyInstaller +/// parent/worker pair while incorrectly reporting success. pub fn stop() -> Result<(), String> { platform_run("stop") } @@ -104,21 +106,14 @@ fn platform_run(verb: &str) -> Result<(), String> { }, "linux" => systemctl_user(verb), "windows" => match verb { - "start" | "stop" => sc_action(verb), + "start" => sc_action(verb), + "stop" => windows_stop(), // Windows `sc.exe`/`schtasks` has no atomic restart; do stop + // wait-for-STOPPED + start. We can't just sleep a fixed interval // because the stop is async (returns immediately while the task // exits); starting before STOPPED can error "already running". "restart" => { - let _ = sc_action("stop"); - // Poll for STOPPED state up to 10s. Match NSSM's stop timeout. - let deadline = Instant::now() + Duration::from_secs(10); - while Instant::now() < deadline { - if sc_is_stopped() { - break; - } - std::thread::sleep(Duration::from_millis(250)); - } + windows_stop()?; sc_action("start") } _ => Err(format!("unknown verb '{}'", verb)), @@ -179,26 +174,125 @@ fn sc_action(verb: &str) -> Result<(), String> { run("schtasks", &[sw, "/TN", SERVICE_NAME]) } -/// Returns true when trust-local is NOT running. Used by the restart loop to -/// wait for an async `/End` to finish before re-firing `/Run`. -/// -/// We probe the process image name via `tasklist` rather than parsing -/// `schtasks /Query` Status, because the Status strings are LOCALIZED and a -/// substring match would break off English hosts. The image name -/// `trust-local.exe` is not localized. -fn sc_is_stopped() -> bool { - match std::process::Command::new("tasklist") - .args(["/FI", "IMAGENAME eq trust-local.exe", "/FO", "CSV", "/NH"]) - .output() - { - Ok(out) => { - let s = String::from_utf8_lossy(&out.stdout).to_lowercase(); - !s.contains("trust-local.exe") +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct WindowsProcess { + pid: u32, + parent_pid: u32, +} + +/// Return only roots from a set of matching processes. `taskkill /T` on each +/// root then terminates its full descendant tree without issuing redundant +/// kills for the PyInstaller worker child. +fn process_tree_roots(processes: &[WindowsProcess]) -> Vec { + processes + .iter() + .filter(|process| { + !processes + .iter() + .any(|candidate| candidate.pid == process.parent_pid) + }) + .map(|process| process.pid) + .collect() +} + +#[cfg(windows)] +fn normalize_windows_path(path: &std::path::Path) -> String { + path.to_string_lossy() + .replace('/', "\\") + .trim_start_matches(r"\\?\") + .to_lowercase() +} + +/// Enumerate only processes whose OS-reported executable path exactly matches +/// this user's installed trust-local.exe. Matching by image name alone would +/// kill detector processes belonging to another Windows account or sandbox. +#[cfg(windows)] +fn windows_trust_local_processes() -> Result, String> { + use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, + TH32CS_SNAPPROCESS, + }; + + let expected = std::fs::canonicalize(bin_path()).unwrap_or_else(|_| bin_path()); + let expected = normalize_windows_path(&expected); + let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }; + if snapshot == INVALID_HANDLE_VALUE { + return Err(format!( + "CreateToolhelp32Snapshot: {}", + std::io::Error::last_os_error() + )); + } + + let mut matches = Vec::new(); + let mut entry: PROCESSENTRY32W = unsafe { std::mem::zeroed() }; + entry.dwSize = std::mem::size_of::() as u32; + let mut more = unsafe { Process32FirstW(snapshot, &mut entry) } != 0; + while more { + let pid = entry.th32ProcessID; + if pid != 0 { + if let Some(actual) = crate::proxy_proc::process_identity(pid) { + if normalize_windows_path(&actual) == expected { + matches.push(WindowsProcess { + pid, + parent_pid: entry.th32ParentProcessID, + }); + } + } + } + more = unsafe { Process32NextW(snapshot, &mut entry) } != 0; + } + unsafe { CloseHandle(snapshot) }; + Ok(matches) +} + +/// Task Scheduler can report `/End` success after terminating only the hidden +/// PowerShell launcher. Kill the exact installed binary's remaining roots with +/// `/T`, then poll the process table and fail if anything survives. +#[cfg(windows)] +fn windows_stop() -> Result<(), String> { + // Always attempt /End first so the registered task no longer supervises or + // respawns the daemon. A Ready task may return a non-zero result while an + // orphan is still alive; final process state, not that localized message, + // is the authoritative stop result. + let scheduler_result = sc_action("stop"); + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let survivors = windows_trust_local_processes()?; + if survivors.is_empty() { + return Ok(()); } - Err(_) => false, + if Instant::now() >= deadline { + let pids = survivors + .iter() + .map(|process| process.pid.to_string()) + .collect::>() + .join(", "); + let scheduler_detail = scheduler_result + .err() + .map(|detail| format!("; schtasks: {detail}")) + .unwrap_or_default(); + return Err(format!( + "trust-local process tree still running after stop (PID(s): {pids}){scheduler_detail}" + )); + } + // Re-enumerate and retry each pass. A parent can exit between the + // snapshot and taskkill, orphaning its child under a new parent; the + // next pass then treats that survivor as a root instead of waiting the + // full timeout without another termination attempt. + for pid in process_tree_roots(&survivors) { + let pid_arg = pid.to_string(); + let _ = run("taskkill", &["/PID", &pid_arg, "/T", "/F"]); + } + std::thread::sleep(Duration::from_millis(250)); } } +#[cfg(not(windows))] +fn windows_stop() -> Result<(), String> { + Err("Windows trust-local stop requested on a non-Windows build".to_string()) +} + fn run(cmd: &str, args: &[&str]) -> Result<(), String> { let output = std::process::Command::new(cmd) .args(args) @@ -278,4 +372,23 @@ mod tests { assert!(p.ends_with("trust-local") || p.ends_with("trust-local.exe")); assert!(p.to_string_lossy().contains(".aikey")); } + + #[test] + fn process_tree_roots_avoid_redundant_child_kills() { + let processes = [ + WindowsProcess { + pid: 100, + parent_pid: 50, + }, + WindowsProcess { + pid: 101, + parent_pid: 100, + }, + WindowsProcess { + pid: 200, + parent_pid: 75, + }, + ]; + assert_eq!(process_tree_roots(&processes), vec![100, 200]); + } }