Skip to main content

kranz_engine/
auth_verify.rs

1//! Auth-preflight seam (docs/scoping/claude-cli-min-env.md; mission m-165b6f).
2//!
3//! `docs/scoping/claude-cli-min-env.md` claims Keychain auth is
4//! HOME-independent; mission m-66aff8 contradicted that claim in practice
5//! (workers died at turn 1 with "Not logged in", $0 cost, zero tool-use,
6//! producing empty diffs while the mission falsely completed). Rather than
7//! trust the heuristic again, [`verify_worker_auth`] drives a real trivial
8//! session under a candidate env and classifies what actually happened.
9//!
10//! Not wired into the spawn path yet — that is a later feature. This module
11//! is a pure seam over [`AgentBackend`] so it is exercised entirely offline
12//! via [`crate::backend_mock::MockBackend`].
13
14use crate::backend::{AgentBackend, AgentEvent, PromptMode, SessionExit, SessionSpec};
15use std::collections::HashMap;
16
17/// Outcome of driving a trivial session under a candidate worker env.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum AuthVerdict {
20    /// The session produced a normal assistant reply: the candidate env
21    /// authenticates.
22    Authenticated,
23    /// The session exhibited a known auth-failure signature (an explicit
24    /// "not logged in" style message, or terminated with zero assistant
25    /// activity at zero cost / a non-success exit).
26    Unauthenticated,
27    /// Neither of the above could be established (spawn/IO error, or an
28    /// ambiguous result) — fail-safe, never a panic.
29    Inconclusive,
30}
31
32/// Substrings (checked case-insensitively) that indicate the `claude` CLI
33/// rejected the session for lack of authentication.
34const AUTH_FAILURE_SIGNATURES: &[&str] = &[
35    "not logged in",
36    "not authenticated",
37    "please run /login",
38    "please run `claude login`",
39];
40
41fn has_auth_failure_signature(text: &str) -> bool {
42    let lower = text.to_lowercase();
43    AUTH_FAILURE_SIGNATURES
44        .iter()
45        .any(|sig| lower.contains(sig))
46}
47
48/// Build the minimal single-shot [`SessionSpec`] used to probe whether
49/// `candidate_env` lets the worker's `claude` CLI authenticate: a tiny prompt
50/// asking the model to reply with a single token, cwd-independent (auth is
51/// what's under test, not repo state).
52fn probe_spec(candidate_env: &HashMap<String, String>) -> SessionSpec {
53    SessionSpec {
54        cwd: std::env::temp_dir(),
55        prompt: PromptMode::SingleShot(
56            "Reply with the single word: ack. Nothing else.".to_string(),
57        ),
58        append_system_prompt: None,
59        model: "haiku".to_string(),
60        effort: "low".to_string(),
61        session_id: uuid::Uuid::new_v4().to_string(),
62        resume: None,
63        permission_mode: None,
64        allowed_tools: Vec::new(),
65        disallowed_tools: Vec::new(),
66        tools: Vec::new(),
67        writable: false,
68        settings_json: None,
69        json_schema: None,
70        max_budget_usd: None,
71        max_turns: Some(1),
72        env: candidate_env.clone(),
73        sandbox: None,
74        hook_status: None,
75    }
76}
77
78/// Drive a trivial session under `candidate_env` via `backend` and classify
79/// whether the worker's `claude` CLI can authenticate under it. Never panics;
80/// any spawn/IO error or ambiguous outcome yields [`AuthVerdict::Inconclusive`]
81/// rather than a guess in either direction.
82pub async fn verify_worker_auth(
83    backend: &dyn AgentBackend,
84    candidate_env: &HashMap<String, String>,
85) -> AuthVerdict {
86    let spec = probe_spec(candidate_env);
87
88    let mut session = match backend.start(spec).await {
89        Ok(session) => session,
90        Err(_) => return AuthVerdict::Inconclusive,
91    };
92
93    let mut saw_assistant_text = false;
94    let mut result: Option<(bool, Option<f64>)> = None; // (is_error, cost_usd)
95
96    loop {
97        match session.next_event().await {
98            Ok(Some(event)) => match event {
99                AgentEvent::Text { text, .. } => {
100                    if has_auth_failure_signature(&text) {
101                        return AuthVerdict::Unauthenticated;
102                    }
103                    if !text.trim().is_empty() {
104                        saw_assistant_text = true;
105                    }
106                }
107                AgentEvent::Result {
108                    text,
109                    is_error,
110                    cost_usd,
111                    ..
112                } => {
113                    if has_auth_failure_signature(&text) {
114                        return AuthVerdict::Unauthenticated;
115                    }
116                    result = Some((is_error, cost_usd));
117                }
118                AgentEvent::Other { raw } => {
119                    if let Some(text) = raw.as_str() {
120                        if has_auth_failure_signature(text) {
121                            return AuthVerdict::Unauthenticated;
122                        }
123                    }
124                }
125                _ => {}
126            },
127            Ok(None) => break,
128            Err(_) => return AuthVerdict::Inconclusive,
129        }
130    }
131
132    let exit_failed_message = match session.exit_status() {
133        Some(SessionExit::Failed(msg)) => Some(msg),
134        _ => None,
135    };
136    if let Some(msg) = &exit_failed_message {
137        if has_auth_failure_signature(msg) {
138            return AuthVerdict::Unauthenticated;
139        }
140    }
141
142    match result {
143        Some((is_error, cost_usd)) => {
144            let zero_cost = cost_usd.unwrap_or(0.0) <= 0.0;
145            if !is_error && saw_assistant_text {
146                AuthVerdict::Authenticated
147            } else if is_error && !saw_assistant_text && zero_cost {
148                AuthVerdict::Unauthenticated
149            } else {
150                AuthVerdict::Inconclusive
151            }
152        }
153        None => AuthVerdict::Inconclusive,
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use crate::backend_mock::{
161        mock_init, mock_result_error, mock_result_text, MockBackend, MockScript,
162    };
163
164    fn candidate_env() -> HashMap<String, String> {
165        let mut env = HashMap::new();
166        env.insert("HOME".to_string(), "/tmp/kranz-scratch-home".to_string());
167        env.insert(
168            "CLAUDE_CONFIG_DIR".to_string(),
169            "/tmp/kranz-scratch-home/.claude".to_string(),
170        );
171        env
172    }
173
174    #[tokio::test]
175    async fn verify_worker_auth_normal_reply_is_authenticated() {
176        let backend = MockBackend::with_scripts(vec![MockScript::single_shot("ack")]);
177
178        let verdict = verify_worker_auth(&backend, &candidate_env()).await;
179
180        assert_eq!(verdict, AuthVerdict::Authenticated);
181        // The candidate env is what was actually handed to the backend.
182        let started = backend.started_specs();
183        assert_eq!(started.len(), 1);
184        assert_eq!(
185            started[0].env.get("HOME").map(String::as_str),
186            Some("/tmp/kranz-scratch-home")
187        );
188    }
189
190    #[tokio::test]
191    async fn verify_worker_auth_not_logged_in_zero_activity_is_unauthenticated() {
192        let script = MockScript {
193            events: vec![
194                mock_init("mock-session"),
195                mock_result_error("Not logged in"),
196            ],
197            ..Default::default()
198        };
199        let backend = MockBackend::with_scripts(vec![script]);
200
201        let verdict = verify_worker_auth(&backend, &candidate_env()).await;
202
203        assert_eq!(verdict, AuthVerdict::Unauthenticated);
204    }
205
206    #[tokio::test]
207    async fn verify_worker_auth_zero_activity_zero_cost_is_unauthenticated() {
208        let mut zero_cost_error = mock_result_error("session ended");
209        if let AgentEvent::Result { cost_usd, .. } = &mut zero_cost_error {
210            *cost_usd = Some(0.0);
211        }
212        let script = MockScript {
213            events: vec![mock_init("mock-session"), zero_cost_error],
214            ..Default::default()
215        };
216        let backend = MockBackend::with_scripts(vec![script]);
217
218        let verdict = verify_worker_auth(&backend, &candidate_env()).await;
219
220        assert_eq!(verdict, AuthVerdict::Unauthenticated);
221    }
222
223    #[tokio::test]
224    async fn verify_worker_auth_backend_start_error_is_inconclusive() {
225        // No scripts queued: MockBackend::start errors with "no script queued".
226        let backend = MockBackend::new();
227
228        let verdict = verify_worker_auth(&backend, &candidate_env()).await;
229
230        assert_eq!(verdict, AuthVerdict::Inconclusive);
231    }
232
233    #[tokio::test]
234    async fn verify_worker_auth_ambiguous_success_with_no_text_is_inconclusive() {
235        // Successful result but no assistant text at all: ambiguous, not a
236        // confident Authenticated call.
237        let script = MockScript {
238            events: vec![mock_init("mock-session"), mock_result_text("")],
239            ..Default::default()
240        };
241        let backend = MockBackend::with_scripts(vec![script]);
242
243        let verdict = verify_worker_auth(&backend, &candidate_env()).await;
244
245        assert_eq!(verdict, AuthVerdict::Inconclusive);
246    }
247
248    #[test]
249    fn has_auth_failure_signature_is_case_insensitive() {
250        assert!(has_auth_failure_signature("Not Logged In"));
251        assert!(has_auth_failure_signature("ERROR: not authenticated"));
252        assert!(!has_auth_failure_signature("ack"));
253    }
254}