Skip to main content

agent_abstraction/
run.rs

1//! Spawning an agent and turning its output into events and an outcome.
2//!
3//! Two entry points over the same machinery:
4//! - [`run`] waits and hands back the finished [`Outcome`].
5//! - [`stream`] hands back a [`Run`] that yields [`Event`]s as they arrive, for
6//!   a UI that shows work in progress.
7//!
8//! Both read stdout and stderr concurrently. Draining only one would deadlock
9//! the moment the other filled its pipe buffer, which for a chatty agent is a
10//! matter of seconds.
11
12use std::collections::{HashMap, VecDeque};
13use std::process::Stdio;
14
15use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader};
16use tokio::process::{Child, Command};
17use tokio::sync::mpsc;
18
19use crate::agent::{Continue, EnvPolicy};
20use crate::error::{Error, Result};
21use crate::event::{Event, MAX_LINE, Parser, Terminal, append_capped};
22use crate::outcome::{Outcome, Stop};
23use crate::proc::{kill_group_by_pid, kill_process_group};
24use crate::request::Request;
25
26/// Read one line, giving up on a line that never ends.
27///
28/// `AsyncBufReadExt::lines` buffers until a newline arrives, so a stream that
29/// emits megabytes without one exhausts memory before any total cap applies.
30/// This reads a bounded amount and, past the limit, returns what it has and
31/// discards the remainder of that line. Returns `None` at end of input.
32async fn read_bounded_line<R>(reader: &mut R, buf: &mut String) -> std::io::Result<Option<bool>>
33where
34    R: tokio::io::AsyncBufRead + Unpin,
35{
36    buf.clear();
37    let mut bytes = Vec::new();
38    let mut truncated = false;
39    loop {
40        let mut byte = [0u8; 1];
41        match reader.read(&mut byte).await? {
42            // End of input: a trailing fragment still counts as a line.
43            0 => {
44                if bytes.is_empty() {
45                    return Ok(None);
46                }
47                break;
48            }
49            _ if byte[0] == b'\n' => break,
50            _ => {
51                if bytes.len() < MAX_LINE {
52                    bytes.push(byte[0]);
53                } else {
54                    // Keep draining to the newline so the pipe does not block,
55                    // but stop accumulating.
56                    truncated = true;
57                }
58            }
59        }
60    }
61    // Output is not guaranteed to be valid UTF-8, and one bad byte should not
62    // end a run.
63    buf.push_str(&String::from_utf8_lossy(&bytes));
64    Ok(Some(truncated))
65}
66
67/// Aborts a task when dropped.
68///
69/// The decision forwarder holds the child's stdin, so leaving it running past
70/// the run would keep a pipe open to a process that is gone.
71struct AbortOnDrop(tokio::task::JoinHandle<()>);
72
73impl Drop for AbortOnDrop {
74    fn drop(&mut self) {
75        self.0.abort();
76    }
77}
78
79/// How many decisions may queue on the way back to the agent.
80///
81/// Small on purpose: the agent asks one question at a time and waits, so a deep
82/// queue here would only mean answers piling up for questions nobody asked.
83const APPROVAL_BUFFER: usize = 8;
84
85/// How many events may queue before the producer waits for the consumer. Deep
86/// enough that a burst of tool events does not stall the agent, shallow enough
87/// that a consumer which stops reading does not grow without bound.
88const EVENT_BUFFER: usize = 256;
89
90/// A host action travelling back to an interactive agent.
91///
92/// Claude and Codex encode these differently, so the public handle preserves
93/// the intent and lets the selected transport serialize it at the boundary.
94#[derive(Debug)]
95enum Control {
96    Message {
97        body: String,
98        receipt: tokio::sync::oneshot::Sender<Result<()>>,
99    },
100    Approval {
101        id: String,
102        decision: crate::Decision,
103    },
104}
105
106/// A run in progress.
107///
108/// Yields events through [`Run::recv`] and settles into an [`Outcome`] through
109/// [`Run::finish`].
110///
111/// **Dropping a `Run` kills the agent.** That is the safe default for the hosts
112/// this crate targets: closing a window or cancelling a request should stop the
113/// work, not leave an agent running invisibly, spending quota and touching
114/// files with nobody watching. Call [`Run::detach`] when background execution is
115/// genuinely what you want.
116///
117/// On Unix, dropping **synchronously signals** the run's process group and then
118/// aborts the driver task. What it cannot do is *wait*: `Drop` cannot await, so
119/// it does not block until the child has exited or its readers have been
120/// joined. Use [`Run::cancel`] when you need to know the tree has actually gone
121/// before continuing, such as before touching the files it was working on. On
122/// Windows only the direct child is signalled.
123#[derive(Debug)]
124pub struct Run {
125    events: mpsc::Receiver<Event>,
126    /// A cloneable route back into the run. Kept separate from the event
127    /// receiver so a host can drain output while a delivery receipt is pending.
128    control: RunControl,
129    /// The typed command line, kept so both the plain and redacted views come
130    /// from the same source.
131    typed: Vec<crate::agent::Arg>,
132    /// The child's pid, so `Drop` can tear the group down itself rather than
133    /// depending on an aborted task being polled.
134    pid: Option<u32>,
135    /// Set by the driver once the child has been reaped, so `Drop` never
136    /// signals a pid the OS may since have handed to someone else.
137    reaped: std::sync::Arc<std::sync::atomic::AtomicBool>,
138    /// Dropping or firing this asks the driver to tear down in order. Held as
139    /// an `Option` so `detach` can discard it without signalling.
140    cancel: Option<tokio::sync::oneshot::Sender<()>>,
141    /// `None` only after [`Run::finish`], [`Run::cancel`] or [`Run::detach`]
142    /// has taken ownership, which is what stops `Drop` from aborting a run that
143    /// was already settled deliberately.
144    task: Option<tokio::task::JoinHandle<Result<Outcome>>>,
145    argv: Vec<String>,
146}
147
148/// A cloneable route for sending input to a live [`Run`].
149///
150/// Keep this beside the task that drains [`Run::recv`]. A delivery receipt may
151/// arrive after a burst of agent events, so awaiting [`Self::send`] on the same
152/// task that owns the mutable `Run` can apply backpressure to the event stream
153/// and prevent the receipt itself from being read. A separate control handle
154/// lets both directions make progress concurrently.
155#[derive(Clone, Debug)]
156pub struct RunControl {
157    agent: crate::Agent,
158    to_agent: Option<mpsc::Sender<Control>>,
159    bin: String,
160}
161
162impl RunControl {
163    /// Send another message while the agent is still working.
164    ///
165    /// The future resolves only after the transport accepts the message. It is
166    /// safe to await from a separate task while the owner continues draining
167    /// [`Run::recv`].
168    ///
169    /// # Errors
170    ///
171    /// Returns [`Error::Unsupported`] when the run is not interactive, or
172    /// [`Error::Cancelled`] when the run finishes before delivery is confirmed.
173    pub async fn send(&self, message: &str) -> Result<()> {
174        let Some(channel) = &self.to_agent else {
175            return Err(Error::Unsupported {
176                agent: self.agent,
177                what: "sending a follow-up on a run that is not interactive",
178            });
179        };
180        let cancelled = || Error::Cancelled {
181            bin: self.bin.clone(),
182        };
183        let (receipt, delivered) = tokio::sync::oneshot::channel();
184        channel
185            .send(Control::Message {
186                body: message.to_string(),
187                receipt,
188            })
189            .await
190            .map_err(|_| cancelled())?;
191        delivered.await.map_err(|_| cancelled())?
192    }
193
194    /// Answer an [`Event::ApprovalRequest`] without borrowing the event stream.
195    ///
196    /// # Errors
197    ///
198    /// Returns [`Error::Unsupported`] when the run does not accept approvals,
199    /// or [`Error::Cancelled`] when the run finishes before the response is sent.
200    pub async fn respond(&self, id: &str, decision: &crate::Decision) -> Result<()> {
201        let Some(channel) = &self.to_agent else {
202            return Err(Error::Unsupported {
203                agent: self.agent,
204                what: "answering an approval on a run that did not request them",
205            });
206        };
207        channel
208            .send(Control::Approval {
209                id: id.to_string(),
210                decision: decision.clone(),
211            })
212            .await
213            .map_err(|_| Error::Cancelled {
214                bin: self.bin.clone(),
215            })
216    }
217}
218
219impl Run {
220    /// The next event, or `None` once the agent has finished producing them.
221    pub async fn recv(&mut self) -> Option<Event> {
222        self.events.recv().await
223    }
224
225    /// Clone the route used for follow-up messages and approval decisions.
226    ///
227    /// Use this when input and output must progress concurrently. The handle
228    /// does not keep the process alive after the `Run` settles or is dropped.
229    #[must_use]
230    pub fn control(&self) -> RunControl {
231        self.control.clone()
232    }
233
234    /// Send another message while the agent is still working.
235    ///
236    /// The whole point of [`crate::Request::interactive`]: a user who types a
237    /// correction mid-turn should not have to wait for the turn to finish.
238    ///
239    /// The agent takes it at its **next step boundary**, not mid-token, so an
240    /// answer already being written finishes first and a long tool-using task
241    /// changes course at its next step. Verified against claude 2.1.212 and
242    /// codex-cli 0.146.0.
243    ///
244    /// # Ordering and delivery acknowledgement
245    ///
246    /// The caller already knows what it sent, so the intended pattern is to
247    /// append the message to the transcript immediately, below the user's
248    /// previous one, and carry on. This deliberately does not ask the agent to
249    /// echo the message back for sequencing. The future resolves only after
250    /// the transport accepts the message: Codex has acknowledged `turn/steer`,
251    /// or Claude's input was written and flushed successfully. Rendering stays
252    /// immediate while a settle-race becomes an error the host can recover.
253    ///
254    /// # Errors
255    /// [`Error::Unsupported`] on a run that did not open the channel with
256    /// [`crate::Request::interactive`]. [`Error::Cancelled`] once the channel
257    /// has closed, which happens when the turn settles or the run is torn down:
258    /// **a message sent after the turn ends is too late** and belongs in a new
259    /// run resuming the session, so this reports it rather than dropping it.
260    pub async fn send(&self, message: &str) -> Result<()> {
261        self.control.send(message).await
262    }
263
264    /// Answer an [`Event::ApprovalRequest`].
265    ///
266    /// The agent is blocked until this is called, so a consumer that receives an
267    /// approval request and never responds stalls the run until its timeout.
268    ///
269    /// The id must be the one from the request. The agent ignores an answer
270    /// carrying any other id and keeps waiting, so a mismatch presents as a
271    /// hang rather than an error; this passes the id straight through and does
272    /// not invent one.
273    ///
274    /// # Errors
275    /// [`Error::Unsupported`] on a run that did not ask for approvals, since
276    /// there is no channel to answer on. [`Error::Cancelled`] if the run has
277    /// already finished or been torn down, which is the same reason a decision
278    /// can no longer be delivered.
279    pub async fn respond(&self, id: &str, decision: &crate::Decision) -> Result<()> {
280        self.control.respond(id, decision).await
281    }
282
283    /// The exact command line that was spawned.
284    ///
285    /// **This contains the prompt and any session id.** Treat it as sensitive:
286    /// logging it verbatim puts user content into your logs. Use
287    /// [`Run::redacted_argv`] for diagnostics.
288    #[must_use]
289    pub fn argv(&self) -> &[String] {
290        &self.argv
291    }
292
293    /// The command line with every non-public value replaced by a placeholder.
294    ///
295    /// Prompts, system prompts, session ids and anything from
296    /// [`crate::Request::unchecked_args`] are removed; flag names are kept so
297    /// the command stays recognisable. Sensitivity is recorded where each
298    /// argument is built rather than inferred from the finished line, so a
299    /// bare positional prompt or an opaque raw argument is covered too.
300    #[must_use]
301    pub fn redacted_argv(&self) -> Vec<String> {
302        redact(&self.typed)
303    }
304
305    /// Wait for the run to finish.
306    ///
307    /// Drains any events still queued, so a caller that only wants the result
308    /// can call this without having consumed the stream.
309    ///
310    /// # Errors
311    /// Whatever the run failed with. See [`Error`].
312    pub async fn finish(mut self) -> Result<Outcome> {
313        // The driver owns teardown from here; `Drop` must not also fire.
314        self.pid = None;
315        while self.events.recv().await.is_some() {}
316        // Taking the handle disarms the `Drop` guard: this run is settling
317        // normally, not being abandoned.
318        let Some(task) = self.task.take() else {
319            unreachable!("the handle is only taken by a consuming method")
320        };
321        match task.await {
322            Ok(result) => result,
323            // The driver task panicked or was cancelled. The process itself
324            // started fine, so this is not a spawn failure and must not claim
325            // to be one.
326            Err(join) => Err(Error::Interrupted {
327                bin: self.argv.first().cloned().unwrap_or_default(),
328                detail: if join.is_panic() {
329                    "the driver task panicked".into()
330                } else {
331                    "the driver task was cancelled".into()
332                },
333            }),
334        }
335    }
336
337    /// Stop the run and wait until the agent is actually gone.
338    ///
339    /// Cooperative rather than an abort: the driver is asked to stop, signals
340    /// the process group, reaps the child and joins its readers, and only then
341    /// does this return. So when it returns the tree really has exited, which
342    /// matters if the next thing you do touches the files it was working on.
343    ///
344    /// Returns the partial [`Outcome`] if the run happened to finish first,
345    /// otherwise [`Error::Cancelled`].
346    ///
347    /// # Errors
348    /// [`Error::Cancelled`] in the normal case, or whatever the run failed with
349    /// if it failed before the request arrived.
350    pub async fn cancel(mut self) -> Result<Outcome> {
351        // The driver tears down cooperatively and this awaits it, so `Drop`
352        // must not race that with a kill of its own.
353        self.pid = None;
354        // Dropping the sender is itself the signal, so this cannot fail in a
355        // way that leaves the driver waiting.
356        drop(self.cancel.take());
357        let Some(task) = self.task.take() else {
358            unreachable!("the handle is only taken by a consuming method")
359        };
360        match task.await {
361            Ok(result) => result,
362            Err(join) => Err(Error::Interrupted {
363                bin: self.argv.first().cloned().unwrap_or_default(),
364                detail: if join.is_panic() {
365                    "the driver task panicked".into()
366                } else {
367                    "the driver task was cancelled".into()
368                },
369            }),
370        }
371    }
372
373    /// Let the run continue after this handle goes away.
374    ///
375    /// The opposite of the default. Nothing can observe or stop the agent
376    /// afterwards, so reach for this only when an unsupervised background run
377    /// is genuinely intended.
378    pub fn detach(mut self) {
379        // Disarm `Drop` before it runs, or detaching would immediately kill the
380        // run it exists to keep alive.
381        self.pid = None;
382        // Leak the cancel signal rather than dropping it: a dropped sender is
383        // read by the driver as "stop", which is the opposite of detaching.
384        if let Some(cancel) = self.cancel.take() {
385            std::mem::forget(cancel);
386        }
387        // Dropping the handle without aborting is what detaches a tokio task.
388        drop(self.task.take());
389    }
390}
391
392impl Drop for Run {
393    fn drop(&mut self) {
394        // Abandoned rather than finished, cancelled or detached.
395        //
396        // Kill the group here, directly. Signalling the driver and aborting it
397        // is not enough on its own: that leaves teardown waiting on the runtime
398        // to poll the aborted task so its guard runs, and a dropped `Run` was
399        // observed leaving grandchildren alive and sleeping on Linux while the
400        // same teardown worked from `cancel`. `Drop` cannot await, so it does
401        // the one thing it can do synchronously.
402        if let Some(pid) = self.pid
403            && !self.reaped.load(std::sync::atomic::Ordering::SeqCst)
404        {
405            kill_group_by_pid(pid);
406        }
407        drop(self.cancel.take());
408        if let Some(task) = self.task.take() {
409            task.abort();
410        }
411    }
412}
413
414/// Placeholder substituted for a sensitive argv value.
415const REDACTED: &str = "<redacted>";
416
417/// Render a typed command line for logging, keeping flag names and replacing
418/// every value that is not `Public`.
419///
420/// Derived from the sensitivity recorded where each argument was built, so it
421/// cannot miss a case the way matching on flag names and positions can.
422fn redact(argv: &[crate::agent::Arg]) -> Vec<String> {
423    use crate::agent::Sensitivity;
424
425    argv.iter()
426        .map(|arg| match arg.sensitivity {
427            Sensitivity::Public => arg.value.clone(),
428            _ => REDACTED.to_string(),
429        })
430        .collect()
431}
432
433/// Run `request` to completion, discarding the intermediate events.
434///
435/// # Errors
436/// See [`Error`]; notably [`Error::NotInstalled`], [`Error::Timeout`],
437/// [`Error::RateLimited`] and [`Error::Failed`].
438///
439/// [`Error::Unsupported`] for a request that asked for approvals: this entry
440/// point discards events, so an approval request would reach nobody and the run
441/// would sit blocked until its timeout. Use [`stream`] instead.
442pub async fn run(request: &Request) -> Result<Outcome> {
443    if request.plan().approvals {
444        return Err(Error::Unsupported {
445            agent: request.agent,
446            what: "approvals on a run whose events are discarded; use `stream`",
447        });
448    }
449    stream(request)?.finish().await
450}
451
452/// Start `request`, returning a handle that streams its events.
453///
454/// Returns as soon as the child is spawned; the work proceeds on a task.
455///
456/// # Errors
457/// [`Error::NotInstalled`] if the binary is missing, [`Error::Unsupported`] if
458/// the agent cannot honour the request, or [`Error::Spawn`] on an OS failure.
459#[allow(
460    clippy::too_many_lines,
461    reason = "one spawn boundary keeps command posture, pipes, process group, and driver selection together"
462)]
463pub fn stream(request: &Request) -> Result<Run> {
464    // `tokio::spawn` panics outside a runtime. A fallible signature must not
465    // hide that, so the context is checked and reported as an ordinary error.
466    let runtime = tokio::runtime::Handle::try_current().map_err(|_| Error::NoRuntime)?;
467
468    let initial_plan = request.plan();
469    let codex_app_server =
470        request.agent == crate::Agent::Codex && (initial_plan.duplex || initial_plan.approvals);
471
472    // Written before the argv is built, because the argv has to name it. The
473    // app-server protocol accepts the schema inline instead.
474    let schema_file = match (&request.schema, request.agent.caps().schema) {
475        (Some(_), _) if codex_app_server => None,
476        (Some(schema), crate::agent::SchemaSupport::File) => {
477            Some(SchemaFile::write(schema).map_err(|source| Error::Spawn {
478                bin: request.agent.bin().to_string(),
479                source,
480            })?)
481        }
482        _ => None,
483    };
484    let mut request = request.clone();
485    if let Some(file) = &schema_file {
486        request.schema_file = Some(file.0.display().to_string());
487    }
488    let request = &request;
489
490    let plan = request.plan();
491    let typed = request.typed_argv()?;
492    let argv: Vec<String> = typed.iter().map(|a| a.value.clone()).collect();
493
494    let mut command = Command::new(&argv[0]);
495    command
496        .args(&argv[1..])
497        .stdin(if plan.stdin_prompt || plan.duplex || plan.approvals {
498            // An interactive run needs stdin for the whole turn, not just to
499            // deliver a prompt: it is the channel follow-up messages and
500            // approval decisions travel back on.
501            Stdio::piped()
502        } else {
503            // Close stdin so an agent that would otherwise wait on it exits
504            // instead of hanging forever with nothing to read.
505            Stdio::null()
506        })
507        .stdout(Stdio::piped())
508        .stderr(Stdio::piped())
509        // Without this a killed run can leave the child alive holding the pipes.
510        .kill_on_drop(true);
511    if let Some(cwd) = &request.cwd {
512        command.current_dir(cwd);
513    }
514    // Narrow the environment first, then apply explicit variables, so an
515    // explicit `env()` always wins over the policy.
516    match &request.env_policy {
517        EnvPolicy::Inherit => {}
518        EnvPolicy::Minimal => {
519            command.env_clear();
520            inherit_named(&mut command, &request.agent.essential_env());
521        }
522        EnvPolicy::Only(names) => {
523            command.env_clear();
524            inherit_named(&mut command, names);
525        }
526    }
527    for (key, value) in &request.env {
528        command.env(key, value);
529    }
530
531    // Applied last so the dedicated `thinking(false)` switch is authoritative
532    // over the general env map. Only Claude has a lever, delivered as
533    // `MAX_THINKING_TOKENS=0`. See `Agent::thinking_env`.
534    if let Some((key, value)) = request.agent.thinking_env(request.thinking) {
535        command.env(key, value);
536    }
537
538    // Put the agent in its own process group so the whole tree can be signalled
539    // together. Killing only the CLI leaves the commands *it* spawned running:
540    // a build, a test run, a server, still holding files and credentials after
541    // the run is supposedly over.
542    // 0 means "make this child its own group leader". `tokio::process::Command`
543    // exposes this directly on unix.
544    #[cfg(unix)]
545    command.process_group(0);
546
547    // Reserve an assigned session id before the child exists. Doing it inside
548    // the driver leaves a window where a spawn that half-succeeds loses the
549    // binding, and this is the id the caller may already be showing in a UI.
550    if let Some(token) = preassigned_token(request) {
551        persist_session(request, &token)?;
552    }
553
554    let child = command.spawn().map_err(|source| {
555        // A missing binary is the common case and deserves an actionable error
556        // with an install hint. Reading it off the spawn avoids resolving PATH
557        // twice, and with it the window where the resolved path is replaced
558        // between the check and the exec.
559        if source.kind() == std::io::ErrorKind::NotFound {
560            Error::NotInstalled {
561                agent: request.agent,
562                bin: plan.bin.clone(),
563                hint: request.agent.install_hint(),
564            }
565        } else {
566            Error::Spawn {
567                bin: plan.bin.clone(),
568                source,
569            }
570        }
571    })?;
572
573    let request_agent = request.agent;
574    let pid = child.id();
575    let (tx, rx) = mpsc::channel(EVENT_BUFFER);
576    // Only created for an approvals run, so `respond` can tell "no channel" from
577    // "channel closed" and refuse the first rather than hanging on it.
578    let (decisions_tx, decisions_rx) = if plan.duplex || plan.approvals {
579        let (tx, rx) = mpsc::channel::<Control>(APPROVAL_BUFFER);
580        (Some(tx), Some(rx))
581    } else {
582        (None, None)
583    };
584    let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel();
585    let reaped = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
586    let reaped_for_task = std::sync::Arc::clone(&reaped);
587    let request = request.clone();
588    let task = runtime.spawn(async move {
589        // Moved in so the file outlives the run and is removed with it.
590        let _schema_file = schema_file;
591        if codex_app_server {
592            drive_codex_app_server(child, request, tx, cancel_rx, reaped_for_task, decisions_rx)
593                .await
594        } else {
595            drive(child, request, tx, cancel_rx, reaped_for_task, decisions_rx).await
596        }
597    });
598    Ok(Run {
599        events: rx,
600        control: RunControl {
601            agent: request_agent,
602            to_agent: decisions_tx,
603            bin: argv.first().cloned().unwrap_or_default(),
604        },
605        typed,
606        pid,
607        reaped,
608        cancel: Some(cancel_tx),
609        task: Some(task),
610        argv,
611    })
612}
613
614/// Copy the named variables from this process into `command`, skipping any that
615/// are unset so nothing is invented.
616fn inherit_named<S: AsRef<str>>(command: &mut Command, names: &[S]) {
617    for name in names {
618        if let Some(value) = std::env::var_os(name.as_ref()) {
619            command.env(name.as_ref(), value);
620        }
621    }
622}
623
624/// A schema file written for one run, removed when the run ends.
625///
626/// Codex reads its schema from disk, so the file has to outlive the spawn and
627/// not outlive the process. Tying it to a guard means every exit path removes
628/// it, including a cancel or a timeout, without each one remembering.
629struct SchemaFile(std::path::PathBuf);
630
631impl SchemaFile {
632    /// Write `schema` somewhere the agent can read it.
633    fn write(schema: &str) -> std::io::Result<SchemaFile> {
634        use std::io::Write as _;
635        use std::sync::atomic::{AtomicU64, Ordering};
636        static COUNTER: AtomicU64 = AtomicU64::new(0);
637
638        let path = std::env::temp_dir().join(format!(
639            "agent-abstraction-schema-{}-{}.json",
640            std::process::id(),
641            COUNTER.fetch_add(1, Ordering::Relaxed)
642        ));
643        let mut options = std::fs::OpenOptions::new();
644        options.write(true).create_new(true);
645        // A schema can encode what a caller is looking for, so it is no more
646        // public than the prompt.
647        #[cfg(unix)]
648        {
649            use std::os::unix::fs::OpenOptionsExt as _;
650            options.mode(0o600);
651        }
652        options.open(&path)?.write_all(schema.as_bytes())?;
653        Ok(SchemaFile(path))
654    }
655}
656
657impl Drop for SchemaFile {
658    fn drop(&mut self) {
659        let _ = std::fs::remove_file(&self.0);
660    }
661}
662
663/// Owns the child and tears down its whole process group when dropped.
664///
665/// `kill_on_drop` alone is not enough: it kills the CLI, leaving the commands
666/// *it* spawned running. Since aborting the driver task drops this guard, the
667/// same teardown covers cancellation, a dropped [`Run`] and a timeout, without
668/// each path having to remember to do it.
669struct ChildGuard {
670    child: Child,
671    /// Cleared once the child has been reaped, so a pid the OS may since have
672    /// recycled is never signalled.
673    armed: bool,
674}
675
676impl Drop for ChildGuard {
677    fn drop(&mut self) {
678        if self.armed {
679            kill_process_group(&self.child);
680        }
681    }
682}
683
684/// Feed the child, read both its pipes, and assemble the outcome.
685#[allow(
686    clippy::too_many_lines,
687    reason = "one linear lifecycle: feed, read, wait, classify. Splitting it \
688              would thread the child, parser, buffers and cancellation state \
689              through helpers and obscure the ordering that matters, such as \
690              killing the group before reaping."
691)]
692async fn drive(
693    child: Child,
694    request: Request,
695    events: mpsc::Sender<Event>,
696    cancel: tokio::sync::oneshot::Receiver<()>,
697    reaped: std::sync::Arc<std::sync::atomic::AtomicBool>,
698    decisions: Option<mpsc::Receiver<Control>>,
699) -> Result<Outcome> {
700    // From here on the child is owned by a guard, so every exit path from this
701    // task, including an abort, takes the process group with it.
702    let mut child = ChildGuard { child, armed: true };
703    let plan = request.plan();
704    let bin = plan.bin.clone();
705
706    // An approvals run owns stdin for the whole turn: the handshake and the
707    // prompt go out first, then it stays open carrying decisions until the run
708    // ends. Closing it after the prompt, as the plain piped path does, would
709    // take the answer channel with it.
710    let mut decision_task = None;
711    let mut close_stdin = None;
712    if plan.duplex || plan.approvals {
713        let Some(mut stdin) = child.child.stdin.take() else {
714            return Err(Error::Spawn {
715                bin: bin.clone(),
716                source: std::io::Error::other("stdin was not piped for an interactive run"),
717            });
718        };
719        let opening = format!(
720            "{}{}",
721            crate::approval::handshake(),
722            crate::approval::user_message(&request.agent.effective_prompt(&plan)),
723        );
724        stdin
725            .write_all(opening.as_bytes())
726            .await
727            .map_err(|source| Error::Spawn {
728                bin: bin.clone(),
729                source,
730            })?;
731        let _ = stdin.flush().await;
732        // Forwarding runs on its own task so a decision can be written while
733        // stdout is being read. It ends on whichever comes first: the channel
734        // closing, or the turn settling.
735        let (close_tx, mut close_rx) = tokio::sync::oneshot::channel::<()>();
736        close_stdin = Some(close_tx);
737        let decision_bin = bin.clone();
738        decision_task = decisions.map(|mut rx| {
739            tokio::spawn(async move {
740                loop {
741                    tokio::select! {
742                        biased;
743                        // Once the terminal record has arrived there is no
744                        // turn left to receive another message. Prioritizing
745                        // closure makes a simultaneous late send fail instead
746                        // of being reported as delivered to a finished turn.
747                        _ = &mut close_rx => break,
748                        reply = rx.recv() => {
749                            let Some(control) = reply else { break };
750                            match control {
751                                Control::Message { body, receipt } => {
752                                    let wire = crate::approval::user_message(&body);
753                                    let result = async {
754                                        stdin.write_all(wire.as_bytes()).await?;
755                                        stdin.flush().await
756                                    }
757                                    .await
758                                    .map_err(|source| Error::Spawn {
759                                        bin: decision_bin.clone(),
760                                        source,
761                                    });
762                                    let failed = result.is_err();
763                                    let _ = receipt.send(result);
764                                    if failed {
765                                        break;
766                                    }
767                                }
768                                Control::Approval { id, decision } => {
769                                    let wire = decision.wire(&id);
770                                    if stdin.write_all(wire.as_bytes()).await.is_err() {
771                                        break;
772                                    }
773                                    let _ = stdin.flush().await;
774                                }
775                            }
776                        }
777                    }
778                }
779                drop(stdin);
780            })
781        });
782    }
783
784    // Deliver a piped prompt and close the pipe, or the agent waits on EOF.
785    if plan.stdin_prompt {
786        if let Some(mut stdin) = child.child.stdin.take() {
787            let prompt = request.agent.effective_prompt(&plan);
788            stdin
789                .write_all(prompt.as_bytes())
790                .await
791                .map_err(|source| Error::Spawn {
792                    bin: bin.clone(),
793                    source,
794                })?;
795            drop(stdin);
796        }
797    }
798
799    // Drain stderr on its own task: a full stderr pipe blocks the child even
800    // while stdout still has room.
801    // Aborted on every exit path from here, so a forwarder never survives the
802    // run it belongs to.
803    let _decision_guard = decision_task.map(AbortOnDrop);
804
805    let stderr = child.child.stderr.take();
806    let stderr_task = tokio::spawn(async move {
807        let mut buf = String::new();
808        if let Some(handle) = stderr {
809            let mut reader = BufReader::new(handle);
810            let mut line = String::new();
811            // Keep draining after the cap is hit: an undrained pipe blocks the
812            // child even though we no longer want the bytes.
813            while let Ok(Some(_)) = read_bounded_line(&mut reader, &mut line).await {
814                append_capped(&mut buf, &line);
815            }
816        }
817        buf
818    });
819
820    let stdout = child.child.stdout.take();
821    let mut parser = Parser::new(request.agent, plan.format);
822    // Raw stdout is retained only as a fallback answer for a run that exited
823    // cleanly without producing a structured one, and as evidence when
824    // classifying a failure. It is capped for the same reason as everything
825    // else here: an agent can stream for hours.
826    let mut raw = String::new();
827    // Tracks the first `Started`, so the binding is written once, and carries a
828    // store failure back out instead of discarding it.
829    let mut bound = false;
830    let mut persist_result: Result<()> = Ok(());
831
832    let read_stdout = async {
833        if let Some(handle) = stdout {
834            let mut reader = BufReader::new(handle);
835            let mut line = String::new();
836            while read_bounded_line(&mut reader, &mut line).await?.is_some() {
837                append_capped(&mut raw, &line);
838                let parsed = parser.push(&line);
839                // Close stdin as soon as the turn settles. Under stream-json
840                // input claude waits for another message otherwise, so the run
841                // would only end at its timeout even though the answer already
842                // arrived.
843                if parser.saw_terminal()
844                    && let Some(close) = close_stdin.take()
845                {
846                    let _ = close.send(());
847                }
848                for event in parsed {
849                    // Bind a printed id the moment it appears rather than at the
850                    // end. Codex announces its thread before answering, so a
851                    // turn killed mid-answer stays resumable.
852                    if let Event::Started { session, .. } = &event
853                        && !bound
854                    {
855                        bound = true;
856                        persist_result = persist_session(&request, session);
857                    }
858                    // A receiver that went away is not a failure: the run should
859                    // still finish and produce its outcome.
860                    if events.send(event).await.is_err() {
861                        break;
862                    }
863                }
864            }
865        }
866        Ok::<_, std::io::Error>(())
867    };
868
869    // Race three outcomes: the run finishing, the deadline, and a cancellation
870    // request. Reading and waiting are one future so a child that produces
871    // output forever is still bounded by the timeout.
872    let work = async {
873        read_stdout.await?;
874        child.child.wait().await
875    };
876    // A timeout is optional; `pending()` makes the un-timed case the same shape
877    // rather than duplicating the whole select.
878    let deadline = async {
879        match request.timeout {
880            Some(limit) => tokio::time::sleep(limit).await,
881            None => std::future::pending().await,
882        }
883    };
884
885    let status = tokio::select! {
886        // Biased so a finished run is reported as finished even if a deadline
887        // or cancellation lands in the same tick.
888        biased;
889        result = work => result,
890        () = deadline => {
891            // Order matters: signal the group *before* reaping. Reaping clears
892            // the child's pid, and the group kill needs that pid to target the
893            // group, so the other order silently leaves grandchildren running.
894            let partial = shut_down(&mut child, stderr_task).await;
895            reaped.store(true, std::sync::atomic::Ordering::SeqCst);
896            return Err(Error::Timeout {
897                bin,
898                timeout: request.timeout.unwrap_or_default(),
899                partial: parser.finish().text,
900            })
901            .inspect_err(|_| drop(partial));
902        }
903        _ = cancel => {
904            // Cooperative teardown: the caller is waiting on this, so the tree
905            // is signalled, reaped and joined before returning.
906            shut_down(&mut child, stderr_task).await;
907            reaped.store(true, std::sync::atomic::Ordering::SeqCst);
908            return Err(Error::Cancelled { bin });
909        }
910    }
911    .map_err(|source| Error::Spawn {
912        bin: bin.clone(),
913        source,
914    })?;
915
916    // The child has been reaped, so its pid must not be signalled again, by the
917    // guard here or by `Run::drop` racing this.
918    child.armed = false;
919    reaped.store(true, std::sync::atomic::Ordering::SeqCst);
920
921    drop(events);
922    let stderr = stderr_task.await.unwrap_or_default();
923    let saw_structured = parser.saw_structured_record();
924    let saw_terminal = parser.saw_terminal_record();
925    let terminal = parser.finish();
926    let exit_code = status.code().unwrap_or(-1);
927
928    // Under a structured format, silently handing back raw stdout would turn a
929    // protocol failure into a plausible-looking answer. A run that recognized
930    // nothing, or never reached its terminal record, did not produce a result
931    // this crate can vouch for, so it is reported rather than papered over.
932    let structured = plan.format != crate::Format::Text;
933    if structured && exit_code == 0 {
934        if !saw_structured {
935            return Err(Error::Parse {
936                agent: request.agent,
937                detail: format!(
938                    "no recognizable {} records in {} lines of output;                      the CLI's output shape has probably changed",
939                    request.agent,
940                    raw.lines().count()
941                ),
942            });
943        }
944        if !saw_terminal {
945            return Err(Error::Parse {
946                agent: request.agent,
947                detail: "the stream ended without its terminal record, so the turn                          did not complete"
948                    .into(),
949            });
950        }
951    }
952
953    // Plain text has no structure to validate: the stream is the answer.
954    let mut terminal = terminal;
955    if terminal.text.is_empty() && !structured {
956        terminal.text = raw.trim().to_string();
957    }
958
959    // A provider refusal is not always an exit code. Claude can report a
960    // blocking `rate_limit_event` and still exit 0, and the crate promises that
961    // quota refusals surface as `Error::RateLimited`, so the terminal state is
962    // checked regardless of how the process exited.
963    let quota_blocked = terminal
964        .rate_limit
965        .as_ref()
966        .is_some_and(crate::outcome::RateLimit::is_blocking);
967    // An unauthenticated Claude run exits 0 and reports the problem in its
968    // result text, so checking only the exit code would hand back a successful
969    // Outcome whose answer is "Please run /login".
970    //
971    // Read from stderr and the agent's own prose rather than the raw stream, for
972    // the reason `classify` does the same with quota wording: a phrase hunted
973    // through structured output matches ids and field names, not statements.
974    //
975    // The answer is read at its opening only, by the same rule and the same
976    // helper `classify_run` uses. This gate used to read `terminal.text` whole
977    // while the classifier it guards read three lines, so the two could
978    // disagree: a healthy answer that merely discussed logging in opened the
979    // error path, no classifier would name it, and the turn fell out the far
980    // end as `Error::Failed` with exit code 0 and nothing to report. One rule,
981    // one helper, so that disagreement cannot exist.
982    let unauthenticated =
983        answer_reports_no_credentials(&terminal.text) || looks_unauthenticated(&stderr);
984    // The agent saying its turn failed is as much a failure as a non-zero exit,
985    // and Claude reports an unknown model exactly this way: exit 0, `is_error`
986    // true, and the explanation where the answer would be.
987    let turn_failed = terminal.stop == Stop::Error;
988    if exit_code != 0 || quota_blocked || unauthenticated || turn_failed {
989        let error = classify_run(request.agent, &bin, exit_code, &stderr, &raw, &terminal);
990        /*
991         * The backstop, and the reason a false positive can no longer cost an
992         * answer.
993         *
994         * Everything above is a heuristic reading of text the agent wrote, and
995         * a heuristic will be wrong eventually: these phrases are ordinary
996         * English, and an agent asked about rate limits or logging in answers
997         * in exactly the vocabulary that describes being rate limited or
998         * logged out. What must never follow from being wrong is discarding a
999         * finished answer.
1000         *
1001         * So a run whose process exited cleanly, whose terminal record says
1002         * the turn completed, and which carries no parsed quota block is only
1003         * ever failed by a classifier that can *name* the failure. A generic
1004         * `Failed` on that run is the classifiers disagreeing with the gate,
1005         * not evidence, and the answer stands.
1006         */
1007        let exited_clean = exit_code == 0 && !quota_blocked && !turn_failed;
1008        if !exited_clean || names_a_failure(&error) {
1009            return Err(error);
1010        }
1011    }
1012
1013    // A fork lands on a *new* id the agent only reveals at the end, so the name
1014    // has to be repointed once the run settles. Everything else was bound above.
1015    persist_result?;
1016    // Resolved before the terminal is consumed by the Outcome below.
1017    let structured = terminal.structured.clone().or_else(|| {
1018        request
1019            .schema
1020            .as_ref()
1021            .and_then(|_| serde_json::from_str(&terminal.text).ok())
1022    });
1023    if let Some(token) = &terminal.session
1024        && !bound
1025    {
1026        persist_session(&request, token)?;
1027    }
1028    Ok(Outcome {
1029        agent: request.agent,
1030        session: terminal.session,
1031        text: terminal.text,
1032        usage: terminal.usage,
1033        stop: terminal.stop,
1034        rate_limit: terminal.rate_limit,
1035        exit_code,
1036        stderr,
1037        unparsed: terminal.unparsed,
1038        first_unparsed: terminal.first_unparsed,
1039        // Claude reports the conforming value separately; Codex returns it as
1040        // the answer text, so that is parsed only when a schema was asked for.
1041        // Prose is never reinterpreted as data.
1042        structured,
1043    })
1044}
1045
1046/// Drive one interactive Codex turn over app-server's JSON-RPC transport.
1047///
1048/// Unlike `codex exec`, app-server remains alive after a turn completes. This
1049/// driver therefore treats `turn/completed` as the terminal record, closes the
1050/// protocol pipe, and reaps the service process itself.
1051#[allow(
1052    clippy::too_many_lines,
1053    reason = "one select loop owns the protocol, control channel, deadline, and child lifecycle"
1054)]
1055async fn drive_codex_app_server(
1056    child: Child,
1057    request: Request,
1058    events: mpsc::Sender<Event>,
1059    cancel: tokio::sync::oneshot::Receiver<()>,
1060    reaped: std::sync::Arc<std::sync::atomic::AtomicBool>,
1061    controls: Option<mpsc::Receiver<Control>>,
1062) -> Result<Outcome> {
1063    let mut child = ChildGuard { child, armed: true };
1064    let plan = request.plan();
1065    let bin = plan.bin.clone();
1066    let Some(mut stdin) = child.child.stdin.take() else {
1067        return Err(Error::Spawn {
1068            bin,
1069            source: std::io::Error::other("stdin was not piped for Codex app-server"),
1070        });
1071    };
1072    let Some(stdout) = child.child.stdout.take() else {
1073        return Err(Error::Spawn {
1074            bin,
1075            source: std::io::Error::other("stdout was not piped for Codex app-server"),
1076        });
1077    };
1078    let Some(mut controls) = controls else {
1079        return Err(Error::Spawn {
1080            bin,
1081            source: std::io::Error::other("Codex app-server has no host control channel"),
1082        });
1083    };
1084
1085    let stderr = child.child.stderr.take();
1086    let stderr_task = tokio::spawn(async move {
1087        let mut buf = String::new();
1088        if let Some(handle) = stderr {
1089            let mut reader = BufReader::new(handle);
1090            let mut line = String::new();
1091            while let Ok(Some(_)) = read_bounded_line(&mut reader, &mut line).await {
1092                append_capped(&mut buf, &line);
1093            }
1094        }
1095        buf
1096    });
1097
1098    let mut protocol = crate::codex_app_server::Protocol::new(request.clone());
1099    for opening in protocol.opening() {
1100        stdin
1101            .write_all(opening.as_bytes())
1102            .await
1103            .map_err(|source| Error::Spawn {
1104                bin: bin.clone(),
1105                source,
1106            })?;
1107    }
1108    stdin.flush().await.map_err(|source| Error::Spawn {
1109        bin: bin.clone(),
1110        source,
1111    })?;
1112
1113    let mut reader = BufReader::new(stdout);
1114    let mut line = String::new();
1115    let mut raw = String::new();
1116    let mut pending = VecDeque::new();
1117    let mut steer_receipts = HashMap::new();
1118    let mut bound = false;
1119    let mut persist_result: Result<()> = Ok(());
1120    let deadline = async {
1121        match request.timeout {
1122            Some(limit) => tokio::time::sleep(limit).await,
1123            None => std::future::pending().await,
1124        }
1125    };
1126    tokio::pin!(deadline);
1127    tokio::pin!(cancel);
1128
1129    while !protocol.finished {
1130        tokio::select! {
1131            biased;
1132            // User steering and approval answers outrank the agent's output.
1133            // app-server can keep stdout continuously ready with reasoning and
1134            // text deltas; reading it first in a biased select could starve a
1135            // correction precisely while Codex was busiest.
1136            control = controls.recv() => {
1137                let Some(control) = control else {
1138                    continue;
1139                };
1140                pending.push_back(control);
1141                flush_codex_controls(
1142                    &mut protocol,
1143                    &mut pending,
1144                    &mut steer_receipts,
1145                    &mut stdin,
1146                    &bin,
1147                ).await?;
1148                stdin.flush().await.map_err(|source| Error::Spawn {
1149                    bin: bin.clone(), source
1150                })?;
1151            }
1152            record = read_bounded_line(&mut reader, &mut line) => {
1153                if record.map_err(|source| Error::Spawn { bin: bin.clone(), source })?.is_some() {
1154                    append_capped(&mut raw, &line);
1155                    if let Ok(value) = serde_json::from_str::<serde_json::Value>(&line) {
1156                        let step = protocol.push(&value);
1157                        settle_codex_steers(&mut steer_receipts, step.steer_responses, &bin);
1158                        for event in step.events {
1159                            if let Event::Started { session, .. } = &event
1160                                && !bound
1161                            {
1162                                bound = true;
1163                                persist_result = persist_session(&request, session);
1164                            }
1165                            let _ = events.send(event).await;
1166                        }
1167                        for write in step.writes {
1168                            stdin.write_all(write.as_bytes()).await.map_err(|source| {
1169                                Error::Spawn { bin: bin.clone(), source }
1170                            })?;
1171                        }
1172                        flush_codex_controls(
1173                            &mut protocol,
1174                            &mut pending,
1175                            &mut steer_receipts,
1176                            &mut stdin,
1177                            &bin,
1178                        ).await?;
1179                        stdin.flush().await.map_err(|source| Error::Spawn {
1180                            bin: bin.clone(), source
1181                        })?;
1182                    } else {
1183                        protocol.terminal.unparsed += 1;
1184                        if protocol.terminal.first_unparsed.is_none() {
1185                            protocol.terminal.first_unparsed = Some(line.clone());
1186                        }
1187                    }
1188                } else {
1189                    protocol.failure.get_or_insert_with(|| {
1190                        "app-server closed stdout before turn/completed".to_string()
1191                    });
1192                    protocol.finished = true;
1193                }
1194            }
1195            () = &mut deadline => {
1196                let partial = protocol.terminal.text.clone();
1197                shut_down(&mut child, stderr_task).await;
1198                reaped.store(true, std::sync::atomic::Ordering::SeqCst);
1199                return Err(Error::Timeout {
1200                    bin,
1201                    timeout: request.timeout.unwrap_or_default(),
1202                    partial,
1203                });
1204            }
1205            _ = &mut cancel => {
1206                shut_down(&mut child, stderr_task).await;
1207                reaped.store(true, std::sync::atomic::Ordering::SeqCst);
1208                return Err(Error::Cancelled { bin });
1209            }
1210        }
1211    }
1212
1213    // app-server is a service rather than a one-shot process. EOF asks it to
1214    // stop cleanly; the short fallback prevents a completed turn from hanging
1215    // because a future CLI release keeps serving after its input closes.
1216    drop(stdin);
1217    if tokio::time::timeout(std::time::Duration::from_secs(2), child.child.wait())
1218        .await
1219        .is_err()
1220    {
1221        kill_process_group(&child.child);
1222        let _ = child.child.kill().await;
1223    }
1224    child.armed = false;
1225    reaped.store(true, std::sync::atomic::Ordering::SeqCst);
1226    drop(events);
1227    let stderr = stderr_task.await.unwrap_or_default();
1228
1229    persist_result?;
1230    if let Some(detail) = protocol.failure {
1231        return Err(Error::Parse {
1232            agent: request.agent,
1233            detail,
1234        });
1235    }
1236
1237    let terminal = protocol.terminal;
1238    if terminal.stop == Stop::Error {
1239        return Err(classify_run(
1240            request.agent,
1241            &bin,
1242            0,
1243            &stderr,
1244            &raw,
1245            &terminal,
1246        ));
1247    }
1248    let structured = terminal.structured.clone().or_else(|| {
1249        request
1250            .schema
1251            .as_ref()
1252            .and_then(|_| serde_json::from_str(&terminal.text).ok())
1253    });
1254    Ok(Outcome {
1255        agent: request.agent,
1256        session: terminal.session,
1257        text: terminal.text,
1258        usage: terminal.usage,
1259        stop: terminal.stop,
1260        rate_limit: terminal.rate_limit,
1261        exit_code: 0,
1262        stderr,
1263        unparsed: terminal.unparsed,
1264        first_unparsed: terminal.first_unparsed,
1265        structured,
1266    })
1267}
1268
1269/// Write every control whose protocol ids are available, preserving earlier
1270/// messages until thread and turn startup have both completed.
1271async fn flush_codex_controls(
1272    protocol: &mut crate::codex_app_server::Protocol,
1273    pending: &mut VecDeque<Control>,
1274    steer_receipts: &mut HashMap<u64, tokio::sync::oneshot::Sender<Result<()>>>,
1275    stdin: &mut tokio::process::ChildStdin,
1276    bin: &str,
1277) -> Result<()> {
1278    let mut waiting = VecDeque::new();
1279    while let Some(control) = pending.pop_front() {
1280        let encoded = match control {
1281            Control::Message { body, receipt } => {
1282                if let Some(request) = protocol.steer(&body) {
1283                    steer_receipts.insert(request.id, receipt);
1284                    Some(request.wire)
1285                } else {
1286                    waiting.push_back(Control::Message { body, receipt });
1287                    None
1288                }
1289            }
1290            Control::Approval { id, decision } => {
1291                if let Some(encoded) = protocol.respond(&id, &decision) {
1292                    Some(encoded)
1293                } else {
1294                    waiting.push_back(Control::Approval { id, decision });
1295                    None
1296                }
1297            }
1298        };
1299        if let Some(encoded) = encoded {
1300            stdin
1301                .write_all(encoded.as_bytes())
1302                .await
1303                .map_err(|source| Error::Spawn {
1304                    bin: bin.to_string(),
1305                    source,
1306                })?;
1307        }
1308    }
1309    pending.append(&mut waiting);
1310    Ok(())
1311}
1312
1313/// Resolve the public `Run::send` future only after app-server has answered
1314/// the matching `turn/steer` request. Dropping an unresolved sender when the
1315/// turn completes is deliberate: the caller receives `Error::Cancelled` and
1316/// can put the message into a fresh resumed turn.
1317fn settle_codex_steers(
1318    receipts: &mut HashMap<u64, tokio::sync::oneshot::Sender<Result<()>>>,
1319    responses: Vec<crate::codex_app_server::SteerResponse>,
1320    bin: &str,
1321) {
1322    for response in responses {
1323        let Some(receipt) = receipts.remove(&response.id) else {
1324            continue;
1325        };
1326        let result = response
1327            .result
1328            .map(|_| ())
1329            .map_err(|message| Error::AgentError {
1330                agent: crate::Agent::Codex,
1331                bin: bin.to_string(),
1332                status: None,
1333                message,
1334            });
1335        let _ = receipt.send(result);
1336    }
1337}
1338
1339/// Kill the process group, reap the child, and join the stderr reader.
1340///
1341/// The orderly teardown both cancellation and timeout share. Returns whatever
1342/// stderr had been captured, so a caller can still report why a run was stopped.
1343async fn shut_down(child: &mut ChildGuard, stderr_task: tokio::task::JoinHandle<String>) -> String {
1344    kill_process_group(&child.child);
1345    // Reap, so the caller is not left with a zombie once this returns.
1346    let _ = child.child.kill().await;
1347    child.armed = false;
1348    // The pipes are closed now that the child is gone, so this finishes
1349    // promptly rather than hanging the cancellation.
1350    stderr_task.await.unwrap_or_default()
1351}
1352
1353/// Turn a failure into the most specific error available, agent included so an
1354/// auth failure can carry the right login command.
1355fn classify_run(
1356    agent: crate::Agent,
1357    bin: &str,
1358    code: i32,
1359    stderr: &str,
1360    stdout: &str,
1361    terminal: &Terminal,
1362) -> Error {
1363    // Checked before quota and before a plain failure: a login problem is the
1364    // most specific reading of the output, and the only one a user can act on
1365    // directly.
1366    let named = |source: &str| Error::NotAuthenticated {
1367        agent,
1368        bin: bin.to_string(),
1369        message: first_meaningful_line(source).unwrap_or_default(),
1370        hint: agent.login_hint(),
1371    };
1372
1373    // The CLI's own channel, read whole: Copilot's notice runs to five lines.
1374    if looks_unauthenticated(stderr) {
1375        return named(stderr);
1376    }
1377
1378    /*
1379     * The agent's own answer, read only at the top.
1380     *
1381     * An agent with no credentials has nothing to say but the notice, so the
1382     * phrase is in its opening lines and the whole answer is those lines.
1383     * An agent that *writes about* logging in buries the same words in
1384     * paragraphs, and reading the whole answer counted that as a login
1385     * failure: a reply explaining why a publish had been refused mentioned
1386     * not being authenticated, so the run was reported as an auth error, the
1387     * hint told the user to run `/login`, and the answer itself was replaced
1388     * by the report. An agent's prose is not a diagnosis of the agent.
1389     */
1390    for source in [terminal.text.as_str(), stdout] {
1391        if answer_reports_no_credentials(source) {
1392            return named(&opening_lines(source, OPENING_LINES));
1393        }
1394    }
1395    classify(agent, bin, code, stderr, stdout, terminal)
1396}
1397
1398/// The longest an agent's answer may be and still be read as a notice.
1399///
1400/// A CLI that has been stopped says so briefly: Claude's is one sentence and a
1401/// reset time. An answer that *discusses* limits runs to paragraphs and uses
1402/// exactly the same words, so length is the only thing separating them.
1403const NOTICE_MAX: usize = 240;
1404
1405/// How far into an agent's own output a diagnosis may be read from.
1406///
1407/// Three rather than one, because a CLI is entitled to a banner line before it
1408/// says what is wrong, and three rather than more, because past that an agent
1409/// is answering the question it was asked.
1410const OPENING_LINES: usize = 3;
1411
1412/// The first `count` non-blank lines, trimmed and rejoined.
1413fn opening_lines(text: &str, count: usize) -> String {
1414    text.lines()
1415        .map(str::trim)
1416        .filter(|line| !line.is_empty())
1417        .take(count)
1418        .collect::<Vec<_>>()
1419        .join("\n")
1420}
1421
1422/// Whether an agent's own answer is a credentials notice rather than an answer
1423/// that happens to discuss credentials.
1424///
1425/// The single rule for reading an answer as a diagnosis of the run, shared by
1426/// the gate in `run` and by `classify_run`. They read the same text for the
1427/// same phrases and used to apply different rules to it: whole text at the
1428/// gate, opening lines in the classifier. A healthy answer about logging in
1429/// satisfied one and not the other, which opened the error path for a run no
1430/// classifier would then name.
1431///
1432/// Short *and* at the top, which is the same rule the quota branch applies,
1433/// and it takes both halves. Lines alone were not enough: asked to explain the
1434/// difference between a rate limit and an auth failure, a live Claude answered
1435/// in one 900-character paragraph, so "the first three lines" was the entire
1436/// essay and the phrase inside it convicted the run. Prose wraps at the
1437/// window, not at a newline, so length is what distinguishes a notice from an
1438/// answer. A real notice is a sentence: `Not logged in, please run /login`.
1439fn answer_reports_no_credentials(text: &str) -> bool {
1440    let opening = opening_lines(text, OPENING_LINES);
1441    opening.len() <= NOTICE_MAX && looks_unauthenticated(&opening)
1442}
1443
1444/// Whether a classifier named the failure rather than falling through to the
1445/// generic one.
1446///
1447/// `Error::Failed` is what `classify` returns when nothing more specific fits.
1448/// On a run that exited cleanly that is not a diagnosis, it is the absence of
1449/// one, and an answer must not be discarded for it.
1450fn names_a_failure(error: &Error) -> bool {
1451    !matches!(error, Error::Failed { .. })
1452}
1453
1454/// Whether text is an agent saying it has no usable credentials.
1455///
1456/// Narrow on purpose. Mislabelling an ordinary failure as an auth problem sends
1457/// someone to re-login over something unrelated, so these are phrases the CLIs
1458/// actually emit rather than every string containing "auth".
1459fn looks_unauthenticated(text: &str) -> bool {
1460    const PHRASES: &[&str] = &[
1461        // Claude, verified: an unauthenticated run answers exactly this.
1462        "not logged in",
1463        "please run /login",
1464        // Copilot, verified: it exits 1 with plain text, and none of the other
1465        // phrases here appear in it. Its wording shares no vocabulary with the
1466        // other two, which is why this had to be observed rather than guessed.
1467        "no authentication information",
1468        "invalid api key",
1469        "authentication_error",
1470        "unauthorized",
1471        "not authenticated",
1472        "no credentials",
1473        "credentials not found",
1474        "please log in",
1475    ];
1476    let lower = text.to_ascii_lowercase();
1477    PHRASES.iter().any(|needle| lower.contains(needle)) || mentions_status(&lower, "401")
1478}
1479
1480/// Whether `code` appears as a standalone token rather than inside a longer run
1481/// of characters.
1482///
1483/// `401` was previously matched as a bare substring, which made any Copilot
1484/// failure an auth failure whenever one of the UUIDs it prints happened to
1485/// contain those three digits: `"id":"1b0b1401-cb86-..."` was enough. That is
1486/// not rare, since a run emits several ids, so the misdiagnosis was
1487/// intermittent and told someone to re-login over an unrelated failure.
1488///
1489/// A status code is a word. Requiring non-alphanumeric neighbours keeps
1490/// `HTTP 401` and `(status 401)` while rejecting every hex blob, and a UUID
1491/// cannot produce a standalone `401` at all because its groups are four, eight
1492/// or twelve characters long.
1493fn mentions_status(haystack: &str, code: &str) -> bool {
1494    haystack.match_indices(code).any(|(at, _)| {
1495        let before = haystack[..at].chars().next_back();
1496        let after = haystack[at + code.len()..].chars().next();
1497        let free = |c: Option<char>| c.is_none_or(|c| !c.is_alphanumeric());
1498        free(before) && free(after)
1499    })
1500}
1501
1502/// Turn a non-zero exit into the most specific error available.
1503fn classify(
1504    agent: crate::Agent,
1505    bin: &str,
1506    code: i32,
1507    stderr: &str,
1508    stdout: &str,
1509    terminal: &Terminal,
1510) -> Error {
1511    let quota_signalled = terminal
1512        .rate_limit
1513        .as_ref()
1514        .is_some_and(crate::outcome::RateLimit::is_blocking);
1515    // Scanning the *raw* stream for quota wording is a false-positive machine:
1516    // under `stream-json` Claude prints a `rate_limit_event` record on every
1517    // run, including one whose status is `allowed`, so the substring
1518    // `rate_limit` is present in perfectly healthy output. Where the stream
1519    // parsed, the parsed signal and the agent's own prose decide; the raw scan
1520    // is only the fallback for output that produced neither.
1521    /*
1522     * The agent's own answer is evidence about the *topic*, not about the run.
1523     *
1524     * `terminal.text` is what the agent said. A turn that discusses quotas at
1525     * any length contains the vocabulary this function searches for, so a
1526     * finished, successful answer on that subject classified its own run as
1527     * blocked and replaced itself with a banner quoting one of its own
1528     * sentences. The same shape as the auth misclassification fixed in 0.4.2,
1529     * one branch further down the same function.
1530     *
1531     * So the run's own channels stay authoritative. `error_message` is the
1532     * CLI's own field rather than the model's words, and is read whole. The
1533     * answer is read only when it is short enough to *be* a notice: a run that
1534     * was really stopped has the notice and nothing else to say, in a couple
1535     * of lines, while an answer that discusses the subject runs to paragraphs.
1536     * Length is the one thing that separates them, because the vocabulary is
1537     * identical by definition.
1538     */
1539    let reported = terminal.error_message.clone().unwrap_or_default();
1540    let answered = if terminal.text.len() <= NOTICE_MAX {
1541        opening_lines(&terminal.text, OPENING_LINES)
1542    } else {
1543        String::new()
1544    };
1545    let prose = if terminal.text.is_empty() {
1546        format!("{reported}\n{}", opening_lines(stdout, OPENING_LINES))
1547    } else {
1548        format!("{reported}\n{answered}")
1549    };
1550    if quota_signalled || looks_rate_limited(stderr) || looks_rate_limited(&prose) {
1551        return Error::RateLimited {
1552            bin: bin.to_string(),
1553            message: first_meaningful_line(stderr)
1554                .or_else(|| first_meaningful_line(&prose))
1555                .unwrap_or_else(|| "usage limit reached".to_string()),
1556        };
1557    }
1558    // A rejected flag is not a failed request, it is this crate and the CLI
1559    // disagreeing about what the CLI accepts. Naming that is the difference
1560    // between "the run failed" and "your codex is a different version".
1561    if let Some(detail) = rejected_flag(stderr).or_else(|| rejected_flag(stdout)) {
1562        return Error::FlagRejected {
1563            bin: bin.to_string(),
1564            detail,
1565        };
1566    }
1567    // Checked before the generic failure but after quota and a rejected flag,
1568    // which are more specific readings of the same output.
1569    if terminal.stop == Stop::Error {
1570        return Error::AgentError {
1571            agent,
1572            bin: bin.to_string(),
1573            status: terminal.error_status,
1574            // Codex reports the reason apart from the answer; Claude puts it
1575            // where the answer would be.
1576            message: terminal
1577                .error_message
1578                .clone()
1579                .or_else(|| first_meaningful_line(&terminal.text))
1580                .or_else(|| first_meaningful_line(stderr))
1581                .unwrap_or_else(|| "the agent reported a failure without explaining it".into()),
1582        };
1583    }
1584
1585    Error::Failed {
1586        bin: bin.to_string(),
1587        code,
1588        // Fall back to stdout when stderr explains nothing. Codex reports a
1589        // rejected schema as an `{"type":"error"}` event on *stdout* while
1590        // stderr carries only "Reading additional input from stdin...", so
1591        // reporting stderr alone describes the failure as a status message.
1592        stderr: first_meaningful_line(stderr)
1593            .filter(|line| looks_explanatory(line))
1594            .or_else(|| first_meaningful_line(stdout))
1595            .or_else(|| first_meaningful_line(stderr))
1596            .unwrap_or_default(),
1597    }
1598}
1599
1600/// Whether a line plausibly explains a failure rather than narrating progress.
1601fn looks_explanatory(line: &str) -> bool {
1602    const NOISE: &[&str] = &[
1603        "reading additional input",
1604        "reading prompt",
1605        "waiting",
1606        "connecting",
1607        "loading",
1608    ];
1609    let lower = line.to_ascii_lowercase();
1610    !NOISE.iter().any(|noise| lower.contains(noise))
1611}
1612
1613/// The CLI's complaint, if it refused an argument.
1614///
1615/// The phrasings are clap's and commander's, which is what all three CLIs are
1616/// built on. Matched narrowly: a false positive would relabel a genuine failure
1617/// as a version problem and send someone chasing the wrong thing.
1618fn rejected_flag(text: &str) -> Option<String> {
1619    const REJECTIONS: &[&str] = &[
1620        "unexpected argument",
1621        "unknown option",
1622        "unrecognized option",
1623        "unknown flag",
1624        "invalid option",
1625        "unexpected option",
1626    ];
1627    let lower = text.to_ascii_lowercase();
1628    REJECTIONS
1629        .iter()
1630        .any(|needle| lower.contains(needle))
1631        .then(|| first_meaningful_line(text).unwrap_or_default())
1632}
1633
1634/// Whether text carries a provider quota refusal.
1635///
1636/// Deliberately a small set of unambiguous phrases: a false positive here would
1637/// relabel an ordinary failure as a quota problem and send a caller into a
1638/// pointless backoff.
1639fn looks_rate_limited(text: &str) -> bool {
1640    let lower = text.to_ascii_lowercase();
1641    [
1642        "rate limit",
1643        "rate_limit",
1644        "usage limit",
1645        "quota exceeded",
1646        "too many requests",
1647    ]
1648    .iter()
1649    .any(|needle| lower.contains(needle))
1650        // A status code is a word, and `429` as a bare substring is in every
1651        // line number, byte count, sha fragment and identifier that happens to
1652        // contain those digits. `401` was already given this treatment after it
1653        // matched inside a UUID and sent someone to re-login; this is the same
1654        // rule, applied to the code that had been left as a substring.
1655        || mentions_status(&lower, "429")
1656}
1657
1658/// The most useful line of a CLI's output for an error message.
1659///
1660/// Not simply the first non-blank one. CLIs open with progress and status
1661/// chatter, so the first line is often "Reading additional input from stdin..."
1662/// while the actual cause is further down. That turns a report into a
1663/// misdirection: it looks like an explanation and is not one.
1664///
1665/// So a line that looks like an error wins, and the first non-blank line is the
1666/// fallback when nothing does.
1667fn first_meaningful_line(text: &str) -> Option<String> {
1668    const ERROR_MARKERS: &[&str] = &[
1669        "error",
1670        "failed",
1671        "fatal",
1672        "panic",
1673        "denied",
1674        "invalid",
1675        "unexpected",
1676        "cannot",
1677        "unable",
1678    ];
1679    let lines: Vec<&str> = text
1680        .lines()
1681        .map(str::trim)
1682        .filter(|line| !line.is_empty())
1683        .collect();
1684
1685    lines
1686        .iter()
1687        .find(|line| {
1688            let lower = line.to_ascii_lowercase();
1689            ERROR_MARKERS.iter().any(|marker| lower.contains(marker))
1690        })
1691        .or_else(|| lines.first())
1692        .map(|line| (*line).to_string())
1693}
1694
1695/// Write the session binding back, reporting any store failure.
1696///
1697/// Called as soon as an id is known rather than only on a clean exit. Waiting
1698/// for success would lose the binding for exactly the runs where continuity
1699/// matters most: a timeout, a crash, or a cancelled turn.
1700fn persist_session(request: &Request, token: &str) -> Result<()> {
1701    let Some(binding) = &request.binding else {
1702        return Ok(());
1703    };
1704    binding
1705        .store
1706        .bind(request.agent, &binding.project, &binding.name, token)
1707        .map(|_| ())
1708}
1709
1710/// The id this run is already known by before it starts, if any.
1711///
1712/// Only a caller-assigned id qualifies: a printed id does not exist yet. This
1713/// is what makes an assigned session survive a run that never finishes.
1714fn preassigned_token(request: &Request) -> Option<String> {
1715    match &request.plan().cont {
1716        Continue::NewWith(id) => Some(id.clone()),
1717        _ => None,
1718    }
1719}
1720
1721/// Reported by an agent that exited cleanly but said nothing useful.
1722impl Outcome {
1723    /// Whether the agent produced any answer at all.
1724    #[must_use]
1725    pub fn is_empty(&self) -> bool {
1726        self.text.trim().is_empty() && self.stop == Stop::Completed
1727    }
1728}
1729
1730#[cfg(test)]
1731mod tests {
1732    use super::*;
1733    use crate::agent::Agent;
1734
1735    #[test]
1736    fn quota_phrases_are_recognized_and_ordinary_errors_are_not() {
1737        assert!(looks_rate_limited("Error: rate limit exceeded"));
1738        assert!(looks_rate_limited("HTTP 429 Too Many Requests"));
1739        assert!(looks_rate_limited("You have hit your usage limit"));
1740        // A plain failure must not be mistaken for a quota problem.
1741        assert!(!looks_rate_limited("error: no such file or directory"));
1742        assert!(!looks_rate_limited("model not found"));
1743    }
1744
1745    #[test]
1746    fn a_blocking_rate_limit_event_classifies_as_rate_limited() {
1747        let terminal = Terminal {
1748            rate_limit: Some(crate::outcome::RateLimit {
1749                status: "rejected".into(),
1750                window: Some("five_hour".into()),
1751                resets_at: None,
1752                overage_status: None,
1753                is_using_overage: None,
1754            }),
1755            ..Terminal::default()
1756        };
1757        assert!(matches!(
1758            classify(Agent::Claude, "claude", 1, "", "", &terminal),
1759            Error::RateLimited { .. }
1760        ));
1761    }
1762
1763    #[test]
1764    fn an_allowed_rate_limit_event_is_not_a_failure_cause() {
1765        let terminal = Terminal {
1766            rate_limit: Some(crate::outcome::RateLimit {
1767                status: "allowed".into(),
1768                window: None,
1769                resets_at: None,
1770                overage_status: None,
1771                is_using_overage: None,
1772            }),
1773            ..Terminal::default()
1774        };
1775        assert!(matches!(
1776            classify(Agent::Claude, "claude", 1, "boom", "", &terminal),
1777            Error::Failed { .. }
1778        ));
1779    }
1780
1781    /// The exact shape that made a Copilot run look unauthenticated: a UUID
1782    /// carrying the digits 401. Copilot prints several ids per run, so this
1783    /// misfired intermittently and told the user to re-login over a failure
1784    /// that had nothing to do with credentials.
1785    #[test]
1786    fn an_id_containing_401_is_not_an_auth_failure() {
1787        let line = r#"{"type":"session.mcp_server_status_changed","id":"1b0b1401-cb86-4276-9874-e84b94c96499"}"#;
1788        assert!(
1789            !looks_unauthenticated(line),
1790            "a hex blob is not a status code"
1791        );
1792    }
1793
1794    /// The needle still has to work where it was meant to. A status code is a
1795    /// word, and these are the forms an agent actually prints.
1796    #[test]
1797    fn a_real_401_is_still_recognized() {
1798        for text in [
1799            "HTTP 401",
1800            "request failed (status 401)",
1801            "401: unauthorized",
1802            "got a 401 from the API",
1803        ] {
1804            assert!(looks_unauthenticated(text), "should match: {text}");
1805        }
1806    }
1807
1808    /// Neighbouring digits mean it is part of some longer number, not a status.
1809    #[test]
1810    fn digits_around_401_keep_it_from_matching() {
1811        for text in ["error 4010", "code 1401", "seq 24019"] {
1812            assert!(!looks_unauthenticated(text), "should not match: {text}");
1813        }
1814    }
1815
1816    /// Verbatim from a healthy claude 2.1.205 run. Every `stream-json` run
1817    /// carries this record, and its status is `allowed`: nothing is refused.
1818    /// Scanning the raw stream for `rate_limit` matched it anyway, so any
1819    /// Claude failure was reported as a quota refusal, sending a caller to back
1820    /// off when the real cause was something they could fix.
1821    #[test]
1822    fn a_healthy_rate_limit_heartbeat_is_not_a_refusal() {
1823        let stdout = r#"{"type":"rate_limit_event","rate_limit_info":{"status":"allowed","resetsAt":1785331800,"rateLimitType":"five_hour","overageStatus":"rejected","isUsingOverage":false}}"#;
1824        let terminal = Terminal {
1825            stop: Stop::Error,
1826            error_status: Some(404),
1827            text: "There's an issue with the selected model (bogus-model-xyz).".into(),
1828            rate_limit: Some(crate::outcome::RateLimit {
1829                status: "allowed".into(),
1830                window: Some("five_hour".into()),
1831                resets_at: Some(1_785_331_800),
1832                overage_status: None,
1833                is_using_overage: None,
1834            }),
1835            ..Terminal::default()
1836        };
1837        let err = classify_run(Agent::Claude, "claude", 0, "", stdout, &terminal);
1838        assert!(
1839            matches!(err, Error::AgentError { .. }),
1840            "the heartbeat must not mask the real cause: {err:?}"
1841        );
1842    }
1843
1844    /// The counterpart: a refusal the parser did read must still be one, even
1845    /// though it arrives with the same zero exit code.
1846    #[test]
1847    fn a_rejected_quota_signal_is_still_a_refusal() {
1848        let terminal = Terminal {
1849            rate_limit: Some(crate::outcome::RateLimit {
1850                status: "rejected".into(),
1851                window: Some("five_hour".into()),
1852                resets_at: None,
1853                overage_status: None,
1854                is_using_overage: None,
1855            }),
1856            ..Terminal::default()
1857        };
1858        assert!(matches!(
1859            classify_run(Agent::Claude, "claude", 0, "", "", &terminal),
1860            Error::RateLimited { .. }
1861        ));
1862    }
1863
1864    /// Verbatim from a real run with an unknown model. Claude exits **0** with
1865    /// `subtype: "success"` while `is_error` is true and the explanation sits
1866    /// where the answer would be, so a caller checking only `Result::is_ok`
1867    /// renders "There's an issue with the selected model" as the answer.
1868    #[test]
1869    fn a_failed_turn_is_an_error_even_though_the_process_exited_cleanly() {
1870        let terminal = Terminal {
1871            stop: Stop::Error,
1872            error_status: Some(404),
1873            text: "There's an issue with the selected model (bogus-model-xyz). \
1874                   It may not exist or you may not have access to it."
1875                .into(),
1876            ..Terminal::default()
1877        };
1878        let err = classify_run(Agent::Claude, "claude", 0, "", "", &terminal);
1879        let Error::AgentError {
1880            agent,
1881            status,
1882            message,
1883            ..
1884        } = &err
1885        else {
1886            panic!("expected AgentError, got {err:?}")
1887        };
1888        assert_eq!(*agent, Agent::Claude);
1889        assert_eq!(*status, Some(404), "the provider status must survive");
1890        assert!(message.contains("selected model"), "{message}");
1891    }
1892
1893    /// A quota refusal and a missing login are more specific readings of the
1894    /// same shape, so they must not be swallowed by the general case.
1895    #[test]
1896    fn a_failed_turn_does_not_mask_a_more_specific_cause() {
1897        let auth = Terminal {
1898            stop: Stop::Error,
1899            text: "Not logged in · Please run /login".into(),
1900            ..Terminal::default()
1901        };
1902        assert!(
1903            classify_run(Agent::Claude, "claude", 0, "", "", &auth).is_auth_failure(),
1904            "an unauthenticated failed turn must stay an auth failure"
1905        );
1906
1907        let quota = Terminal {
1908            stop: Stop::Error,
1909            rate_limit: Some(crate::outcome::RateLimit {
1910                status: "rejected".into(),
1911                window: None,
1912                resets_at: None,
1913                overage_status: None,
1914                is_using_overage: None,
1915            }),
1916            ..Terminal::default()
1917        };
1918        assert!(
1919            matches!(
1920                classify_run(Agent::Claude, "claude", 0, "", "", &quota),
1921                Error::RateLimited { .. }
1922            ),
1923            "a quota-blocked failed turn must stay a rate limit"
1924        );
1925    }
1926
1927    /// Verified against the real CLI: with `USER` withheld, claude answers
1928    /// "Not logged in · Please run /login" and exits **0**. Checking only the
1929    /// exit code hands back a successful Outcome whose answer is a login
1930    /// prompt.
1931    #[test]
1932    fn an_unauthenticated_run_is_named_even_though_it_exits_zero() {
1933        let terminal = Terminal {
1934            text: "Not logged in · Please run /login".into(),
1935            ..Terminal::default()
1936        };
1937        let err = classify_run(Agent::Claude, "claude", 0, "", "", &terminal);
1938        let Error::NotAuthenticated { agent, hint, .. } = &err else {
1939            panic!("expected NotAuthenticated, got {err:?}")
1940        };
1941        assert_eq!(*agent, Agent::Claude);
1942        assert!(hint.contains("/login"), "{hint}");
1943        assert!(err.is_auth_failure());
1944    }
1945
1946    /// Verbatim from an unauthenticated Copilot run, captured by pointing it at
1947    /// an empty HOME. Its wording shares no phrase with Claude's or Codex's, so
1948    /// before this was observed the phrase list did not match it at all and a
1949    /// missing Copilot login was reported as a generic failure.
1950    #[test]
1951    fn copilots_own_unauthenticated_wording_is_recognized() {
1952        let stderr = "Error: No authentication information found.\n\n\
1953                      Copilot can be authenticated with GitHub using an OAuth Token or a \
1954                      Fine-Grained Personal Access Token.\n\n\
1955                      To authenticate, you can use any of the following methods:\n\
1956                      \u{2022} Start 'copilot' and run the '/login' command\n\
1957                      \u{2022} Set the COPILOT_GITHUB_TOKEN, GH_TOKEN, or GITHUB_TOKEN \
1958                      environment variable";
1959        let err = classify_run(
1960            Agent::Copilot,
1961            "copilot",
1962            1,
1963            stderr,
1964            "",
1965            &Terminal::default(),
1966        );
1967        let Error::NotAuthenticated { agent, hint, .. } = &err else {
1968            panic!("expected NotAuthenticated, got {err:?}")
1969        };
1970        assert_eq!(*agent, Agent::Copilot);
1971        assert!(hint.contains("copilot login"), "{hint}");
1972    }
1973
1974    /// Each agent's hint has to name its own login route, since they differ:
1975    /// Codex and Copilot have `login` subcommands, Claude does not.
1976    #[test]
1977    fn every_agent_offers_its_own_login_route() {
1978        for (agent, expected) in [
1979            (Agent::Claude, "setup-token"),
1980            (Agent::Codex, "codex login"),
1981            (Agent::Copilot, "copilot login"),
1982        ] {
1983            let err = classify_run(
1984                agent,
1985                agent.bin(),
1986                1,
1987                "error: unauthorized",
1988                "",
1989                &Terminal::default(),
1990            );
1991            let Error::NotAuthenticated { hint, .. } = &err else {
1992                panic!("{agent}: expected NotAuthenticated, got {err:?}")
1993            };
1994            assert!(hint.contains(expected), "{agent}: {hint}");
1995        }
1996    }
1997
1998    /// Reported from the field: a run was stopped, the user was told `claude`
1999    /// was not authenticated, and the answer was replaced by a login hint. The
2000    /// agent had been explaining why a `cargo publish` was refused, and its own
2001    /// prose contained the phrases this classifier looks for. An answer is not
2002    /// a diagnosis of the thing that produced it.
2003    #[test]
2004    fn an_agent_writing_about_authentication_is_not_an_auth_failure() {
2005        let answer = "The publish was refused before it ran.\n\n\
2006                      What denied it was the auto mode classifier, not a missing \
2007                      credential.\n\
2008                      In auto mode there is no human to receive the prompt, so an \
2009                      ask collapses into a refusal.\n\
2010                      The message said the CLI was not authenticated, which is \
2011                      unrelated: an unauthorized upload is exactly what the rule \
2012                      is there to stop.";
2013        let terminal = Terminal {
2014            text: answer.into(),
2015            ..Terminal::default()
2016        };
2017        let err = classify_run(Agent::Claude, "claude", 1, "", answer, &terminal);
2018        assert!(
2019            !err.is_auth_failure(),
2020            "an answer that discusses auth was read as an auth failure: {err:?}"
2021        );
2022    }
2023
2024    /// The other half of the same rule: the notice itself still has to be
2025    /// caught, and it arrives as the agent's entire answer.
2026    #[test]
2027    fn the_notice_is_still_caught_when_it_is_the_whole_answer() {
2028        for text in [
2029            "Not logged in · Please run /login",
2030            // A banner first, which is why the opening is three lines deep.
2031            "claude 2.1.212\n\nNot logged in · Please run /login",
2032        ] {
2033            let terminal = Terminal {
2034                text: text.into(),
2035                ..Terminal::default()
2036            };
2037            let err = classify_run(Agent::Claude, "claude", 0, "", "", &terminal);
2038            assert!(
2039                err.is_auth_failure(),
2040                "{text:?} was not read as auth: {err:?}"
2041            );
2042        }
2043    }
2044
2045    /// The gate into the error path and the classifier behind it must read an
2046    /// answer by the same rule.
2047    ///
2048    /// Shaped after the report from the field: a healthy, finished turn about
2049    /// a stalled crate release, which mentions an auth error in a later
2050    /// paragraph because that was the subject. Read whole, as the gate used
2051    /// to, the phrase convicts the run; read at its opening, as everything
2052    /// now does, the answer is an answer. An agent with no credentials leads
2053    /// with the notice, which is what makes the opening the honest place to
2054    /// look.
2055    #[test]
2056    fn an_answer_mentioning_auth_late_does_not_open_the_error_path() {
2057        let answer = "So the duplicate pastes cost nothing.\n\
2058                      The publish chain is done: 0.4.1 and 0.4.2 are both on the \
2059                      registry and tagged.\n\
2060                      Everything downstream already consumes them.\n\
2061                      The only thing still open anywhere is the crate PR, \
2062                      `pathscale/RustAgentAbstraction#18`, which is the auth error \
2063                      that ate your reply: the run was reported as `not \
2064                      authenticated` and the hint sent you to /login, while the \
2065                      credentials were fine the whole time.";
2066        assert!(
2067            !answer_reports_no_credentials(answer),
2068            "an answer discussing auth opened the error path"
2069        );
2070        // And the notice itself, which is what the rule exists to catch.
2071        assert!(answer_reports_no_credentials(
2072            "Not logged in \u{b7} Please run /login"
2073        ));
2074    }
2075
2076    /// The backstop, which is what makes a false positive survivable at all.
2077    ///
2078    /// Every phrase check here is a heuristic over ordinary English and will
2079    /// be wrong eventually. When it is, the run reaches a classifier that
2080    /// cannot name any failure and returns the generic one. On a process that
2081    /// exited cleanly with a completed turn, that verdict is the absence of
2082    /// evidence rather than evidence, and the finished answer must stand.
2083    #[test]
2084    fn a_generic_failure_does_not_name_a_failure() {
2085        let unnamed = Error::Failed {
2086            bin: "claude".into(),
2087            code: 0,
2088            stderr: String::new(),
2089        };
2090        assert!(
2091            !names_a_failure(&unnamed),
2092            "a generic failure was treated as a diagnosis, which discards the answer"
2093        );
2094        // Everything a classifier can actually name still stands on its own.
2095        assert!(names_a_failure(&Error::RateLimited {
2096            bin: "claude".into(),
2097            message: "usage limit reached".into(),
2098        }));
2099        assert!(names_a_failure(&Error::NotAuthenticated {
2100            agent: Agent::Claude,
2101            bin: "claude".into(),
2102            message: "Not logged in".into(),
2103            hint: Agent::Claude.login_hint(),
2104        }));
2105    }
2106
2107    /// Reported from the field: a finished, successful turn that happened to be
2108    /// *about* usage limits classified its own run as blocked, and the banner
2109    /// quoted one of the answer's own sentences back as the provider's message.
2110    /// An answer is evidence about its topic, not about the run that produced it.
2111    #[test]
2112    fn an_agent_writing_about_limits_is_not_a_limit() {
2113        let answer = "The three retries cost nothing extra, so that is not where it \
2114                      came from.\n\n\
2115                      Providers do not rate limit on repetition: the limit is on \
2116                      tokens per window, and a 429 is what you would see if one had \
2117                      actually been reached. Each retry does re-send the whole \
2118                      conversation, which is real spend, but spend is not the same \
2119                      thing as a block and the run reported no blocking signal at \
2120                      all.\n\
2121                      Nothing here suggests the usage limit was reached, and the \
2122                      banner quoted a sentence of this answer back as though a \
2123                      provider had written it.";
2124        let terminal = Terminal {
2125            text: answer.into(),
2126            ..Terminal::default()
2127        };
2128        let err = classify(Agent::Claude, "claude", 1, "", answer, &terminal);
2129        assert!(
2130            !matches!(err, Error::RateLimited { .. }),
2131            "an answer discussing limits was read as one: {err:?}"
2132        );
2133    }
2134
2135    /// A status code is a word. These are the shapes that used to trip it.
2136    #[test]
2137    fn a_bare_429_in_prose_is_not_a_status_code() {
2138        assert!(!looks_rate_limited("see run.rs:4291 for the caller"));
2139        assert!(!looks_rate_limited("sha 8f429ac"));
2140        assert!(looks_rate_limited("HTTP 429"));
2141        assert!(looks_rate_limited("(status 429)"));
2142        assert!(looks_rate_limited("Error: too many requests"));
2143    }
2144
2145    /// Auth is the most specific reading, so it wins over a generic failure,
2146    /// but must not swallow unrelated errors.
2147    #[test]
2148    fn ordinary_failures_are_not_mistaken_for_auth_problems() {
2149        for stderr in [
2150            "error: no such file or directory",
2151            "model not found",
2152            "rate limit exceeded",
2153            "error: unexpected argument '--sandbox' found",
2154        ] {
2155            let err = classify_run(Agent::Codex, "codex", 1, stderr, "", &Terminal::default());
2156            assert!(
2157                !err.is_auth_failure(),
2158                "{stderr:?} was misread as an auth failure: {err:?}"
2159            );
2160        }
2161    }
2162
2163    /// The exact failure that cost a round of debugging: `codex exec resume`
2164    /// rejects `--sandbox`, which `Error::Failed` reported as a generic
2165    /// non-zero exit naming a flag rather than a version mismatch.
2166    #[test]
2167    fn a_rejected_flag_is_named_as_a_version_mismatch() {
2168        let err = classify(
2169            Agent::Codex,
2170            "codex",
2171            2,
2172            "error: unexpected argument '--sandbox' found",
2173            "",
2174            &Terminal::default(),
2175        );
2176        let Error::FlagRejected { bin, detail } = err else {
2177            panic!("expected FlagRejected, got {err:?}")
2178        };
2179        assert_eq!(bin, "codex");
2180        assert!(detail.contains("--sandbox"), "{detail}");
2181    }
2182
2183    #[test]
2184    fn ordinary_failures_are_not_mistaken_for_version_drift() {
2185        for stderr in [
2186            "error: no such file or directory",
2187            "model not found",
2188            "permission denied",
2189        ] {
2190            assert!(
2191                matches!(
2192                    classify(Agent::Codex, "codex", 1, stderr, "", &Terminal::default()),
2193                    Error::Failed { .. }
2194                ),
2195                "{stderr:?} should stay a plain failure"
2196            );
2197        }
2198    }
2199
2200    /// Real output from a failing codex run: the first line is status, the
2201    /// cause is below it. Reporting the first line looks like an explanation
2202    /// while pointing at the wrong thing.
2203    #[test]
2204    fn a_status_line_does_not_masquerade_as_the_cause() {
2205        let stderr = "Reading additional input from stdin...\n\
2206                      error: invalid value 'nope' for '--sandbox <SANDBOX_MODE>'";
2207        let err = classify_run(Agent::Codex, "codex", 1, stderr, "", &Terminal::default());
2208        let Error::Failed {
2209            stderr: reported, ..
2210        } = err
2211        else {
2212            panic!("expected Failed, got {err:?}")
2213        };
2214        assert!(reported.contains("invalid value"), "reported {reported:?}");
2215    }
2216
2217    /// Codex reports a rejected schema as a JSON error event on **stdout**
2218    /// while stderr carries only a status line. Reporting stderr alone
2219    /// described the failure as "Reading additional input from stdin...",
2220    /// which is not what went wrong.
2221    #[test]
2222    fn a_cause_on_stdout_is_reported_when_stderr_only_narrates() {
2223        let stdout = r#"{"type":"error","message":"invalid_json_schema: 'additionalProperties' is required to be supplied and to be false."}"#;
2224        let err = classify_run(
2225            Agent::Codex,
2226            "codex",
2227            1,
2228            "Reading additional input from stdin...",
2229            stdout,
2230            &Terminal::default(),
2231        );
2232        let Error::Failed {
2233            stderr: reported, ..
2234        } = err
2235        else {
2236            panic!("expected Failed, got {err:?}")
2237        };
2238        assert!(
2239            reported.contains("additionalProperties"),
2240            "reported {reported:?}, which explains nothing"
2241        );
2242    }
2243
2244    #[test]
2245    fn failures_report_the_first_useful_line() {
2246        let err = classify(
2247            Agent::Claude,
2248            "claude",
2249            2,
2250            "\n\n  real problem  \nstack",
2251            "",
2252            &Terminal::default(),
2253        );
2254        let Error::Failed { code, stderr, .. } = err else {
2255            panic!("expected a plain failure")
2256        };
2257        assert_eq!(code, 2);
2258        assert_eq!(stderr, "real problem");
2259    }
2260
2261    /// Prompts and session ids ride the argv, and `Run::argv` invites logging
2262    /// it. The redacted form must keep the shape while dropping the content.
2263    #[test]
2264    fn redaction_removes_prompts_and_session_ids_but_keeps_flags() {
2265        let request = crate::Request::new(Agent::Claude, "my secret prompt")
2266            .system("secret system")
2267            .session_id("11111111-2222-3333-4444-555555555555");
2268        let safe = redact(&request.typed_argv().unwrap());
2269
2270        for secret in [
2271            "my secret prompt",
2272            "secret system",
2273            "11111111-2222-3333-4444-555555555555",
2274        ] {
2275            assert!(
2276                !safe.iter().any(|a| a.contains(secret)),
2277                "{secret:?} survived redaction: {safe:?}"
2278            );
2279        }
2280        // Still recognisable as the same command.
2281        assert_eq!(safe[0], "claude");
2282        assert!(safe.contains(&"--permission-mode".to_string()));
2283        assert!(safe.contains(&"--session-id".to_string()));
2284    }
2285
2286    #[test]
2287    fn codex_trailing_prompt_is_redacted_even_without_a_flag() {
2288        let request = crate::Request::new(Agent::Codex, "my secret prompt");
2289        let safe = redact(&request.typed_argv().unwrap());
2290        assert_eq!(safe.last().unwrap(), REDACTED);
2291        assert_eq!(safe[1], "exec", "the subcommand must survive");
2292    }
2293
2294    /// Redaction must cover the two shapes positional guesswork misses: Codex's
2295    /// bare trailing prompt, and raw arguments whose contents are unknowable.
2296    #[test]
2297    fn redaction_covers_positional_prompts_and_unchecked_arguments() {
2298        let request = crate::Request::new(Agent::Codex, "my secret prompt")
2299            .unchecked_args(["-c", "api_key=hunter2"]);
2300        let safe = redact(&request.typed_argv().unwrap());
2301        assert!(!safe.iter().any(|a| a.contains("my secret prompt")));
2302        assert!(
2303            !safe.iter().any(|a| a.contains("hunter2")),
2304            "unchecked arguments may hold secrets: {safe:?}"
2305        );
2306        assert_eq!(safe[1], "exec", "the subcommand must survive");
2307    }
2308
2309    /// A resume id is a capability: it continues someone's conversation.
2310    #[test]
2311    fn redaction_covers_the_codex_positional_resume_id() {
2312        let request = crate::Request::new(Agent::Codex, "hi").resume("thread-secret-9");
2313        let safe = redact(&request.typed_argv().unwrap());
2314        assert!(
2315            !safe.iter().any(|a| a.contains("thread-secret-9")),
2316            "{safe:?}"
2317        );
2318        assert!(safe.contains(&"resume".to_string()));
2319    }
2320
2321    /// `stream` is synchronous but spawns a task. Outside a runtime that would
2322    /// panic, which a `Result`-returning function must not do.
2323    #[test]
2324    fn stream_outside_a_runtime_errors_instead_of_panicking() {
2325        let err = stream(&crate::Request::new(Agent::Claude, "hi")).unwrap_err();
2326        assert!(matches!(err, Error::NoRuntime), "got {err:?}");
2327    }
2328
2329    #[tokio::test]
2330    async fn a_missing_binary_names_the_install_command() {
2331        let request = Request::new(Agent::Claude, "hi").bin("definitely-not-a-real-binary-xyz");
2332        let err = run(&request).await.unwrap_err();
2333        let Error::NotInstalled { hint, agent, .. } = err else {
2334            panic!("expected NotInstalled, got {err:?}")
2335        };
2336        assert_eq!(agent, Agent::Claude);
2337        assert!(hint.contains("claude-code"));
2338    }
2339
2340    /// Regression for the `AgencyZero` hang: Codex can put its steer receipt
2341    /// behind more events than fit in the bounded channel. Input must wait on a
2342    /// handle independent from the mutable event receiver, or the producer and
2343    /// consumer block each other permanently.
2344    #[tokio::test]
2345    async fn a_control_receipt_can_wait_behind_a_full_event_buffer() {
2346        let (events_tx, events_rx) = mpsc::channel(EVENT_BUFFER);
2347        let (controls_tx, mut controls_rx) = mpsc::channel(1);
2348        let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel();
2349        let task = tokio::spawn(async move {
2350            let _cancel_rx = cancel_rx;
2351            let Some(Control::Message { receipt, .. }) = controls_rx.recv().await else {
2352                panic!("follow-up message")
2353            };
2354            for index in 0..=EVENT_BUFFER {
2355                events_tx
2356                    .send(Event::Thinking(index.to_string()))
2357                    .await
2358                    .expect("the host keeps draining events");
2359            }
2360            let _ = receipt.send(Ok(()));
2361            Ok(Outcome {
2362                agent: Agent::Codex,
2363                session: None,
2364                text: String::new(),
2365                usage: crate::Usage::default(),
2366                stop: Stop::Completed,
2367                rate_limit: None,
2368                exit_code: 0,
2369                stderr: String::new(),
2370                unparsed: 0,
2371                first_unparsed: None,
2372                structured: None,
2373            })
2374        });
2375        let reaped = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true));
2376        let mut run = Run {
2377            events: events_rx,
2378            control: RunControl {
2379                agent: Agent::Codex,
2380                to_agent: Some(controls_tx),
2381                bin: "codex".into(),
2382            },
2383            typed: Vec::new(),
2384            pid: None,
2385            reaped,
2386            cancel: Some(cancel_tx),
2387            task: Some(task),
2388            argv: vec!["codex".into()],
2389        };
2390
2391        let control = run.control();
2392        let delivery = tokio::spawn(async move { control.send("change course").await });
2393        let mut seen = 0;
2394        while run.recv().await.is_some() {
2395            seen += 1;
2396        }
2397
2398        assert_eq!(seen, EVENT_BUFFER + 1);
2399        delivery
2400            .await
2401            .expect("delivery task")
2402            .expect("transport receipt");
2403        run.finish().await.expect("run outcome");
2404    }
2405
2406    #[test]
2407    fn transient_errors_are_distinguished_from_permanent_ones() {
2408        assert!(
2409            Error::RateLimited {
2410                bin: "claude".into(),
2411                message: String::new()
2412            }
2413            .is_transient()
2414        );
2415        assert!(
2416            !Error::NotInstalled {
2417                agent: Agent::Claude,
2418                bin: "claude".into(),
2419                hint: ""
2420            }
2421            .is_transient()
2422        );
2423    }
2424}