kranz_engine/
auth_verify.rs1use crate::backend::{AgentBackend, AgentEvent, PromptMode, SessionExit, SessionSpec};
15use std::collections::HashMap;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum AuthVerdict {
20 Authenticated,
23 Unauthenticated,
27 Inconclusive,
30}
31
32const 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
48fn 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
78pub 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; 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 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 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 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}