From 0ff7676123c3f972d5d2ae838792eed2f206d481 Mon Sep 17 00:00:00 2001 From: Cvv9 Date: Thu, 23 Jul 2026 21:26:17 +0530 Subject: [PATCH] fix(desktop): prevent WSL flashes during agent discovery Signed-off-by: Cvv9 --- .../src-tauri/src/managed_agents/discovery.rs | 12 ++ .../src-tauri/src/managed_agents/git_bash.rs | 47 +++++- .../src/managed_agents/readiness/cli_probe.rs | 144 +++++++++++++++++- 3 files changed, 201 insertions(+), 2 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 50206567ad..80aed08a81 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -905,6 +905,18 @@ pub(crate) fn is_npm_global_install(cmd: &str) -> bool { fn probe_auth_status(binary_path: &Path, probe_args: &[&str]) -> AuthStatus { use crate::managed_agents::readiness::cli_probe; + if let Some(outcome) = cli_probe::native_credentials_probe(binary_path, probe_args) { + return match outcome { + cli_probe::ProbeOutcome::LoggedIn => AuthStatus::LoggedIn, + cli_probe::ProbeOutcome::LoggedOut => AuthStatus::LoggedOut, + cli_probe::ProbeOutcome::ConfigInvalid { stderr_excerpt } => { + AuthStatus::ConfigInvalid { + diagnostic: stderr_excerpt, + } + } + }; + } + let augmented_path = cli_probe::augmented_path(); let mut command = std::process::Command::new(binary_path); diff --git a/desktop/src-tauri/src/managed_agents/git_bash.rs b/desktop/src-tauri/src/managed_agents/git_bash.rs index 790f18cfa2..74091c12b8 100644 --- a/desktop/src-tauri/src/managed_agents/git_bash.rs +++ b/desktop/src-tauri/src/managed_agents/git_bash.rs @@ -272,7 +272,7 @@ fn scan_path_for_command( ) -> Option { let needs_exe = name.extension().is_none(); std::env::split_paths(path_env).find_map(|dir| { - if system_root.is_some_and(|root| is_under_dir(&dir, root)) { + if system_root.is_some_and(|root| is_under_dir(&dir, root)) || is_windows_apps_dir(&dir) { return None; } let candidate = dir.join(name); @@ -290,6 +290,20 @@ fn scan_path_for_command( }) } +/// Windows exposes `bash.exe` compatibility/app-execution aliases in +/// System32 and `%LOCALAPPDATA%\Microsoft\WindowsApps`. They launch WSL, not +/// Git Bash. System32 is rejected through `system_root`; reject WindowsApps +/// explicitly so runtime discovery cannot flash WSL console windows. +#[cfg(windows)] +fn is_windows_apps_dir(dir: &Path) -> bool { + dir.components().any(|component| { + component + .as_os_str() + .to_string_lossy() + .eq_ignore_ascii_case("WindowsApps") + }) +} + #[cfg(windows)] fn is_under_dir(dir: &Path, root: &Path) -> bool { let mut dir_components = dir.components(); @@ -457,6 +471,37 @@ mod tests { ); } + #[test] + fn test_windows_apps_bash_alias_is_not_treated_as_git_bash() { + let temp = tempdir().expect("tempdir"); + let windows_apps = temp.path().join("Microsoft").join("WindowsApps"); + let alias = windows_apps.join("bash.exe"); + let git = temp.path().join("Git").join("cmd").join("git.exe"); + let bash = temp.path().join("Git").join("bin").join("bash.exe"); + std::fs::create_dir_all(&windows_apps).expect("mkdir WindowsApps"); + std::fs::create_dir_all(git.parent().expect("git parent")).expect("mkdir git"); + std::fs::create_dir_all(bash.parent().expect("bash parent")).expect("mkdir bash"); + std::fs::write(alias, []).expect("alias"); + std::fs::write(&git, []).expect("git"); + std::fs::write(&bash, []).expect("bash"); + + let path = + std::env::join_paths([windows_apps, git.parent().expect("cmd dir").to_path_buf()]) + .expect("PATH"); + assert_eq!( + resolve_git_bash_no_registry( + path.to_str().expect("utf8"), + None, + None, + None, + None, + None, + None, + ), + Some(bash) + ); + } + #[test] fn test_program_files_x86_git_bash_resolves() { let temp = tempdir().expect("tempdir"); diff --git a/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs b/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs index 8e2f866b7e..96d97c0242 100644 --- a/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs +++ b/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs @@ -36,6 +36,87 @@ pub(crate) enum ProbeOutcome { }, } +/// Read Claude's local OAuth state on Windows instead of launching +/// `claude auth status`. +/// +/// Claude's native Windows CLI creates console-mode descendants even when the +/// immediate probe process uses `CREATE_NO_WINDOW`, which makes Command Prompt +/// windows flash whenever the desktop refreshes agent readiness. The +/// credentials file contains refreshable OAuth state, so checking for a +/// non-empty access or refresh token provides the same logged-in signal without +/// launching a process. +#[cfg(windows)] +pub(crate) fn native_credentials_probe( + binary_path: &Path, + probe_args: &[&str], +) -> Option { + let binary_name = binary_path + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or_default(); + + if binary_name.eq_ignore_ascii_case("claude") && probe_args == ["claude", "auth", "status"] { + let credentials_path = dirs::home_dir()?.join(".claude").join(".credentials.json"); + return classify_claude_credentials(&std::fs::read(credentials_path).ok()?); + } + + if binary_name.eq_ignore_ascii_case("codex") && probe_args == ["codex", "login", "status"] { + let credentials_path = dirs::home_dir()?.join(".codex").join("auth.json"); + return classify_codex_credentials(&std::fs::read(credentials_path).ok()?); + } + + None +} + +#[cfg(any(windows, test))] +fn classify_claude_credentials(bytes: &[u8]) -> Option { + let credentials: serde_json::Value = serde_json::from_slice(bytes).ok()?; + let oauth = credentials.get("claudeAiOauth")?; + let has_token = ["accessToken", "refreshToken"].iter().any(|key| { + oauth + .get(key) + .and_then(serde_json::Value::as_str) + .is_some_and(|value| !value.trim().is_empty()) + }); + Some(if has_token { + ProbeOutcome::LoggedIn + } else { + ProbeOutcome::LoggedOut + }) +} + +#[cfg(any(windows, test))] +fn classify_codex_credentials(bytes: &[u8]) -> Option { + let credentials: serde_json::Value = serde_json::from_slice(bytes).ok()?; + let has_api_key = credentials + .get("OPENAI_API_KEY") + .and_then(serde_json::Value::as_str) + .is_some_and(|value| !value.trim().is_empty()); + let has_token = credentials.get("tokens").is_some_and(|tokens| { + ["access_token", "refresh_token", "id_token"] + .iter() + .any(|key| { + tokens + .get(key) + .and_then(serde_json::Value::as_str) + .is_some_and(|value| !value.trim().is_empty()) + }) + }); + Some(if has_api_key || has_token { + ProbeOutcome::LoggedIn + } else { + ProbeOutcome::LoggedOut + }) +} + +#[cfg(not(windows))] +pub(crate) fn native_credentials_probe( + _binary_path: &Path, + _probe_args: &[&str], +) -> Option { + None +} + /// Signals emitted to stderr by codex (and related CLI tools) when they /// fail to parse their config file. We check these to distinguish a /// config-parse failure from a genuine "not authenticated" exit. @@ -56,6 +137,10 @@ pub(crate) fn login_probe( probe_args: &[&str], augmented_path: Option<&str>, ) -> ProbeOutcome { + if let Some(outcome) = native_credentials_probe(binary_path, probe_args) { + return outcome; + } + let mut command = std::process::Command::new(binary_path); command.args(&probe_args[1..]); if let Some(path) = augmented_path { @@ -95,7 +180,64 @@ pub(crate) fn classify_probe_output(stderr_bytes: &[u8], exit_success: bool) -> #[cfg(test)] mod tests { - use super::{ProbeOutcome, CONFIG_PARSE_SIGNALS}; + use super::{ + classify_claude_credentials, classify_codex_credentials, ProbeOutcome, CONFIG_PARSE_SIGNALS, + }; + + #[test] + fn claude_credentials_accept_access_or_refresh_token() { + for credentials in [ + br#"{"claudeAiOauth":{"accessToken":"access","refreshToken":""}}"#.as_slice(), + br#"{"claudeAiOauth":{"accessToken":"","refreshToken":"refresh"}}"#.as_slice(), + ] { + assert_eq!( + classify_claude_credentials(credentials), + Some(ProbeOutcome::LoggedIn) + ); + } + } + + #[test] + fn claude_credentials_reject_missing_or_empty_tokens() { + for credentials in [ + br#"{"claudeAiOauth":{"accessToken":"","refreshToken":""}}"#.as_slice(), + br#"{"other":{}}"#.as_slice(), + b"not-json".as_slice(), + ] { + assert_ne!( + classify_claude_credentials(credentials), + Some(ProbeOutcome::LoggedIn) + ); + } + } + + #[test] + fn codex_credentials_accept_api_key_or_refreshable_tokens() { + for credentials in [ + br#"{"OPENAI_API_KEY":"key"}"#.as_slice(), + br#"{"tokens":{"access_token":"access"}}"#.as_slice(), + br#"{"tokens":{"refresh_token":"refresh"}}"#.as_slice(), + ] { + assert_eq!( + classify_codex_credentials(credentials), + Some(ProbeOutcome::LoggedIn) + ); + } + } + + #[test] + fn codex_credentials_reject_missing_or_empty_tokens() { + for credentials in [ + br#"{"OPENAI_API_KEY":"","tokens":{"access_token":""}}"#.as_slice(), + br#"{"other":{}}"#.as_slice(), + b"not-json".as_slice(), + ] { + assert_ne!( + classify_codex_credentials(credentials), + Some(ProbeOutcome::LoggedIn) + ); + } + } #[cfg(unix)] #[test]