1use tokio::process::Command;
23
24use super::claude::{self, ClaudeRunner};
25use super::{
26 AgentError, AgentRunner, ParsedResponse, PermissionRules, StreamEvent, shell_single_quote,
27};
28
29#[derive(Debug)]
31pub struct GrokRunner;
32
33impl GrokRunner {
34 fn parser() -> ClaudeRunner {
35 ClaudeRunner::new("grok")
36 }
37}
38
39impl AgentRunner for GrokRunner {
40 #[cfg(test)]
41 fn name(&self) -> &'static str {
42 "grok"
43 }
44
45 fn stream_events(&self, event: &serde_json::Value) -> Vec<StreamEvent> {
46 claude::stream_events(event)
47 }
48
49 fn router_transcript(&self) -> super::RouterTranscript {
50 super::RouterTranscript::Messages
51 }
52
53 fn extract_text(&self, raw: &str) -> Result<String, AgentError> {
54 Self::parser().extract_text(raw)
55 }
56
57 fn build_command(
58 &self,
59 model: Option<&str>,
60 timeout_cmd: &str,
61 sys_file: &str,
62 msg_file: &str,
63 permissions: &PermissionRules,
64 ) -> String {
65 let sq_sys = shell_single_quote(sys_file);
66 let sq_msg = shell_single_quote(msg_file);
67 let mut args = Vec::new();
68 if !timeout_cmd.is_empty() {
69 args.push(timeout_cmd.to_string());
70 }
71 args.push("grok".to_string());
72 args.push("--no-plan".to_string());
73 args.push("--output-format".to_string());
74 args.push("streaming-messages-json".to_string());
75 args.push("--permission-mode".to_string());
76 args.push(permission_mode(permissions).to_string());
77 args.push("--sandbox".to_string());
78 args.push(sandbox_profile(permissions).to_string());
79 if let Some(model) = model.filter(|m| !m.is_empty()) {
80 args.push("--model".to_string());
81 args.push(shell_single_quote(model));
82 }
83 args.extend(permission_args(permissions));
84 args.push("--system-prompt-override".to_string());
85 args.push(format!("\"$(cat {sq_sys})\""));
86 args.push("--prompt-file".to_string());
87 args.push(sq_msg);
88 args.join(" ")
89 }
90
91 fn parse_response(&self, raw: &str) -> Result<ParsedResponse, AgentError> {
92 Self::parser().parse_response(raw)
93 }
94
95 fn classify_error(&self, exit_code: i32, stdout: &str, stderr: &str) -> AgentError {
96 let extracted = startup_errors(stdout);
97 for message in &extracted {
98 if let Some(err) = classify_grok_message(message) {
99 return err;
100 }
101 }
102 let stderr = if extracted.is_empty() {
103 stderr.to_string()
104 } else {
105 format!("{stderr}\n{}", extracted.join("\n"))
106 };
107 Self::parser().classify_error(exit_code, stdout, &stderr)
108 }
109
110 fn router_command(&self, prompt: &str, model: Option<&str>) -> anyhow::Result<Command> {
111 let mut cmd = Command::new("grok");
114 cmd.arg("--no-plan")
115 .arg("--no-subagents")
116 .arg("--disable-web-search")
117 .arg("--max-turns")
118 .arg("1")
119 .arg("--output-format")
120 .arg("streaming-messages-json")
121 .arg("--permission-mode")
122 .arg("dontAsk")
123 .arg("--disallowed-tools")
124 .arg("read_file,grep,list_dir,search_replace,write,run_terminal_cmd,web_search,web_fetch,task");
125 if let Some(model) = model.filter(|m| !m.is_empty()) {
126 cmd.arg("--model").arg(model);
127 }
128 cmd.arg("-p").arg(prompt);
129 Ok(cmd)
130 }
131}
132
133pub fn find_grok_result(ndjson: &str) -> Option<super::AgentResult> {
135 let mut result = claude::find_claude_result(ndjson)?;
136 let Some((line, _)) = claude::find_ndjson_result_line(ndjson.trim()) else {
137 return Some(result);
138 };
139 let Ok(val) = serde_json::from_str::<serde_json::Value>(line) else {
140 return Some(result);
141 };
142 if result.cost_usd.is_none() {
143 result.cost_usd = val
144 .get("total_cost_usd")
145 .and_then(serde_json::Value::as_f64);
146 }
147 if val.get("stop_reason").and_then(|v| v.as_str()) == Some("max_tokens") {
148 result.truncated_by_length = true;
149 }
150 Some(result)
151}
152
153fn permission_mode(permissions: &PermissionRules) -> &'static str {
154 if permissions.autonomous {
155 "bypassPermissions"
156 } else if permissions.deny_read_only {
157 "dontAsk"
158 } else {
159 "acceptEdits"
160 }
161}
162
163fn sandbox_profile(permissions: &PermissionRules) -> &'static str {
164 if permissions.deny_read_only {
165 "read-only"
166 } else {
167 "off"
168 }
169}
170
171fn grok_tool_id(name: &str) -> Option<&'static str> {
173 Some(match name {
174 "Read" => "read_file",
175 "Grep" => "grep",
176 "Glob" => "list_dir",
177 "Edit" => "search_replace",
178 "Write" => "write",
179 "Bash" => "run_terminal_cmd",
180 "WebFetch" => "web_fetch",
181 "WebSearch" => "web_search",
182 "Task" => "task",
183 "TodoWrite" => "todo_write",
184 _ => return None,
185 })
186}
187
188fn permission_args(permissions: &PermissionRules) -> Vec<String> {
189 if !permissions.allowed_tools.is_empty() {
190 return allow_args(permissions);
191 }
192 deny_args(&permissions.disallowed_tools)
193}
194
195fn allow_args(permissions: &PermissionRules) -> Vec<String> {
196 let mut tools = Vec::new();
197 let mut allows = Vec::new();
198 for item in &permissions.allowed_tools {
199 if let Some(id) = grok_tool_id(item) {
200 if !tools.contains(&id) {
201 tools.push(id);
202 }
203 if (item == "Edit" || item == "Write") && !permissions.allowed_edit_paths.is_empty() {
204 for path in &permissions.allowed_edit_paths {
205 allows.push(format!("{item}({}/**)", path.display()));
206 }
207 }
208 } else if item.chars().next().is_some_and(|c| c.is_ascii_lowercase()) {
209 allows.push(format!("Bash({item} *)"));
212 }
213 }
214 let mut args = Vec::new();
216 if !tools.is_empty() {
217 args.push("--tools".to_string());
218 args.push(shell_single_quote(&tools.join(",")));
219 }
220 for rule in allows {
221 args.push("--allow".to_string());
222 args.push(shell_single_quote(&rule));
223 }
224 args.extend(deny_args(&permissions.disallowed_tools));
225 args
226}
227
228fn deny_args(rules: &[String]) -> Vec<String> {
229 let mut remove = Vec::new();
230 let mut denies = Vec::new();
231 for rule in rules {
232 match rule.as_str() {
233 "Read" | "Grep" | "Glob" | "Edit" | "Write" | "Bash" => {
234 if let Some(id) = grok_tool_id(rule)
235 && !remove.contains(&id)
236 {
237 remove.push(id);
238 }
239 }
240 _ => denies.push(rule.as_str()),
241 }
242 }
243 let mut args = Vec::new();
244 if !remove.is_empty() {
245 args.push("--disallowed-tools".to_string());
246 args.push(shell_single_quote(&remove.join(",")));
247 }
248 for rule in denies {
249 args.push("--deny".to_string());
250 args.push(shell_single_quote(rule));
251 }
252 args
253}
254
255fn startup_errors(stdout: &str) -> Vec<String> {
257 let mut out = Vec::new();
258 for line in stdout.lines() {
259 let line = line.trim();
260 if line.is_empty() {
261 continue;
262 }
263 let Ok(val) = serde_json::from_str::<serde_json::Value>(line) else {
264 continue;
265 };
266 if val.get("type").and_then(|t| t.as_str()) != Some("error") {
267 continue;
268 }
269 if let Some(message) = val.get("message").and_then(|m| m.as_str())
270 && !message.is_empty()
271 {
272 out.push(message.to_string());
273 }
274 }
275 out
276}
277
278fn classify_grok_message(message: &str) -> Option<AgentError> {
280 let lower = message.to_lowercase();
281 if lower.contains("not logged in")
282 || lower.contains("please log in")
283 || lower.contains("grok login")
284 || lower.contains("xai_api_key")
285 || lower.contains("authentication failed")
286 || lower.contains("could not refresh")
287 {
288 return Some(AgentError::Auth {
289 message: message.to_string(),
290 });
291 }
292 if lower.contains("permission required") {
293 return Some(AgentError::PermissionDenied {
294 message: message.to_string(),
295 });
296 }
297 None
298}
299
300#[cfg(test)]
301mod tests {
302 use std::path::PathBuf;
303
304 use super::*;
305 use crate::engine::runner::agents::SandboxLevel;
306
307 fn runner() -> GrokRunner {
308 GrokRunner
309 }
310
311 fn perms(autonomous: bool, disallowed: &[&str], allowed: &[&str]) -> PermissionRules {
312 PermissionRules {
313 autonomous,
314 sandbox: SandboxLevel::WorkspaceWrite,
315 disallowed_tools: disallowed.iter().map(|s| (*s).to_string()).collect(),
316 allowed_tools: allowed.iter().map(|s| (*s).to_string()).collect(),
317 allowed_edit_paths: vec![],
318 deny_read_only: false,
319 extra_writable_dirs: vec![],
320 }
321 }
322
323 #[test]
324 fn build_command_autonomous_denies_rm_and_passes_files() {
325 let cmd = runner().build_command(
326 Some("fast"),
327 "timeout 1800",
328 "/tmp/sys.txt",
329 "/tmp/msg.txt",
330 &perms(true, &["Bash(rm *)", "Bash(git push*)"], &[]),
331 );
332 assert!(cmd.starts_with("timeout 1800 grok "), "{cmd}");
333 assert!(cmd.contains("--model 'fast'"), "{cmd}");
334 assert!(
335 cmd.contains("--output-format streaming-messages-json"),
336 "{cmd}"
337 );
338 assert!(cmd.contains("--permission-mode bypassPermissions"), "{cmd}");
339 assert!(cmd.contains("--sandbox off"), "{cmd}");
340 assert!(cmd.contains("--no-plan"), "{cmd}");
341 assert!(cmd.contains("--deny 'Bash(rm *)'"), "{cmd}");
342 assert!(cmd.contains("--deny 'Bash(git push*)'"), "{cmd}");
343 assert!(cmd.contains("--prompt-file '/tmp/msg.txt'"), "{cmd}");
344 assert!(
345 cmd.contains("--system-prompt-override \"$(cat '/tmp/sys.txt')\""),
346 "{cmd}"
347 );
348 assert!(!cmd.contains("< "), "{cmd}");
349 }
350
351 #[test]
352 fn build_command_omits_empty_model() {
353 let cmd = runner().build_command(
354 Some(""),
355 "",
356 "/tmp/sys.txt",
357 "/tmp/msg.txt",
358 &perms(true, &[], &[]),
359 );
360 assert!(!cmd.contains("--model"), "{cmd}");
361 }
362
363 #[test]
364 fn build_command_supervised_accepts_edits() {
365 let cmd = runner().build_command(
366 None,
367 "",
368 "/tmp/sys.txt",
369 "/tmp/msg.txt",
370 &perms(false, &[], &[]),
371 );
372 assert!(cmd.contains("--permission-mode acceptEdits"), "{cmd}");
373 }
374
375 #[test]
376 fn build_command_read_only_removes_read_tools() {
377 let rules = PermissionRules::deny_read_only();
378 let cmd = runner().build_command(None, "", "/tmp/sys.txt", "/tmp/msg.txt", &rules);
379 assert!(cmd.contains("--sandbox read-only"), "{cmd}");
380 assert!(cmd.contains("--permission-mode bypassPermissions"), "{cmd}");
381 assert!(cmd.contains("read_file"), "{cmd}");
382 assert!(cmd.contains("grep"), "{cmd}");
383 assert!(cmd.contains("list_dir"), "{cmd}");
384 assert!(cmd.contains("--deny 'Bash(cat *)'"), "{cmd}");
385 }
386
387 #[test]
388 fn build_command_allowlist_keeps_destructive_denies() {
389 let mut rules = perms(true, &["Bash(rm *)"], &["Edit", "Write", "Bash", "git"]);
390 rules.allowed_edit_paths = vec![PathBuf::from("/work/tree")];
391 let cmd = runner().build_command(None, "", "/tmp/sys.txt", "/tmp/msg.txt", &rules);
392 assert!(
393 cmd.contains("--tools 'search_replace,write,run_terminal_cmd'"),
394 "{cmd}"
395 );
396 assert!(cmd.contains("--allow 'Edit(/work/tree/**)'"), "{cmd}");
397 assert!(cmd.contains("--allow 'Write(/work/tree/**)'"), "{cmd}");
398 assert!(cmd.contains("--allow 'Bash(git *)'"), "{cmd}");
399 assert!(cmd.contains("--deny 'Bash(rm *)'"), "{cmd}");
400 }
401
402 #[test]
403 fn parse_response_reads_messages_result() {
404 let raw = concat!(
405 r#"{"type":"system","subtype":"init","model":"test"}"#,
406 "\n",
407 r#"{"type":"result","subtype":"success","is_error":false,"result":"{\"status\":\"done\",\"summary\":\"shipped\",\"accomplished\":[\"a\"],\"remaining\":[],\"files\":[]}","usage":{"input_tokens":8,"output_tokens":3},"total_cost_usd":0.02,"duration_ms":40,"stop_reason":"end_turn"}"#,
408 "\n",
409 );
410 let parsed = runner().parse_response(raw).expect("parse");
411 assert_eq!(parsed.response.status, "done");
412 assert_eq!(parsed.response.summary, "shipped");
413 assert_eq!(parsed.input_tokens, Some(8));
414 assert_eq!(parsed.output_tokens, Some(3));
415 }
416
417 #[test]
418 fn find_result_reads_cost_and_max_tokens() {
419 let raw = r#"{"type":"result","subtype":"success","is_error":false,"result":"partial","usage":{"input_tokens":1,"output_tokens":2},"total_cost_usd":1.5,"stop_reason":"max_tokens"}"#;
420 let result = find_grok_result(raw).expect("result");
421 assert_eq!(result.result_text, "partial");
422 assert_eq!(result.cost_usd, Some(1.5));
423 assert!(result.truncated_by_length);
424 assert_eq!(result.input_tokens, Some(1));
425 }
426
427 #[test]
428 fn classify_error_rate_limit_from_error_event() {
429 let stdout = r#"{"type":"error","message":"429 Too Many Requests"}"#;
430 let err = runner().classify_error(1, stdout, "");
431 assert!(matches!(err, AgentError::RateLimit { .. }), "{err}");
432 }
433
434 #[test]
435 fn classify_error_model_unavailable() {
436 let stdout = r#"{"type":"error","message":"The model `missing` is not supported"}"#;
437 let err = runner().classify_error(1, stdout, "");
438 match err {
439 AgentError::ModelUnavailable { model, .. } => assert_eq!(model, "missing"),
440 other => panic!("expected model unavailable, got {other}"),
441 }
442 }
443
444 #[test]
445 fn classify_error_login_is_auth() {
446 let stdout = r#"{"type":"error","message":"Not logged in. Run grok login."}"#;
447 let err = runner().classify_error(1, stdout, "");
448 assert!(matches!(err, AgentError::Auth { .. }), "{err}");
449 }
450
451 #[test]
452 fn classify_error_permission_required() {
453 let stdout =
454 r#"{"type":"error","message":"permission required: Edit calc.py was not run"}"#;
455 let err = runner().classify_error(1, stdout, "");
456 assert!(matches!(err, AgentError::PermissionDenied { .. }), "{err}");
457 }
458}