1use std::collections::HashSet;
30use std::io::{BufRead, BufReader, Read, Write};
31use std::os::unix::process::CommandExt;
32use std::path::{Path, PathBuf};
33use std::sync::{Arc, Mutex};
34use std::time::{Duration, Instant};
35
36use crate::claude_ask::emit_event;
37use crate::paths::AgentsHome;
38
39const DEFAULT_ASK_TIMEOUT: Duration = Duration::from_secs(600);
42const AGY_PRINT_TIMEOUT: &str = "5m";
44const STDERR_CAP: usize = 256 * 1024;
46
47fn raw_head(s: &str) -> String {
49 s.chars().take(200).collect()
50}
51
52pub fn inject_from_name(prompt: &str, from_name: &str) -> String {
58 format!("[from: {}]\n\n{}", from_name, prompt)
59}
60
61fn agy_yolo_flags() -> Vec<String> {
66 vec!["--dangerously-skip-permissions".to_string()]
67}
68
69pub fn build_argv_once(
81 full_prompt: &str,
82 cwd: &Path,
83 model: Option<&str>,
84 add_dir: Option<&str>,
85) -> Vec<String> {
86 let mut argv = vec![
87 "agy".to_string(),
88 "--print-timeout".to_string(),
89 AGY_PRINT_TIMEOUT.to_string(),
90 "--add-dir".to_string(),
91 cwd.to_string_lossy().into_owned(),
92 ];
93 if let Some(d) = add_dir.filter(|d| !d.is_empty()) {
94 argv.push("--add-dir".to_string());
95 argv.push(d.to_string());
96 }
97 argv.extend(agy_yolo_flags());
98 if let Some(m) = model {
99 if !m.is_empty() {
100 argv.push("--model".to_string());
101 argv.push(m.to_string());
102 }
103 }
104 argv.push("-p".to_string());
106 argv.push(full_prompt.to_string());
107 argv
108}
109
110pub fn parse_response(stdout_text: &str) -> Result<String, AgyAskError> {
114 let trimmed = stdout_text.trim();
115 if trimmed.is_empty() {
116 return Err(AgyAskError::Empty {
117 raw_head: raw_head(stdout_text),
118 });
119 }
120 Ok(trimmed.to_string())
121}
122
123pub fn classify_failure(stderr_text: &str, exit_code: i32) -> AgyAskError {
128 let blob = stderr_text.to_ascii_lowercase();
129 if blob.contains("quota") || blob.contains("rate limit") || blob.contains("resource exhausted")
130 {
131 return AgyAskError::Quota;
132 }
133 if blob.contains("unauthenticated")
134 || blob.contains("unauthorized")
135 || blob.contains("sign in")
136 || blob.contains("please authenticate")
137 || blob.contains("reauth")
138 {
139 return AgyAskError::Auth;
140 }
141 if blob.contains("timed out")
142 || blob.contains("deadline exceeded")
143 || blob.contains("print-timeout")
144 {
145 return AgyAskError::Timeout { timeout_sec: 0.0 };
146 }
147 AgyAskError::Invocation { exit_code }
148}
149
150#[derive(Debug)]
158pub enum AgyAskError {
159 NotFound,
161 Empty { raw_head: String },
163 Quota,
165 Auth,
167 Timeout { timeout_sec: f64 },
169 Invocation { exit_code: i32 },
171 OsError { message: String },
173 Interrupted,
175}
176
177impl AgyAskError {
178 pub fn exit_code(&self) -> i32 {
180 match self {
181 AgyAskError::NotFound => 13,
182 AgyAskError::Empty { .. } => 3,
183 AgyAskError::Quota => 10,
184 AgyAskError::Auth => 11,
185 AgyAskError::Timeout { .. } => 12,
186 AgyAskError::Invocation { exit_code } => {
187 if *exit_code != 0 {
188 2
189 } else {
190 1
191 }
192 }
193 AgyAskError::OsError { .. } => 1,
194 AgyAskError::Interrupted => 130,
195 }
196 }
197}
198
199impl std::fmt::Display for AgyAskError {
200 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
201 match self {
202 AgyAskError::NotFound => {
203 write!(
204 f,
205 "agy binary not found on PATH — install the Antigravity CLI"
206 )
207 }
208 AgyAskError::Empty { raw_head } => write!(
209 f,
210 "agy returned empty output; first {} chars: {:?}",
211 raw_head.chars().count(),
212 raw_head
213 ),
214 AgyAskError::Quota => write!(f, "agy quota / rate limit exhausted"),
215 AgyAskError::Auth => write!(f, "agy not authenticated — run `agy` once to sign in"),
216 AgyAskError::Timeout { timeout_sec } => {
217 write!(f, "agy timed out after {}s", timeout_sec)
218 }
219 AgyAskError::Invocation { exit_code } => write!(f, "agy exited {}", exit_code),
220 AgyAskError::OsError { message } => {
221 write!(f, "agy provider: OSError invoking agy: {}", message)
222 }
223 AgyAskError::Interrupted => write!(f, "agy interrupted by SIGINT (Ctrl-C)"),
224 }
225 }
226}
227
228impl std::error::Error for AgyAskError {}
229
230pub fn ensure_agy_folder_trusted(cwd: &Path) {
250 let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
251 return; };
253 ensure_trusted_at(&home.join(".gemini").join("trustedFolders.json"), cwd);
254}
255
256fn ensure_trusted_at(file: &Path, cwd: &Path) {
265 let cwd_abs = if cwd.is_absolute() {
268 cwd.to_path_buf()
269 } else {
270 std::env::current_dir()
271 .map(|d| d.join(cwd))
272 .unwrap_or_else(|_| cwd.to_path_buf())
273 };
274 let cwd_key = cwd_abs.to_string_lossy().into_owned();
275
276 let mut map: serde_json::Map<String, serde_json::Value> = match std::fs::read_to_string(file) {
280 Ok(s) if s.trim().is_empty() => serde_json::Map::new(),
281 Ok(s) => match serde_json::from_str::<serde_json::Value>(&s) {
282 Ok(serde_json::Value::Object(m)) => m,
283 _ => {
284 eprintln!(
285 "fno-agents: agy trust: {:?} is not a JSON object; leaving untouched",
286 file
287 );
288 return;
289 }
290 },
291 Err(e) if e.kind() == std::io::ErrorKind::NotFound => serde_json::Map::new(),
292 Err(e) => {
293 eprintln!("fno-agents: agy trust: cannot read {:?}: {}", file, e);
294 return;
295 }
296 };
297
298 if map.contains_key(&cwd_key) {
301 return;
302 }
303 for (k, v) in &map {
304 if v.as_str() == Some("TRUST_PARENT") && cwd_abs.starts_with(Path::new(k)) {
305 return;
306 }
307 }
308
309 map.insert(
312 cwd_key,
313 serde_json::Value::String("TRUST_FOLDER".to_string()),
314 );
315
316 let Ok(serialized) = serde_json::to_string_pretty(&map) else {
317 return;
318 };
319 if let Some(dir) = file.parent() {
320 let _ = std::fs::create_dir_all(dir);
321 }
322 static TRUST_TMP_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
333 let seq = TRUST_TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
334 let tmp = file.with_extension(format!("tmp.{}.{}", std::process::id(), seq));
335 if std::fs::write(&tmp, serialized.as_bytes()).is_err() {
336 let _ = std::fs::remove_file(&tmp);
337 eprintln!("fno-agents: agy trust: temp write failed near {:?}", file);
338 return;
339 }
340 if let Err(e) = std::fs::rename(&tmp, file) {
341 let _ = std::fs::remove_file(&tmp);
342 eprintln!(
343 "fno-agents: agy trust: rename into {:?} failed: {}",
344 file, e
345 );
346 }
347}
348
349#[derive(Debug, Clone)]
351pub struct AgyResult {
352 pub exit_code: i32,
353 pub last_msg: String,
354 pub duration_ms: u64,
355}
356
357fn open_tee(log_path: &Path) -> Result<std::fs::File, AgyAskError> {
363 crate::subprocess_ask::open_tee(log_path).map_err(|e| AgyAskError::OsError {
364 message: format!("cannot open output tee: {}", e),
365 })
366}
367
368fn run_agy(
372 argv: &[String],
373 output_path: &Path,
374 timeout: Option<Duration>,
375 popen_cwd: &Path,
376 agent_self: Option<&str>,
377) -> Result<AgyResult, AgyAskError> {
378 use std::process::{Command, Stdio};
379
380 let started = Instant::now();
381 let tee_fh = open_tee(output_path)?;
382
383 let argv = crate::spawn_gate::qos_wrap(popen_cwd, argv.to_vec());
386 let mut cmd = Command::new(&argv[0]);
387 cmd.args(&argv[1..]);
388 cmd.stdin(Stdio::null());
391 cmd.stdout(Stdio::piped());
392 cmd.stderr(Stdio::piped());
393 cmd.current_dir(popen_cwd);
394 if let Some(name) = agent_self {
395 cmd.env("FNO_AGENT_SELF", name);
396 cmd.env("FNO_AGENT_PROVIDER", "agy");
397 }
398 unsafe {
400 cmd.pre_exec(|| {
401 libc::setpgid(0, 0);
402 Ok(())
403 });
404 }
405
406 let mut child = match cmd.spawn() {
407 Ok(c) => c,
408 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(AgyAskError::NotFound),
409 Err(e) => {
410 eprintln!("agy provider: OSError invoking agy: {}", e);
411 return Err(AgyAskError::OsError {
412 message: e.to_string(),
413 });
414 }
415 };
416
417 let pid = child.id();
418 let _sigint_guard = crate::subprocess_ask::SigintForwarder::install(pid);
421
422 let stdout_pipe = child.stdout.take().expect("stdout piped");
423 let stderr_pipe = child.stderr.take().expect("stderr piped");
424
425 let tee = Arc::new(Mutex::new(tee_fh));
426 let tee_stderr = tee.clone();
427 let stderr_capture: Arc<Mutex<String>> = Arc::new(Mutex::new(String::new()));
430 let capture_handle = stderr_capture.clone();
431
432 let stderr_handle = std::thread::spawn(move || {
433 let mut warned: HashSet<String> = HashSet::new();
434 let mut total: usize = 0;
435 let mut reader = BufReader::new(stderr_pipe);
436 let mut line = String::new();
437 loop {
438 line.clear();
439 match reader.read_line(&mut line) {
440 Ok(0) => break,
441 Ok(n) => {
442 if let Ok(mut guard) = tee_stderr.lock() {
444 if let Err(e) = guard.write_all(line.as_bytes()) {
445 let key = e.to_string();
446 if warned.insert(key) {
447 eprintln!("agy provider: stderr tee write failed: {}", e);
448 }
449 } else {
450 let _ = guard.flush();
451 }
452 }
453 if total < STDERR_CAP {
455 if let Ok(mut cap) = capture_handle.lock() {
456 cap.push_str(&line);
457 }
458 }
459 total += n;
460 }
461 Err(_) => break,
462 }
463 }
464 });
465
466 let mut watchdog = crate::subprocess_ask::AskWatchdog::spawn(pid, timeout);
467
468 let mut stdout_bytes: Vec<u8> = Vec::new();
472 {
473 let mut reader = stdout_pipe;
474 if let Err(e) = reader.read_to_end(&mut stdout_bytes) {
475 eprintln!("agy provider: stdout stream read error: {}", e);
476 }
477 }
478 let stdout_text = String::from_utf8(stdout_bytes)
479 .unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned());
480
481 if !stdout_text.is_empty() {
483 if let Ok(mut guard) = tee.lock() {
484 if guard.write_all(stdout_text.as_bytes()).is_ok() {
485 if !stdout_text.ends_with('\n') {
486 let _ = guard.write_all(b"\n");
487 }
488 let _ = guard.flush();
489 }
490 }
491 }
492
493 watchdog.cancel();
494 let (exit_code, sigkill_escalated) =
495 crate::subprocess_ask::wait_with_grace(pid, &mut child, 5.0);
496 watchdog.join();
497 if stderr_handle.join().is_err() {
498 eprintln!("agy provider: stderr drain thread panicked");
499 }
500
501 let duration_ms = started.elapsed().as_millis() as u64;
502 let was_timed_out = watchdog.timed_out();
503 let stderr_text = stderr_capture
506 .lock()
507 .map(|mut s| std::mem::take(&mut *s))
508 .unwrap_or_default();
509
510 if crate::subprocess_ask::ask_interrupted() {
512 return Err(AgyAskError::Interrupted);
513 }
514 if was_timed_out || sigkill_escalated {
515 return Err(AgyAskError::Timeout {
516 timeout_sec: timeout.map(|d| d.as_secs_f64()).unwrap_or(0.0),
517 });
518 }
519 if exit_code != 0 {
521 return Err(classify_failure(&stderr_text, exit_code));
522 }
523 let reply = parse_response(&stdout_text)?;
525 Ok(AgyResult {
526 exit_code,
527 last_msg: reply,
528 duration_ms,
529 })
530}
531
532pub fn agy_create(
534 cwd: &Path,
535 prompt: &str,
536 from_name: &str,
537 model: Option<&str>,
538 output_path: &Path,
539 timeout: Option<Duration>,
540 agent_self: Option<&str>,
541 add_dir: Option<&str>,
542) -> Result<AgyResult, AgyAskError> {
543 let full_prompt = inject_from_name(prompt, from_name);
544 let argv = build_argv_once(&full_prompt, cwd, model, add_dir);
545 run_agy(&argv, output_path, timeout, cwd, agent_self)
546}
547
548#[derive(Debug, Clone, PartialEq, Eq)]
555pub struct AskOutcome {
556 pub stdout: String,
557 pub stderr: String,
558 pub exit_code: i32,
559}
560
561impl AskOutcome {
562 fn ok_reply(reply: String) -> Self {
563 Self {
564 stdout: reply,
565 stderr: String::new(),
566 exit_code: 0,
567 }
568 }
569 fn err(msg: impl Into<String>, code: i32) -> Self {
570 Self {
571 stdout: String::new(),
572 stderr: format!("{}\n", msg.into()),
573 exit_code: code,
574 }
575 }
576}
577
578fn derive_log_path(home: &AgentsHome, name: &str) -> std::path::PathBuf {
580 home.root()
581 .join("agents")
582 .join("logs")
583 .join(format!("{}.jsonl", name))
584}
585
586#[allow(clippy::too_many_arguments)]
592pub fn dispatch_agy_once(
593 home: &AgentsHome,
594 name: &str,
595 message: &str,
596 from_name: &str,
597 cwd: &Path,
598 model: Option<&str>,
599 timeout: Option<Duration>,
600 add_dir: Option<&str>,
601) -> AskOutcome {
602 use crate::claude_ask::py_repr;
603 if let Err(msg) = crate::claude_ask::validate_spawn_inputs(name, from_name) {
604 return AskOutcome::err(msg, 2);
605 }
606
607 let events = home.events_jsonl();
608
609 let registry = match crate::state::load_registry(&home.registry_json()) {
615 Ok(r) => r,
616 Err(e) => {
617 emit_event(
618 &events,
619 "agent_ask_failed",
620 &[
621 ("stage", "registry-read".into()),
622 ("name", name.into()),
623 ("provider", "agy".into()),
624 ("error", e.to_string().into()),
625 ],
626 );
627 return AskOutcome::err(format!("registry read failed: {}", e), 12);
628 }
629 };
630 if registry.find(name).is_some() {
631 return AskOutcome::err(
632 format!(
633 "agent {} already exists; use 'fno agents rm {}' first or pick another name",
634 py_repr(name),
635 name
636 ),
637 2,
638 );
639 }
640
641 let effective_message = if message.is_empty() { "hello" } else { message };
643 let log_path = derive_log_path(home, name);
644 if let Some(parent) = log_path.parent() {
645 let _ = std::fs::create_dir_all(parent);
646 }
647
648 let eff_timeout = timeout.or(Some(DEFAULT_ASK_TIMEOUT));
649 match agy_create(
650 cwd,
651 effective_message,
652 from_name,
653 model,
654 &log_path,
655 eff_timeout,
656 Some(name),
657 add_dir,
658 ) {
659 Ok(res) => AskOutcome::ok_reply(res.last_msg),
660 Err(e) => {
661 let code = e.exit_code();
662 emit_event(
663 &events,
664 "agent_ask_failed",
665 &[
666 ("stage", "agy-once".into()),
667 ("name", name.into()),
668 ("provider", "agy".into()),
669 ("error", e.to_string().into()),
670 ],
671 );
672 AskOutcome::err(e.to_string(), code)
673 }
674 }
675}
676
677pub fn maybe_run_agy_ask(home: &AgentsHome, params: &serde_json::Value, name: &str) -> Option<i32> {
683 let provider_param = params.get("provider").and_then(|v| v.as_str());
684 let registry = match crate::state::load_registry(&home.registry_json()) {
685 Ok(r) => r,
686 Err(e) => {
687 eprintln!(
688 "fno-agents: cannot read agents registry at {:?}: {}",
689 home.registry_json(),
690 e
691 );
692 return Some(12);
693 }
694 };
695 let existing_provider = registry.find(name).map(|e| e.harness_name());
698 let resolved = existing_provider.or(provider_param);
699 if resolved != Some("agy") {
700 return None; }
702 eprintln!(
703 "fno-agents: agy does not support stateful 'ask' resume (plain-text output, no session id); \
704 use 'fno agents spawn --harness agy --once <name> --message <prompt>' for a one-shot."
705 );
706 Some(2)
707}
708
709#[cfg(test)]
710mod trust_tests {
711 use super::*;
712
713 fn read_map(file: &Path) -> serde_json::Map<String, serde_json::Value> {
714 let s = std::fs::read_to_string(file).unwrap();
715 match serde_json::from_str(&s).unwrap() {
716 serde_json::Value::Object(m) => m,
717 other => panic!("not a JSON object: {other:?}"),
718 }
719 }
720
721 #[test]
723 fn absent_file_created_with_trust_folder() {
724 let dir = tempfile::tempdir().unwrap();
725 let file = dir.path().join(".gemini").join("trustedFolders.json");
726 let cwd = dir.path().join("work");
727 ensure_trusted_at(&file, &cwd);
728 let map = read_map(&file);
729 assert_eq!(map.len(), 1);
730 assert_eq!(
731 map.get(cwd.to_string_lossy().as_ref())
732 .and_then(|v| v.as_str()),
733 Some("TRUST_FOLDER")
734 );
735 }
736
737 #[test]
739 fn exact_key_present_is_noop() {
740 let dir = tempfile::tempdir().unwrap();
741 let file = dir.path().join("trustedFolders.json");
742 let cwd = dir.path().join("work");
743 let key = cwd.to_string_lossy().into_owned();
744 std::fs::write(&file, format!("{{\n {key:?}: \"TRUST_PARENT\"\n}}")).unwrap();
747 let before = std::fs::read_to_string(&file).unwrap();
748 ensure_trusted_at(&file, &cwd);
749 assert_eq!(std::fs::read_to_string(&file).unwrap(), before);
750 }
751
752 #[test]
754 fn under_trust_parent_ancestor_is_noop() {
755 let dir = tempfile::tempdir().unwrap();
756 let file = dir.path().join("trustedFolders.json");
757 let parent = dir.path().join("workspaces");
758 let cwd = parent.join("proj").join("wt");
759 let pkey = parent.to_string_lossy().into_owned();
760 std::fs::write(&file, format!("{{\n {pkey:?}: \"TRUST_PARENT\"\n}}")).unwrap();
761 let before = std::fs::read_to_string(&file).unwrap();
762 ensure_trusted_at(&file, &cwd);
763 assert_eq!(std::fs::read_to_string(&file).unwrap(), before);
764 }
765
766 #[test]
768 fn corrupt_file_left_untouched() {
769 let dir = tempfile::tempdir().unwrap();
770 let file = dir.path().join("trustedFolders.json");
771 let corrupt = "this is not json {{{";
772 std::fs::write(&file, corrupt).unwrap();
773 ensure_trusted_at(&file, &dir.path().join("work"));
774 assert_eq!(std::fs::read_to_string(&file).unwrap(), corrupt);
775 }
776
777 #[test]
779 fn existing_entries_preserved_on_insert() {
780 let dir = tempfile::tempdir().unwrap();
781 let file = dir.path().join("trustedFolders.json");
782 std::fs::write(&file, "{\n \"/some/other/dir\": \"TRUST_FOLDER\"\n}").unwrap();
783 let cwd = dir.path().join("work");
784 ensure_trusted_at(&file, &cwd);
785 let map = read_map(&file);
786 assert_eq!(map.len(), 2);
787 assert_eq!(
788 map.get("/some/other/dir").and_then(|v| v.as_str()),
789 Some("TRUST_FOLDER")
790 );
791 assert_eq!(
792 map.get(cwd.to_string_lossy().as_ref())
793 .and_then(|v| v.as_str()),
794 Some("TRUST_FOLDER")
795 );
796 }
797}