Skip to content
Draft
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
12 changes: 12 additions & 0 deletions desktop/src-tauri/src/managed_agents/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
47 changes: 46 additions & 1 deletion desktop/src-tauri/src/managed_agents/git_bash.rs
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ fn scan_path_for_command(
) -> Option<PathBuf> {
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);
Expand All @@ -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();
Expand Down Expand Up @@ -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");
Expand Down
144 changes: 143 additions & 1 deletion desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProbeOutcome> {
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<ProbeOutcome> {
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<ProbeOutcome> {
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<ProbeOutcome> {
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.
Expand All @@ -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 {
Expand Down Expand Up @@ -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]
Expand Down