diff --git a/CHANGELOG.md b/CHANGELOG.md index 7400f20..60aed63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,10 @@ them through an ordinary `cargo update`. ### Fixed +- **`COPILOT_GITHUB_TOKEN` reaches Copilot under `EnvPolicy::Minimal`.** It is the + *highest-precedence* credential variable Copilot accepts and it was missing from the + list, so a host authenticating that way would have failed to authenticate at all once + `Minimal` became the default. - **Dropping a `Run` now reliably kills the process group.** It signalled the driver and aborted it, which left the kill waiting on the runtime to poll the aborted task. On Linux that did not reliably happen and grandchildren survived, while `cancel` and timeouts were @@ -47,6 +51,11 @@ them through an ordinary `cargo update`. ### Added +- **`AuthStatus::check(agent)`** answers whether an agent is logged in without spending a + request, which a missing login otherwise only revealed by running a turn and failing. + Claude reports JSON, Codex reports prose, and Copilot offers neither: that case is + `AuthState::Unknown` rather than a logout, since telling someone to re-authenticate a + working setup is worse than admitting the question cannot be answered. - **`Probe`** reads a CLI's `--version` and compares it against `Agent::verified_version`, the release its flag mappings were checked against, reporting `Verified` / `Newer` / `Older` / `Unrecognized` with an `advisory()` written to be shown to diff --git a/README.md b/README.md index 69fbd9e..5e4ab61 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,30 @@ Two more honest limits: Codex has no true plan mode, so `Plan` maps to its read- sandbox (writes blocked, execution still permitted), and `unchecked_args` can contradict any of this by design. +## Is each agent logged in? + +Without spending a request: + +```rust +for agent in Agent::ALL { + let status = AuthStatus::check(agent).await?; + println!("{agent}: {}", status.summary()); +} +``` + +```text +claude-code: logged in as you@example.com (max) +codex: logged in as ChatGPT +copilot: unknown: copilot exposes no status command, so this cannot be + confirmed without spending a request +``` + +Claude answers JSON (`claude auth status`), Codex answers prose +(`codex login status`), and **Copilot offers neither**. That third case reports `Unknown` +rather than "logged out", because telling someone to re-authenticate a working setup is +worse than admitting the question cannot be answered. `needs_login()` is true only for a +*confirmed* logout, so gating on it never nags about an agent that simply cannot be asked. + ## Environment isolation **`EnvPolicy::Minimal` is the default.** Inheriting the whole environment is what a CLI gets diff --git a/src/agent.rs b/src/agent.rs index 5baa050..f1cfd42 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -255,6 +255,36 @@ impl Agent { } } + /// The command that asks this agent whether it is logged in, or `None` + /// when it offers no way to ask. + /// + /// Verified against each CLI: Claude has `auth status`, which answers JSON + /// by default, and Codex has `login status`, which answers prose. Copilot + /// has neither, so its credentials cannot be confirmed without spending a + /// request. + #[must_use] + pub fn auth_status_argv(self) -> Option<&'static [&'static str]> { + match self { + Agent::Claude => Some(&["auth", "status", "--json"]), + Agent::Codex => Some(&["login", "status"]), + Agent::Copilot => None, + } + } + + /// The environment variables this agent accepts a credential in, most + /// preferred first. + /// + /// Copilot documents its precedence explicitly: `COPILOT_GITHUB_TOKEN`, + /// then `GH_TOKEN`, then `GITHUB_TOKEN`. + #[must_use] + pub fn auth_env_vars(self) -> &'static [&'static str] { + match self { + Agent::Claude => &["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"], + Agent::Codex => &["CODEX_API_KEY", "OPENAI_API_KEY"], + Agent::Copilot => &["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"], + } + } + /// The command that resolves a missing login for this agent. /// /// Verified against each CLI's own help: Codex and Copilot expose a `login` @@ -360,7 +390,15 @@ impl Agent { "OPENAI_API_KEY", "OPENAI_BASE_URL", ], - Agent::Copilot => &["GH_TOKEN", "GITHUB_TOKEN", "XDG_CONFIG_HOME"], + // `COPILOT_GITHUB_TOKEN` takes precedence over the others per + // Copilot's own docs, and was missing here: a host using it would + // have failed to authenticate under EnvPolicy::Minimal. + Agent::Copilot => &[ + "COPILOT_GITHUB_TOKEN", + "GH_TOKEN", + "GITHUB_TOKEN", + "XDG_CONFIG_HOME", + ], }; BASE.iter().chain(WINDOWS).chain(agent).copied().collect() } diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..d645096 --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,329 @@ +//! Asking an agent whether it is logged in, without spending a request. +//! +//! A missing login is otherwise only discoverable by running a turn and +//! catching [`crate::Error::NotAuthenticated`], which costs quota and is a poor +//! way to populate a settings screen. Two of the three CLIs expose a status +//! command; the third does not, and this says so rather than guessing. +//! +//! ```no_run +//! # use agent_abstraction::{Agent, AuthStatus}; +//! # async fn example() -> agent_abstraction::Result<()> { +//! for agent in Agent::ALL { +//! let status = AuthStatus::check(agent).await?; +//! println!("{agent}: {}", status.summary()); +//! } +//! # Ok(()) +//! # } +//! ``` + +use serde_json::Value; + +use crate::agent::Agent; +use crate::error::{Error, Result}; + +/// Whether an agent has usable credentials. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum AuthState { + /// The CLI confirmed it is logged in. + LoggedIn, + /// The CLI confirmed it is not. + LoggedOut, + /// Could not be determined. Either the agent exposes no way to ask, or it + /// answered something unrecognized. + /// + /// Deliberately distinct from [`AuthState::LoggedOut`]: reporting "not + /// logged in" for an agent that simply cannot be asked would send someone + /// to re-authenticate a working setup. + Unknown, +} + +/// What an agent reported about its credentials. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct AuthStatus { + /// The agent asked. + pub agent: Agent, + /// What it said. + pub state: AuthState, + /// How it is authenticated, in its own words: `claude.ai`, `ChatGPT`, an + /// API key. `None` when it did not say. + pub method: Option, + /// The account, where the agent reports one. Claude gives an email. + pub account: Option, + /// The plan or subscription, where reported. + pub plan: Option, + /// The CLI's own output, or an explanation when it could not be asked. + pub detail: String, + /// The command that resolves a missing login. + pub login_hint: &'static str, +} + +impl AuthStatus { + /// Ask `agent`'s default binary. + /// + /// # Errors + /// [`Error::NotInstalled`] if the binary is missing, [`Error::Spawn`] if it + /// cannot be run. A CLI that answers "logged out" is a successful check, + /// not an error. + pub async fn check(agent: Agent) -> Result { + AuthStatus::check_bin(agent, agent.bin()).await + } + + /// Ask a specific binary, for a caller overriding the path with + /// [`crate::Request::bin`]. + /// + /// # Errors + /// [`Error::NotInstalled`] if the binary is missing, [`Error::Spawn`] if it + /// cannot be run. + pub async fn check_bin(agent: Agent, bin: &str) -> Result { + let Some(args) = agent.auth_status_argv() else { + return Ok(AuthStatus::uncheckable(agent)); + }; + + let output = tokio::process::Command::new(bin) + .args(args) + .output() + .await + .map_err(|source| { + if source.kind() == std::io::ErrorKind::NotFound { + Error::NotInstalled { + agent, + bin: bin.to_string(), + hint: agent.install_hint(), + } + } else { + Error::Spawn { + bin: bin.to_string(), + source, + } + } + })?; + + let mut text = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if text.is_empty() { + text = String::from_utf8_lossy(&output.stderr).trim().to_string(); + } + Ok(AuthStatus::read(agent, &text, output.status.success())) + } + + /// Interpret a status command's output. + /// + /// Split out from the spawn so every agent's parsing is unit-testable + /// against its real output without needing the CLI installed. + #[must_use] + pub(crate) fn read(agent: Agent, text: &str, exit_ok: bool) -> AuthStatus { + let mut status = AuthStatus { + agent, + state: AuthState::Unknown, + method: None, + account: None, + plan: None, + detail: text.to_string(), + login_hint: agent.login_hint(), + }; + + match agent { + // Claude answers JSON by default, which is the one machine-readable + // status of the three. + Agent::Claude => { + if let Ok(Value::Object(map)) = serde_json::from_str::(text) { + status.state = match map.get("loggedIn").and_then(Value::as_bool) { + Some(true) => AuthState::LoggedIn, + Some(false) => AuthState::LoggedOut, + None => AuthState::Unknown, + }; + let field = |key: &str| { + map.get(key) + .and_then(Value::as_str) + .map(str::to_string) + .filter(|v| !v.is_empty()) + }; + status.method = field("authMethod"); + status.account = field("email"); + status.plan = field("subscriptionType"); + } + } + // Codex answers prose, so this reads the phrases it actually uses. + // The negative is checked first: "not logged in" contains "logged + // in". + Agent::Codex => { + let lower = text.to_ascii_lowercase(); + status.state = if lower.contains("not logged in") || lower.contains("logged out") { + AuthState::LoggedOut + } else if exit_ok && lower.contains("logged in") { + // e.g. "Logged in using ChatGPT" + status.method = text + .rsplit_once(" using ") + .map(|(_, method)| method.trim().to_string()); + AuthState::LoggedIn + } else { + AuthState::Unknown + }; + } + // Unreachable: `auth_status_argv` returns None, so `check_bin` + // never gets here for Copilot. + Agent::Copilot => {} + } + status + } + + /// The status for an agent that offers no way to ask. + fn uncheckable(agent: Agent) -> AuthStatus { + // A token in the environment is worth reporting, but its presence is + // not proof it is valid, so this stays Unknown rather than claiming a + // login it has not verified. + let env_token = agent + .auth_env_vars() + .iter() + .find(|name| std::env::var_os(name).is_some_and(|v| !v.is_empty())); + + AuthStatus { + agent, + state: AuthState::Unknown, + method: env_token.map(|name| format!("token in {name}")), + account: None, + plan: None, + detail: match env_token { + Some(name) => format!( + "{agent} exposes no status command, so this cannot be confirmed without \ + spending a request. {name} is set, but its validity is unverified." + ), + None => format!( + "{agent} exposes no status command, so this cannot be confirmed without \ + spending a request, and no credential environment variable is set." + ), + }, + login_hint: agent.login_hint(), + } + } + + /// Whether the agent confirmed it is logged in. + /// + /// False for [`AuthState::Unknown`], so a caller that gates on this is + /// conservative. Check `state` directly to distinguish "no" from "cannot + /// tell". + #[must_use] + pub fn is_logged_in(&self) -> bool { + self.state == AuthState::LoggedIn + } + + /// Whether the answer means someone has to log in. + /// + /// Only a confirmed logout. An agent that cannot be asked is not evidence + /// of a problem. + #[must_use] + pub fn needs_login(&self) -> bool { + self.state == AuthState::LoggedOut + } + + /// One line fit to show in a settings screen. + #[must_use] + pub fn summary(&self) -> String { + match self.state { + AuthState::LoggedIn => { + let who = self + .account + .as_deref() + .or(self.method.as_deref()) + .unwrap_or("logged in"); + match &self.plan { + Some(plan) => format!("logged in as {who} ({plan})"), + None => format!("logged in as {who}"), + } + } + AuthState::LoggedOut => format!("not logged in: {}", self.login_hint), + AuthState::Unknown => format!("unknown: {}", self.detail), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Verbatim from `claude auth status`, which answers JSON by default. + #[test] + fn claude_json_is_read_into_a_status() { + let text = r#"{ + "loggedIn": true, + "authMethod": "claude.ai", + "apiProvider": "firstParty", + "email": "claude@pathscale.com", + "orgId": "18b5d0a6", + "subscriptionType": "max" + }"#; + let status = AuthStatus::read(Agent::Claude, text, true); + assert_eq!(status.state, AuthState::LoggedIn); + assert!(status.is_logged_in()); + assert!(!status.needs_login()); + assert_eq!(status.account.as_deref(), Some("claude@pathscale.com")); + assert_eq!(status.method.as_deref(), Some("claude.ai")); + assert_eq!(status.plan.as_deref(), Some("max")); + assert!(status.summary().contains("claude@pathscale.com")); + } + + #[test] + fn claude_reports_a_logout_as_one() { + let status = AuthStatus::read(Agent::Claude, r#"{"loggedIn": false}"#, true); + assert_eq!(status.state, AuthState::LoggedOut); + assert!(status.needs_login()); + assert!(status.summary().contains("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/login"), "{}", status.summary()); + } + + /// Verbatim from `codex login status`. + #[test] + fn codex_prose_is_read_into_a_status() { + let status = AuthStatus::read(Agent::Codex, "Logged in using ChatGPT", true); + assert_eq!(status.state, AuthState::LoggedIn); + assert_eq!(status.method.as_deref(), Some("ChatGPT")); + } + + /// "not logged in" contains "logged in", so order of checks decides this. + #[test] + fn codex_negatives_are_not_read_as_positives() { + for text in ["Not logged in", "You are not logged in.", "Logged out"] { + let status = AuthStatus::read(Agent::Codex, text, true); + assert_eq!(status.state, AuthState::LoggedOut, "{text:?}"); + assert!(status.summary().contains("codex login")); + } + } + + #[test] + fn unrecognized_output_is_unknown_rather_than_a_guess() { + for (agent, text) in [ + (Agent::Claude, "not json at all"), + (Agent::Codex, "something else entirely"), + ] { + let status = AuthStatus::read(agent, text, true); + assert_eq!(status.state, AuthState::Unknown, "{agent}"); + assert!(!status.is_logged_in()); + // Crucially not `needs_login`: an unreadable answer is not evidence + // that someone has to log in. + assert!(!status.needs_login(), "{agent}"); + } + } + + /// Copilot exposes no status command, and saying "logged out" for an agent + /// that cannot be asked would send someone to fix a working setup. + #[tokio::test] + async fn copilot_reports_that_it_cannot_be_checked() { + let status = AuthStatus::check_bin(Agent::Copilot, "copilot") + .await + .expect("an uncheckable agent is not an error"); + assert_eq!(status.state, AuthState::Unknown); + assert!(!status.needs_login()); + assert!( + status.detail.contains("no status command"), + "{}", + status.detail + ); + } + + #[test] + fn only_claude_and_codex_can_be_asked() { + assert!(Agent::Claude.auth_status_argv().is_some()); + assert!(Agent::Codex.auth_status_argv().is_some()); + assert!(Agent::Copilot.auth_status_argv().is_none()); + } +} diff --git a/src/lib.rs b/src/lib.rs index 3e7db53..9113a63 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -86,6 +86,7 @@ //! is the caller's decision. See `docs/operating-limits.md`. mod agent; +mod auth; mod error; mod event; mod outcome; @@ -96,6 +97,7 @@ mod run; mod session; pub use agent::{Agent, Caps, EnvPolicy, Format, NETWORK_ENV, Permission, SessionSupport}; +pub use auth::{AuthState, AuthStatus}; pub use error::{Error, Result}; pub use event::{Event, MAX_CAPTURE, MAX_EVENT_BYTES, MAX_LINE, TRUNCATION_MARK}; pub use outcome::{Outcome, RateLimit, Stop, Usage}; diff --git a/tests/live.rs b/tests/live.rs index 0e42a64..b1ec786 100644 --- a/tests/live.rs +++ b/tests/live.rs @@ -14,8 +14,8 @@ use std::time::Duration; use agent_abstraction::{ - Agent, EnvPolicy, Event, Format, Permission, Probe, Request, SessionStore, VersionStatus, run, - stream, + Agent, AuthState, AuthStatus, EnvPolicy, Event, Format, Permission, Probe, Request, + SessionStore, VersionStatus, run, stream, }; /// A prompt with exactly one correct answer, so the assertion is about the @@ -477,3 +477,39 @@ async fn installed_agents_match_the_versions_the_flags_were_verified_against() { } eprintln!("probed {checked} installed agents"); } + +/// Checking login costs no quota, so like the version probe this runs by +/// default. It is the test that keeps the status parsing honest: both CLIs +/// answer in their own shape, and a parser that silently stopped recognizing +/// `Logged in using ChatGPT` would report a working setup as unknown. +#[tokio::test] +async fn installed_agents_report_their_login_state() { + for agent in Agent::ALL { + if !available(agent) { + continue; + } + let status = AuthStatus::check(agent).await.expect("check failed"); + + if agent.auth_status_argv().is_some() { + // Claude and Codex answer, so the result must be a real yes or no. + // Unknown here means the parsing no longer matches the CLI. + assert_ne!( + status.state, + AuthState::Unknown, + "{agent} answered {:?}, which this crate no longer recognizes", + status.detail + ); + assert!( + status.is_logged_in(), + "{agent} is not logged in: {}", + status.summary() + ); + } else { + // Copilot cannot be asked, and must say so rather than claiming a + // logout that would send someone to fix a working setup. + assert_eq!(status.state, AuthState::Unknown); + assert!(!status.needs_login()); + } + eprintln!("{agent}: {}", status.summary()); + } +}