Skip to content
Merged
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
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 39 additions & 6 deletions src/commands_project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -673,6 +673,22 @@ fn doctor_plugin_registry() -> Vec<DoctorPlugin> {
]
}

/// 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
Expand Down Expand Up @@ -1300,7 +1316,7 @@ pub fn handle_doctor(json_mode: bool) -> Result<bool, Box<dyn std::error::Error>
// "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
Expand All @@ -1311,8 +1327,11 @@ pub fn handle_doctor(json_mode: bool) -> Result<bool, Box<dyn std::error::Error>
.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)
Expand Down Expand Up @@ -1362,8 +1381,7 @@ pub fn handle_doctor(json_mode: bool) -> Result<bool, Box<dyn std::error::Error>
// 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
Expand Down Expand Up @@ -1430,7 +1448,7 @@ pub fn handle_doctor(json_mode: bool) -> Result<bool, Box<dyn std::error::Error>
// 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);
Expand Down Expand Up @@ -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"
);
}
}

// ---------------------------------------------------------------------------
Expand Down
169 changes: 141 additions & 28 deletions src/trust_local_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down Expand Up @@ -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)),
Expand Down Expand Up @@ -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<u32> {
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<Vec<WindowsProcess>, 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::<PROCESSENTRY32W>() 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::<Vec<_>>()
.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)
Expand Down Expand Up @@ -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]);
}
}
Loading