Skip to main content

agent_abstraction/
auth.rs

1//! Asking an agent whether it is logged in, without spending a request.
2//!
3//! A missing login is otherwise only discoverable by running a turn and
4//! catching [`crate::Error::NotAuthenticated`], which costs quota and is a poor
5//! way to populate a settings screen. Two of the three CLIs expose a status
6//! command; the third does not, and this says so rather than guessing.
7//!
8//! ```no_run
9//! # use agent_abstraction::{Agent, AuthStatus};
10//! # async fn example() -> agent_abstraction::Result<()> {
11//! for agent in Agent::ALL {
12//!     let status = AuthStatus::check(agent).await?;
13//!     println!("{agent}: {}", status.summary());
14//! }
15//! # Ok(())
16//! # }
17//! ```
18
19use serde_json::Value;
20
21use crate::agent::Agent;
22use crate::error::{Error, Result};
23
24/// Whether an agent has usable credentials.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26#[non_exhaustive]
27pub enum AuthState {
28    /// The CLI confirmed it is logged in.
29    LoggedIn,
30    /// The CLI confirmed it is not.
31    LoggedOut,
32    /// Could not be determined. Either the agent exposes no way to ask, or it
33    /// answered something unrecognized.
34    ///
35    /// Deliberately distinct from [`AuthState::LoggedOut`]: reporting "not
36    /// logged in" for an agent that simply cannot be asked would send someone
37    /// to re-authenticate a working setup.
38    Unknown,
39}
40
41/// What an agent reported about its credentials.
42#[derive(Debug, Clone)]
43#[non_exhaustive]
44pub struct AuthStatus {
45    /// The agent asked.
46    pub agent: Agent,
47    /// What it said.
48    pub state: AuthState,
49    /// How it is authenticated, in its own words: `claude.ai`, `ChatGPT`, an
50    /// API key. `None` when it did not say.
51    pub method: Option<String>,
52    /// The account, where the agent reports one. Claude gives an email.
53    pub account: Option<String>,
54    /// The plan or subscription, where reported.
55    pub plan: Option<String>,
56    /// The CLI's own output, or an explanation when it could not be asked.
57    pub detail: String,
58    /// The command that resolves a missing login.
59    pub login_hint: &'static str,
60}
61
62impl AuthStatus {
63    /// Ask `agent`'s default binary.
64    ///
65    /// # Errors
66    /// [`Error::NotInstalled`] if the binary is missing, [`Error::Spawn`] if it
67    /// cannot be run. A CLI that answers "logged out" is a successful check,
68    /// not an error.
69    pub async fn check(agent: Agent) -> Result<AuthStatus> {
70        AuthStatus::check_bin(agent, agent.bin()).await
71    }
72
73    /// Ask a specific binary, for a caller overriding the path with
74    /// [`crate::Request::bin`].
75    ///
76    /// # Errors
77    /// [`Error::NotInstalled`] if the binary is missing, [`Error::Spawn`] if it
78    /// cannot be run.
79    pub async fn check_bin(agent: Agent, bin: &str) -> Result<AuthStatus> {
80        let Some(args) = agent.auth_status_argv() else {
81            return Ok(AuthStatus::uncheckable(agent));
82        };
83
84        let output = tokio::process::Command::new(bin)
85            .args(args)
86            .output()
87            .await
88            .map_err(|source| {
89                if source.kind() == std::io::ErrorKind::NotFound {
90                    Error::NotInstalled {
91                        agent,
92                        bin: bin.to_string(),
93                        hint: agent.install_hint(),
94                    }
95                } else {
96                    Error::Spawn {
97                        bin: bin.to_string(),
98                        source,
99                    }
100                }
101            })?;
102
103        let mut text = String::from_utf8_lossy(&output.stdout).trim().to_string();
104        if text.is_empty() {
105            text = String::from_utf8_lossy(&output.stderr).trim().to_string();
106        }
107        Ok(AuthStatus::read(agent, &text, output.status.success()))
108    }
109
110    /// Interpret a status command's output.
111    ///
112    /// Split out from the spawn so every agent's parsing is unit-testable
113    /// against its real output without needing the CLI installed.
114    #[must_use]
115    pub(crate) fn read(agent: Agent, text: &str, exit_ok: bool) -> AuthStatus {
116        let mut status = AuthStatus {
117            agent,
118            state: AuthState::Unknown,
119            method: None,
120            account: None,
121            plan: None,
122            detail: text.to_string(),
123            login_hint: agent.login_hint(),
124        };
125
126        match agent {
127            // Claude answers JSON by default, which is the one machine-readable
128            // status of the three.
129            Agent::Claude => {
130                if let Ok(Value::Object(map)) = serde_json::from_str::<Value>(text) {
131                    status.state = match map.get("loggedIn").and_then(Value::as_bool) {
132                        Some(true) => AuthState::LoggedIn,
133                        Some(false) => AuthState::LoggedOut,
134                        None => AuthState::Unknown,
135                    };
136                    let field = |key: &str| {
137                        map.get(key)
138                            .and_then(Value::as_str)
139                            .map(str::to_string)
140                            .filter(|v| !v.is_empty())
141                    };
142                    status.method = field("authMethod");
143                    status.account = field("email");
144                    status.plan = field("subscriptionType");
145                }
146            }
147            // Codex answers prose, so this reads the phrases it actually uses.
148            // The negative is checked first: "not logged in" contains "logged
149            // in".
150            Agent::Codex => {
151                let lower = text.to_ascii_lowercase();
152                status.state = if lower.contains("not logged in") || lower.contains("logged out") {
153                    AuthState::LoggedOut
154                } else if exit_ok && lower.contains("logged in") {
155                    // e.g. "Logged in using ChatGPT"
156                    status.method = text
157                        .rsplit_once(" using ")
158                        .map(|(_, method)| method.trim().to_string());
159                    AuthState::LoggedIn
160                } else {
161                    AuthState::Unknown
162                };
163            }
164            // Unreachable: `auth_status_argv` returns None, so `check_bin`
165            // never gets here for Copilot.
166            Agent::Copilot => {}
167        }
168        status
169    }
170
171    /// The status for an agent that offers no way to ask.
172    fn uncheckable(agent: Agent) -> AuthStatus {
173        // A token in the environment is worth reporting, but its presence is
174        // not proof it is valid, so this stays Unknown rather than claiming a
175        // login it has not verified.
176        let env_token = agent
177            .auth_env_vars()
178            .iter()
179            .find(|name| std::env::var_os(name).is_some_and(|v| !v.is_empty()));
180
181        AuthStatus {
182            agent,
183            state: AuthState::Unknown,
184            method: env_token.map(|name| format!("token in {name}")),
185            account: None,
186            plan: None,
187            detail: match env_token {
188                Some(name) => format!(
189                    "{agent} exposes no status command, so this cannot be confirmed without \
190                     spending a request. {name} is set, but its validity is unverified."
191                ),
192                None => format!(
193                    "{agent} exposes no status command, so this cannot be confirmed without \
194                     spending a request, and no credential environment variable is set."
195                ),
196            },
197            login_hint: agent.login_hint(),
198        }
199    }
200
201    /// Whether the agent confirmed it is logged in.
202    ///
203    /// False for [`AuthState::Unknown`], so a caller that gates on this is
204    /// conservative. Check `state` directly to distinguish "no" from "cannot
205    /// tell".
206    #[must_use]
207    pub fn is_logged_in(&self) -> bool {
208        self.state == AuthState::LoggedIn
209    }
210
211    /// Whether the answer means someone has to log in.
212    ///
213    /// Only a confirmed logout. An agent that cannot be asked is not evidence
214    /// of a problem.
215    #[must_use]
216    pub fn needs_login(&self) -> bool {
217        self.state == AuthState::LoggedOut
218    }
219
220    /// One line fit to show in a settings screen.
221    #[must_use]
222    pub fn summary(&self) -> String {
223        match self.state {
224            AuthState::LoggedIn => {
225                let who = self
226                    .account
227                    .as_deref()
228                    .or(self.method.as_deref())
229                    .unwrap_or("logged in");
230                match &self.plan {
231                    Some(plan) => format!("logged in as {who} ({plan})"),
232                    None => format!("logged in as {who}"),
233                }
234            }
235            AuthState::LoggedOut => format!("not logged in: {}", self.login_hint),
236            AuthState::Unknown => format!("unknown: {}", self.detail),
237        }
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    /// Verbatim from `claude auth status`, which answers JSON by default.
246    #[test]
247    fn claude_json_is_read_into_a_status() {
248        let text = r#"{
249            "loggedIn": true,
250            "authMethod": "claude.ai",
251            "apiProvider": "firstParty",
252            "email": "claude@pathscale.com",
253            "orgId": "18b5d0a6",
254            "subscriptionType": "max"
255        }"#;
256        let status = AuthStatus::read(Agent::Claude, text, true);
257        assert_eq!(status.state, AuthState::LoggedIn);
258        assert!(status.is_logged_in());
259        assert!(!status.needs_login());
260        assert_eq!(status.account.as_deref(), Some("claude@pathscale.com"));
261        assert_eq!(status.method.as_deref(), Some("claude.ai"));
262        assert_eq!(status.plan.as_deref(), Some("max"));
263        assert!(status.summary().contains("claude@pathscale.com"));
264    }
265
266    #[test]
267    fn claude_reports_a_logout_as_one() {
268        let status = AuthStatus::read(Agent::Claude, r#"{"loggedIn": false}"#, true);
269        assert_eq!(status.state, AuthState::LoggedOut);
270        assert!(status.needs_login());
271        assert!(status.summary().contains("/login"), "{}", status.summary());
272    }
273
274    /// Verbatim from `codex login status`.
275    #[test]
276    fn codex_prose_is_read_into_a_status() {
277        let status = AuthStatus::read(Agent::Codex, "Logged in using ChatGPT", true);
278        assert_eq!(status.state, AuthState::LoggedIn);
279        assert_eq!(status.method.as_deref(), Some("ChatGPT"));
280    }
281
282    /// "not logged in" contains "logged in", so order of checks decides this.
283    #[test]
284    fn codex_negatives_are_not_read_as_positives() {
285        for text in ["Not logged in", "You are not logged in.", "Logged out"] {
286            let status = AuthStatus::read(Agent::Codex, text, true);
287            assert_eq!(status.state, AuthState::LoggedOut, "{text:?}");
288            assert!(status.summary().contains("codex login"));
289        }
290    }
291
292    #[test]
293    fn unrecognized_output_is_unknown_rather_than_a_guess() {
294        for (agent, text) in [
295            (Agent::Claude, "not json at all"),
296            (Agent::Codex, "something else entirely"),
297        ] {
298            let status = AuthStatus::read(agent, text, true);
299            assert_eq!(status.state, AuthState::Unknown, "{agent}");
300            assert!(!status.is_logged_in());
301            // Crucially not `needs_login`: an unreadable answer is not evidence
302            // that someone has to log in.
303            assert!(!status.needs_login(), "{agent}");
304        }
305    }
306
307    /// Copilot exposes no status command, and saying "logged out" for an agent
308    /// that cannot be asked would send someone to fix a working setup.
309    #[tokio::test]
310    async fn copilot_reports_that_it_cannot_be_checked() {
311        let status = AuthStatus::check_bin(Agent::Copilot, "copilot")
312            .await
313            .expect("an uncheckable agent is not an error");
314        assert_eq!(status.state, AuthState::Unknown);
315        assert!(!status.needs_login());
316        assert!(
317            status.detail.contains("no status command"),
318            "{}",
319            status.detail
320        );
321    }
322
323    #[test]
324    fn only_claude_and_codex_can_be_asked() {
325        assert!(Agent::Claude.auth_status_argv().is_some());
326        assert!(Agent::Codex.auth_status_argv().is_some());
327        assert!(Agent::Copilot.auth_status_argv().is_none());
328    }
329}