Skip to main content

fno_agents/
agy_ask.rs

1//! Client-side `agy -p` ask path (Phase C, agy harness).
2//!
3//! `agy` (Google's Antigravity CLI) is a one-shot `agy -p <prompt>` subprocess
4//! (like gemini/codex, NOT a daemon PTY pane on this path). The cleavage from
5//! gemini is the OUTPUT SHAPE: agy v1.0.x has **no `--output-format json`**, so
6//! stdout is the model's reply as PLAIN TEXT. There is no session id in the
7//! output, so this path is STATELESS — `dispatch_agy_once` runs one prompt and
8//! returns the reply; there is no registry row and no `ask`-by-id resume (agy's
9//! own `--continue`/`--conversation` resume is a daemon/interactive concern, not
10//! this headless one-shot).
11//!
12//! Ported gotchas from the battle-tested MIT wrapper
13//! `antigravity-for-claude-code/scripts/agy-delegate.sh`:
14//! - **stdin is `/dev/null`**: `agy -p` silently drops stdout when stdin is a
15//!   non-TTY waiting for input; detaching stdin avoids the empty-output hang.
16//! - **outer wall-clock guard**: without a console agy v1.0.x can hard-hang
17//!   *before* its own `--print-timeout` engages, so the shared
18//!   [`crate::subprocess_ask::AskWatchdog`] bounds the call (the Rust analogue
19//!   of the wrapper's outer `timeout`/`gtimeout`).
20//! - **structured failure classification**: agy emits no machine-readable error;
21//!   the wrapper scans STDERR for quota/auth/timeout and maps to distinct exit
22//!   codes. [`AgyAskError`] mirrors that map (2 failed / 3 empty / 10 quota /
23//!   11 auth / 12 timeout / 13 missing / 130 interrupted).
24//!
25//! The subprocess primitives (SIGINT forwarding, process-group kill, grace
26//! reap, watchdog, cwd resolution) are reused from `crate::subprocess_ask`,
27//! shared with codex/gemini.
28
29use 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
39/// Default one-shot timeout (matches the wrapper's outer-guard intent; the
40/// caller can override via the `timeout` param).
41const DEFAULT_ASK_TIMEOUT: Duration = Duration::from_secs(600);
42/// Inner `--print-timeout` handed to agy (the OUTER watchdog is larger).
43const AGY_PRINT_TIMEOUT: &str = "5m";
44/// Cap on stderr captured for classification (bounds memory on a runaway loop).
45const STDERR_CAP: usize = 256 * 1024;
46
47/// First 200 *characters* of `s` (forensic head for parse failures).
48fn raw_head(s: &str) -> String {
49    s.chars().take(200).collect()
50}
51
52// ===========================================================================
53// Pure-fn helpers (no I/O, fully unit-testable)
54// ===========================================================================
55
56/// Prepend `[from: <from_name>]\n\n` to `prompt` (mirror of the sibling asks).
57pub fn inject_from_name(prompt: &str, from_name: &str) -> String {
58    format!("[from: {}]\n\n{}", from_name, prompt)
59}
60
61/// Yolo posture flag. agy's full-auto / never-prompt is
62/// `--dangerously-skip-permissions`; the headless one-shot ALWAYS passes it (an
63/// autonomous worker must not wedge on agy's first approval prompt). Returned as
64/// a vec so the argv builder can splice it cleanly.
65fn agy_yolo_flags() -> Vec<String> {
66    vec!["--dangerously-skip-permissions".to_string()]
67}
68
69/// Build the one-shot argv: `agy --print-timeout <dur> --add-dir <cwd>
70/// --dangerously-skip-permissions [--model <m>] -p <full_prompt>`.
71///
72/// NOTE: in agy, `-p`/`--print` takes the prompt as its VALUE, so it must come
73/// LAST with the prompt attached (the wrapper's load-bearing ordering rule).
74/// `full_prompt` should already be built via [`inject_from_name`]. cwd is passed
75/// BOTH as `--add-dir` (agy's workspace) and via `Command::current_dir`.
76///
77/// A user `--add-dir` (x-b6e2) is ADDITIVE: it appends a second `--add-dir
78/// <dir>` after the internal cwd injection, never replacing it (Locked Decision
79/// 5). Empty/None leaves the argv byte-for-byte as before.
80pub 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    // -p LAST, prompt as its value.
105    argv.push("-p".to_string());
106    argv.push(full_prompt.to_string());
107    argv
108}
109
110/// Parse agy's PLAIN-TEXT stdout into the reply. agy has no JSON; the whole
111/// stdout (trimmed) IS the reply. Whitespace-only output is the wrapper's
112/// "empty" case (exit 3) — the model produced nothing usable.
113pub 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
123/// Classify a non-zero agy exit by scanning its STDERR (never stdout — the
124/// model's reply could contain trigger words). Patterns mirror
125/// `agy-delegate.sh`'s case block. Returns the most specific [`AgyAskError`];
126/// the generic [`AgyAskError::Invocation`] is the safe fallback.
127pub 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// ===========================================================================
151// Error enum + exit-code map (mirror of agy-delegate.sh's exit codes)
152// ===========================================================================
153
154/// Errors from the agy ask path. Exit codes mirror `agy-delegate.sh`:
155/// 2 failed / 3 empty / 10 quota / 11 auth / 12 timeout / 13 missing /
156/// 130 interrupted. (1 for a non-ENOENT spawn OSError, matching the siblings.)
157#[derive(Debug)]
158pub enum AgyAskError {
159    /// `agy` binary not found at spawn (ErrorKind::NotFound) — exit 13.
160    NotFound,
161    /// Clean exit but whitespace-only output (model declined) — exit 3.
162    Empty { raw_head: String },
163    /// agy quota / rate limit (classified from stderr) — exit 10.
164    Quota,
165    /// agy not authenticated (classified from stderr) — exit 11.
166    Auth,
167    /// Wall-clock / print timeout — exit 12.
168    Timeout { timeout_sec: f64 },
169    /// agy exited non-zero for an unclassified reason — exit 2.
170    Invocation { exit_code: i32 },
171    /// Non-ENOENT OSError at spawn — exit 1.
172    OsError { message: String },
173    /// Operator SIGINT (Ctrl-C) forwarded to the agy group — exit 130.
174    Interrupted,
175}
176
177impl AgyAskError {
178    /// Wrapper-compatible exit code for this error.
179    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
230// ===========================================================================
231// Folder-trust pre-grant
232// ===========================================================================
233
234/// Grant agy folder-trust for `cwd` by upserting it into
235/// `~/.gemini/trustedFolders.json` — the record agy reads. agy shares Gemini's
236/// `~/.gemini/` config root but, unlike gemini, exposes no `--skip-trust` flag,
237/// so the spawn cannot bypass the prompt with an argv flag the way every gemini
238/// spawn does. This is the agy analogue of gemini's unconditional `--skip-trust`.
239///
240/// Without it, an INTERACTIVE agy worker launched in a not-yet-trusted cwd blocks
241/// on agy's "Do you trust this folder?" modal, which eats the relay's priming
242/// steer (`relay_prime_failed`) and the worker is born unusable.
243/// `--dangerously-skip-permissions` (already on the agy spawn) auto-approves tool
244/// calls only; it does not touch folder trust.
245///
246/// Best-effort: any I/O or parse failure logs once and returns, leaving agy to
247/// prompt exactly as before (no regression, no panic). Idempotent: a no-op when
248/// the cwd is already trusted or sits under a `TRUST_PARENT` ancestor.
249pub fn ensure_agy_folder_trusted(cwd: &Path) {
250    let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
251        return; // HOME unset: best-effort, agy prompts as before.
252    };
253    ensure_trusted_at(&home.join(".gemini").join("trustedFolders.json"), cwd);
254}
255
256/// Inner, fully unit-testable core: upsert `cwd -> "TRUST_FOLDER"` into the
257/// trusted-folders map at `file`. Split out so tests can drive it against a temp
258/// dir without touching the real `~/.gemini` config.
259///
260/// Uses the cwd EXACTLY as agy will open it (absolute, NOT canonicalized): agy
261/// matches the literal cwd string it is launched in, so resolving symlinks (e.g.
262/// `/tmp` -> `/private/tmp` on macOS) would write a key agy never checks.
263/// Verified against a live agy `-i` probe.
264fn ensure_trusted_at(file: &Path, cwd: &Path) {
265    // Absolutize without canonicalizing (see fn doc). The daemon always forwards
266    // an absolute cwd; the relative branch is a defensive fallback.
267    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    // Read the existing map (absent -> empty). A parse failure or a non-object
277    // root is left UNTOUCHED — never clobber the user's real grants (shared with
278    // interactive gemini/agy).
279    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    // Coverage check: exact key present (any value), or an ancestor TRUST_PARENT.
299    // Either way agy already trusts this cwd — no write.
300    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    // Grant only the exact cwd (TRUST_FOLDER, never TRUST_PARENT): never over-trust
310    // siblings/children the worker has no business in.
311    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    // Atomic write: temp file alongside the target (same filesystem) + rename, so
323    // a reader never sees a half-written file. The temp name must be unique PER
324    // INVOCATION, not just per process: the daemon pre-trusts on concurrent tasks
325    // (two agy spawns racing on different cwds), and `std::process::id()` is
326    // identical across threads — a pid-only temp path would let one write clobber
327    // the other's temp before its rename (torn file / spurious I/O error). A
328    // process-wide atomic counter makes each writer's temp private, so every
329    // rename publishes a COMPLETE valid file. The remaining last-writer-wins on
330    // the final file is by design (a lost insert self-heals: the next spawn
331    // re-detects the absent cwd and re-inserts).
332    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/// Result of a successful agy one-shot invocation.
350#[derive(Debug, Clone)]
351pub struct AgyResult {
352    pub exit_code: i32,
353    pub last_msg: String,
354    pub duration_ms: u64,
355}
356
357// ===========================================================================
358// Subprocess driver
359// ===========================================================================
360
361/// Open the JSONL tee, tagging an error as an invocation failure.
362fn 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
368/// Drive one `agy -p` subprocess: stdin `/dev/null`, capture stdout (plain
369/// text) and stderr (for classification), bounded by the outer watchdog. Mirrors
370/// `run_gemini`'s reap/grace/interrupt ordering but parses plain text.
371fn 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    // QoS (x-c5cc): exec-wrap at background priority (identity when
384    // worker_qos=off).
385    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    // CRITICAL: detach stdin so agy -p never blocks on a non-TTY waiting for
389    // input (the silent-stdout-drop gotcha).
390    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    // Own process group so SIGTERM/SIGKILL/SIGINT reach agy's subshells.
399    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    // Forward operator Ctrl-C to agy's process group for this call's lifetime.
419    // RAII guard held for the whole function — do NOT discard to `_`.
420    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    // Capture stderr text (bounded) for failure classification, while also
428    // tee'ing it to the log. Kept OFF stdout so the plain-text reply stays pure.
429    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                    // Tee (best-effort, warn-once on write error).
443                    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                    // Capture for classification, capped.
454                    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    // Read the whole stdout blob (agy emits plain text). agy output is UTF-8 in
469    // practice, so try a zero-copy `from_utf8` first and only pay the lossy copy
470    // on the rare invalid-byte path (a stray byte can't hard-fail the read).
471    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    // Tee the stdout blob under the shared lock (after EOF).
482    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    // The stderr drain thread has been joined, so we are the sole owner — move
504    // the captured text out of the mutex instead of cloning up to 256 KB.
505    let stderr_text = stderr_capture
506        .lock()
507        .map(|mut s| std::mem::take(&mut *s))
508        .unwrap_or_default();
509
510    // Operator Ctrl-C wins over every other classification.
511    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    // Non-zero exit dominates emptiness (wrapper ordering): classify from stderr.
520    if exit_code != 0 {
521        return Err(classify_failure(&stderr_text, exit_code));
522    }
523    // Clean exit: parse the plain-text reply (empty -> exit 3).
524    let reply = parse_response(&stdout_text)?;
525    Ok(AgyResult {
526        exit_code,
527        last_msg: reply,
528        duration_ms,
529    })
530}
531
532/// Spawn `agy -p ...` in `cwd` and return the plain-text reply.
533pub 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// ===========================================================================
549// Dispatch (stateless one-shot)
550// ===========================================================================
551
552/// Stdout/stderr/exit triple returned to the client (mirror of the sibling
553/// `AskOutcome`).
554#[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
578/// Derive the stable log path for an agy agent (mirror of the siblings).
579fn 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/// Orchestrate one agy `spawn --once`: validate, run `agy -p`, return the reply.
587///
588/// STATELESS by design — agy emits no session id, so there is no registry row to
589/// create/tear-down and no `--continue` resume to wire from here. The `name` is
590/// a label for the log path + events only.
591#[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    // Authoritative registry read, fail-closed (codex P2): `maybe_run_spawn`'s
610    // collision check uses an ADVISORY `unwrap_or_default()`, so a corrupt /
611    // unreadable registry would be treated as empty and agy launched anyway. The
612    // codex/gemini one-shots surface exit 12 here before running the CLI; agy
613    // matches that, and re-checks the name collision under this read.
614    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    // spawn allows an empty initial message; default it to "hello" (Python parity).
642    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
677/// Client interceptor for the `ask` (resume-by-name) verb on an agy target.
678///
679/// agy is STATELESS here (plain text, no session id), so a stateful `ask` resume
680/// is not supported. Returns `None` for non-agy targets (fall through), or a
681/// clear error directing the caller to `spawn --provider agy --once`.
682pub 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    // Borrow the provider string (lives until the function returns) instead of
696    // cloning it (gemini review).
697    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; // not an agy target; fall through
701    }
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    // AC4-EDGE: absent file -> created with exactly {cwd: "TRUST_FOLDER"}.
722    #[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    // AC3-UI: exact key already present (any value) -> no rewrite.
738    #[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        // Pre-seed with a DIFFERENT value to prove no rewrite (would flip to
745        // TRUST_FOLDER if we touched it).
746        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    // AC3-UI: cwd under an ancestor TRUST_PARENT entry -> no write.
753    #[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    // AC2-ERR: corrupt / non-object file -> left untouched, function returns.
767    #[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    // Invariants: existing unrelated entries preserved after an insert.
778    #[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}