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::process::Stdio;
13
14use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader};
15use tokio::process::{Child, Command};
16use tokio::sync::mpsc;
17
18use crate::agent::{Continue, EnvPolicy};
19use crate::error::{Error, Result};
20use crate::event::{Event, MAX_LINE, Parser, Terminal, append_capped};
21use crate::outcome::{Outcome, Stop};
22use crate::proc::{kill_group_by_pid, kill_process_group};
23use crate::request::Request;
24
25/// Read one line, giving up on a line that never ends.
26///
27/// `AsyncBufReadExt::lines` buffers until a newline arrives, so a stream that
28/// emits megabytes without one exhausts memory before any total cap applies.
29/// This reads a bounded amount and, past the limit, returns what it has and
30/// discards the remainder of that line. Returns `None` at end of input.
31async fn read_bounded_line<R>(reader: &mut R, buf: &mut String) -> std::io::Result<Option<bool>>
32where
33 R: tokio::io::AsyncBufRead + Unpin,
34{
35 buf.clear();
36 let mut bytes = Vec::new();
37 let mut truncated = false;
38 loop {
39 let mut byte = [0u8; 1];
40 match reader.read(&mut byte).await? {
41 // End of input: a trailing fragment still counts as a line.
42 0 => {
43 if bytes.is_empty() {
44 return Ok(None);
45 }
46 break;
47 }
48 _ if byte[0] == b'\n' => break,
49 _ => {
50 if bytes.len() < MAX_LINE {
51 bytes.push(byte[0]);
52 } else {
53 // Keep draining to the newline so the pipe does not block,
54 // but stop accumulating.
55 truncated = true;
56 }
57 }
58 }
59 }
60 // Output is not guaranteed to be valid UTF-8, and one bad byte should not
61 // end a run.
62 buf.push_str(&String::from_utf8_lossy(&bytes));
63 Ok(Some(truncated))
64}
65
66/// How many events may queue before the producer waits for the consumer. Deep
67/// enough that a burst of tool events does not stall the agent, shallow enough
68/// that a consumer which stops reading does not grow without bound.
69const EVENT_BUFFER: usize = 256;
70
71/// A run in progress.
72///
73/// Yields events through [`Run::recv`] and settles into an [`Outcome`] through
74/// [`Run::finish`].
75///
76/// **Dropping a `Run` kills the agent.** That is the safe default for the hosts
77/// this crate targets: closing a window or cancelling a request should stop the
78/// work, not leave an agent running invisibly, spending quota and touching
79/// files with nobody watching. Call [`Run::detach`] when background execution is
80/// genuinely what you want.
81///
82/// On Unix, dropping **synchronously signals** the run's process group and then
83/// aborts the driver task. What it cannot do is *wait*: `Drop` cannot await, so
84/// it does not block until the child has exited or its readers have been
85/// joined. Use [`Run::cancel`] when you need to know the tree has actually gone
86/// before continuing, such as before touching the files it was working on. On
87/// Windows only the direct child is signalled.
88#[derive(Debug)]
89pub struct Run {
90 events: mpsc::Receiver<Event>,
91 /// The typed command line, kept so both the plain and redacted views come
92 /// from the same source.
93 typed: Vec<crate::agent::Arg>,
94 /// The child's pid, so `Drop` can tear the group down itself rather than
95 /// depending on an aborted task being polled.
96 pid: Option<u32>,
97 /// Set by the driver once the child has been reaped, so `Drop` never
98 /// signals a pid the OS may since have handed to someone else.
99 reaped: std::sync::Arc<std::sync::atomic::AtomicBool>,
100 /// Dropping or firing this asks the driver to tear down in order. Held as
101 /// an `Option` so `detach` can discard it without signalling.
102 cancel: Option<tokio::sync::oneshot::Sender<()>>,
103 /// `None` only after [`Run::finish`], [`Run::cancel`] or [`Run::detach`]
104 /// has taken ownership, which is what stops `Drop` from aborting a run that
105 /// was already settled deliberately.
106 task: Option<tokio::task::JoinHandle<Result<Outcome>>>,
107 argv: Vec<String>,
108}
109
110impl Run {
111 /// The next event, or `None` once the agent has finished producing them.
112 pub async fn recv(&mut self) -> Option<Event> {
113 self.events.recv().await
114 }
115
116 /// The exact command line that was spawned.
117 ///
118 /// **This contains the prompt and any session id.** Treat it as sensitive:
119 /// logging it verbatim puts user content into your logs. Use
120 /// [`Run::redacted_argv`] for diagnostics.
121 #[must_use]
122 pub fn argv(&self) -> &[String] {
123 &self.argv
124 }
125
126 /// The command line with every non-public value replaced by a placeholder.
127 ///
128 /// Prompts, system prompts, session ids and anything from
129 /// [`crate::Request::unchecked_args`] are removed; flag names are kept so
130 /// the command stays recognisable. Sensitivity is recorded where each
131 /// argument is built rather than inferred from the finished line, so a
132 /// bare positional prompt or an opaque raw argument is covered too.
133 #[must_use]
134 pub fn redacted_argv(&self) -> Vec<String> {
135 redact(&self.typed)
136 }
137
138 /// Wait for the run to finish.
139 ///
140 /// Drains any events still queued, so a caller that only wants the result
141 /// can call this without having consumed the stream.
142 ///
143 /// # Errors
144 /// Whatever the run failed with. See [`Error`].
145 pub async fn finish(mut self) -> Result<Outcome> {
146 // The driver owns teardown from here; `Drop` must not also fire.
147 self.pid = None;
148 while self.events.recv().await.is_some() {}
149 // Taking the handle disarms the `Drop` guard: this run is settling
150 // normally, not being abandoned.
151 let Some(task) = self.task.take() else {
152 unreachable!("the handle is only taken by a consuming method")
153 };
154 match task.await {
155 Ok(result) => result,
156 // The driver task panicked or was cancelled. The process itself
157 // started fine, so this is not a spawn failure and must not claim
158 // to be one.
159 Err(join) => Err(Error::Interrupted {
160 bin: self.argv.first().cloned().unwrap_or_default(),
161 detail: if join.is_panic() {
162 "the driver task panicked".into()
163 } else {
164 "the driver task was cancelled".into()
165 },
166 }),
167 }
168 }
169
170 /// Stop the run and wait until the agent is actually gone.
171 ///
172 /// Cooperative rather than an abort: the driver is asked to stop, signals
173 /// the process group, reaps the child and joins its readers, and only then
174 /// does this return. So when it returns the tree really has exited, which
175 /// matters if the next thing you do touches the files it was working on.
176 ///
177 /// Returns the partial [`Outcome`] if the run happened to finish first,
178 /// otherwise [`Error::Cancelled`].
179 ///
180 /// # Errors
181 /// [`Error::Cancelled`] in the normal case, or whatever the run failed with
182 /// if it failed before the request arrived.
183 pub async fn cancel(mut self) -> Result<Outcome> {
184 // The driver tears down cooperatively and this awaits it, so `Drop`
185 // must not race that with a kill of its own.
186 self.pid = None;
187 // Dropping the sender is itself the signal, so this cannot fail in a
188 // way that leaves the driver waiting.
189 drop(self.cancel.take());
190 let Some(task) = self.task.take() else {
191 unreachable!("the handle is only taken by a consuming method")
192 };
193 match task.await {
194 Ok(result) => result,
195 Err(join) => Err(Error::Interrupted {
196 bin: self.argv.first().cloned().unwrap_or_default(),
197 detail: if join.is_panic() {
198 "the driver task panicked".into()
199 } else {
200 "the driver task was cancelled".into()
201 },
202 }),
203 }
204 }
205
206 /// Let the run continue after this handle goes away.
207 ///
208 /// The opposite of the default. Nothing can observe or stop the agent
209 /// afterwards, so reach for this only when an unsupervised background run
210 /// is genuinely intended.
211 pub fn detach(mut self) {
212 // Disarm `Drop` before it runs, or detaching would immediately kill the
213 // run it exists to keep alive.
214 self.pid = None;
215 // Leak the cancel signal rather than dropping it: a dropped sender is
216 // read by the driver as "stop", which is the opposite of detaching.
217 if let Some(cancel) = self.cancel.take() {
218 std::mem::forget(cancel);
219 }
220 // Dropping the handle without aborting is what detaches a tokio task.
221 drop(self.task.take());
222 }
223}
224
225impl Drop for Run {
226 fn drop(&mut self) {
227 // Abandoned rather than finished, cancelled or detached.
228 //
229 // Kill the group here, directly. Signalling the driver and aborting it
230 // is not enough on its own: that leaves teardown waiting on the runtime
231 // to poll the aborted task so its guard runs, and a dropped `Run` was
232 // observed leaving grandchildren alive and sleeping on Linux while the
233 // same teardown worked from `cancel`. `Drop` cannot await, so it does
234 // the one thing it can do synchronously.
235 if let Some(pid) = self.pid
236 && !self.reaped.load(std::sync::atomic::Ordering::SeqCst)
237 {
238 kill_group_by_pid(pid);
239 }
240 drop(self.cancel.take());
241 if let Some(task) = self.task.take() {
242 task.abort();
243 }
244 }
245}
246
247/// Placeholder substituted for a sensitive argv value.
248const REDACTED: &str = "<redacted>";
249
250/// Render a typed command line for logging, keeping flag names and replacing
251/// every value that is not `Public`.
252///
253/// Derived from the sensitivity recorded where each argument was built, so it
254/// cannot miss a case the way matching on flag names and positions can.
255fn redact(argv: &[crate::agent::Arg]) -> Vec<String> {
256 use crate::agent::Sensitivity;
257
258 argv.iter()
259 .map(|arg| match arg.sensitivity {
260 Sensitivity::Public => arg.value.clone(),
261 _ => REDACTED.to_string(),
262 })
263 .collect()
264}
265
266/// Run `request` to completion, discarding the intermediate events.
267///
268/// # Errors
269/// See [`Error`]; notably [`Error::NotInstalled`], [`Error::Timeout`],
270/// [`Error::RateLimited`] and [`Error::Failed`].
271pub async fn run(request: &Request) -> Result<Outcome> {
272 stream(request)?.finish().await
273}
274
275/// Start `request`, returning a handle that streams its events.
276///
277/// Returns as soon as the child is spawned; the work proceeds on a task.
278///
279/// # Errors
280/// [`Error::NotInstalled`] if the binary is missing, [`Error::Unsupported`] if
281/// the agent cannot honour the request, or [`Error::Spawn`] on an OS failure.
282pub fn stream(request: &Request) -> Result<Run> {
283 // `tokio::spawn` panics outside a runtime. A fallible signature must not
284 // hide that, so the context is checked and reported as an ordinary error.
285 let runtime = tokio::runtime::Handle::try_current().map_err(|_| Error::NoRuntime)?;
286
287 let plan = request.plan();
288 let typed = request.typed_argv()?;
289 let argv: Vec<String> = typed.iter().map(|a| a.value.clone()).collect();
290
291 let mut command = Command::new(&argv[0]);
292 command
293 .args(&argv[1..])
294 .stdin(if plan.stdin_prompt {
295 Stdio::piped()
296 } else {
297 // Close stdin so an agent that would otherwise wait on it exits
298 // instead of hanging forever with nothing to read.
299 Stdio::null()
300 })
301 .stdout(Stdio::piped())
302 .stderr(Stdio::piped())
303 // Without this a killed run can leave the child alive holding the pipes.
304 .kill_on_drop(true);
305 if let Some(cwd) = &request.cwd {
306 command.current_dir(cwd);
307 }
308 // Narrow the environment first, then apply explicit variables, so an
309 // explicit `env()` always wins over the policy.
310 match &request.env_policy {
311 EnvPolicy::Inherit => {}
312 EnvPolicy::Minimal => {
313 command.env_clear();
314 inherit_named(&mut command, &request.agent.essential_env());
315 }
316 EnvPolicy::Only(names) => {
317 command.env_clear();
318 inherit_named(&mut command, names);
319 }
320 }
321 for (key, value) in &request.env {
322 command.env(key, value);
323 }
324
325 // Put the agent in its own process group so the whole tree can be signalled
326 // together. Killing only the CLI leaves the commands *it* spawned running:
327 // a build, a test run, a server, still holding files and credentials after
328 // the run is supposedly over.
329 // 0 means "make this child its own group leader". `tokio::process::Command`
330 // exposes this directly on unix.
331 #[cfg(unix)]
332 command.process_group(0);
333
334 // Reserve an assigned session id before the child exists. Doing it inside
335 // the driver leaves a window where a spawn that half-succeeds loses the
336 // binding, and this is the id the caller may already be showing in a UI.
337 if let Some(token) = preassigned_token(request) {
338 persist_session(request, &token)?;
339 }
340
341 let child = command.spawn().map_err(|source| {
342 // A missing binary is the common case and deserves an actionable error
343 // with an install hint. Reading it off the spawn avoids resolving PATH
344 // twice, and with it the window where the resolved path is replaced
345 // between the check and the exec.
346 if source.kind() == std::io::ErrorKind::NotFound {
347 Error::NotInstalled {
348 agent: request.agent,
349 bin: plan.bin.clone(),
350 hint: request.agent.install_hint(),
351 }
352 } else {
353 Error::Spawn {
354 bin: plan.bin.clone(),
355 source,
356 }
357 }
358 })?;
359
360 let pid = child.id();
361 let (tx, rx) = mpsc::channel(EVENT_BUFFER);
362 let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel();
363 let reaped = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
364 let request = request.clone();
365 let task = runtime.spawn(drive(
366 child,
367 request,
368 tx,
369 cancel_rx,
370 std::sync::Arc::clone(&reaped),
371 ));
372 Ok(Run {
373 events: rx,
374 typed,
375 pid,
376 reaped,
377 cancel: Some(cancel_tx),
378 task: Some(task),
379 argv,
380 })
381}
382
383/// Copy the named variables from this process into `command`, skipping any that
384/// are unset so nothing is invented.
385fn inherit_named<S: AsRef<str>>(command: &mut Command, names: &[S]) {
386 for name in names {
387 if let Some(value) = std::env::var_os(name.as_ref()) {
388 command.env(name.as_ref(), value);
389 }
390 }
391}
392
393/// Owns the child and tears down its whole process group when dropped.
394///
395/// `kill_on_drop` alone is not enough: it kills the CLI, leaving the commands
396/// *it* spawned running. Since aborting the driver task drops this guard, the
397/// same teardown covers cancellation, a dropped [`Run`] and a timeout, without
398/// each path having to remember to do it.
399struct ChildGuard {
400 child: Child,
401 /// Cleared once the child has been reaped, so a pid the OS may since have
402 /// recycled is never signalled.
403 armed: bool,
404}
405
406impl Drop for ChildGuard {
407 fn drop(&mut self) {
408 if self.armed {
409 kill_process_group(&self.child);
410 }
411 }
412}
413
414/// Feed the child, read both its pipes, and assemble the outcome.
415#[allow(
416 clippy::too_many_lines,
417 reason = "one linear lifecycle: feed, read, wait, classify. Splitting it \
418 would thread the child, parser, buffers and cancellation state \
419 through helpers and obscure the ordering that matters, such as \
420 killing the group before reaping."
421)]
422async fn drive(
423 child: Child,
424 request: Request,
425 events: mpsc::Sender<Event>,
426 cancel: tokio::sync::oneshot::Receiver<()>,
427 reaped: std::sync::Arc<std::sync::atomic::AtomicBool>,
428) -> Result<Outcome> {
429 // From here on the child is owned by a guard, so every exit path from this
430 // task, including an abort, takes the process group with it.
431 let mut child = ChildGuard { child, armed: true };
432 let plan = request.plan();
433 let bin = plan.bin.clone();
434
435 // Deliver a piped prompt and close the pipe, or the agent waits on EOF.
436 if plan.stdin_prompt {
437 if let Some(mut stdin) = child.child.stdin.take() {
438 let prompt = request.agent.effective_prompt(&plan);
439 stdin
440 .write_all(prompt.as_bytes())
441 .await
442 .map_err(|source| Error::Spawn {
443 bin: bin.clone(),
444 source,
445 })?;
446 drop(stdin);
447 }
448 }
449
450 // Drain stderr on its own task: a full stderr pipe blocks the child even
451 // while stdout still has room.
452 let stderr = child.child.stderr.take();
453 let stderr_task = tokio::spawn(async move {
454 let mut buf = String::new();
455 if let Some(handle) = stderr {
456 let mut reader = BufReader::new(handle);
457 let mut line = String::new();
458 // Keep draining after the cap is hit: an undrained pipe blocks the
459 // child even though we no longer want the bytes.
460 while let Ok(Some(_)) = read_bounded_line(&mut reader, &mut line).await {
461 append_capped(&mut buf, &line);
462 }
463 }
464 buf
465 });
466
467 let stdout = child.child.stdout.take();
468 let mut parser = Parser::new(request.agent, plan.format);
469 // Raw stdout is retained only as a fallback answer for a run that exited
470 // cleanly without producing a structured one, and as evidence when
471 // classifying a failure. It is capped for the same reason as everything
472 // else here: an agent can stream for hours.
473 let mut raw = String::new();
474 // Tracks the first `Started`, so the binding is written once, and carries a
475 // store failure back out instead of discarding it.
476 let mut bound = false;
477 let mut persist_result: Result<()> = Ok(());
478
479 let read_stdout = async {
480 if let Some(handle) = stdout {
481 let mut reader = BufReader::new(handle);
482 let mut line = String::new();
483 while read_bounded_line(&mut reader, &mut line).await?.is_some() {
484 append_capped(&mut raw, &line);
485 for event in parser.push(&line) {
486 // Bind a printed id the moment it appears rather than at the
487 // end. Codex announces its thread before answering, so a
488 // turn killed mid-answer stays resumable.
489 if let Event::Started { session, .. } = &event
490 && !bound
491 {
492 bound = true;
493 persist_result = persist_session(&request, session);
494 }
495 // A receiver that went away is not a failure: the run should
496 // still finish and produce its outcome.
497 if events.send(event).await.is_err() {
498 break;
499 }
500 }
501 }
502 }
503 Ok::<_, std::io::Error>(())
504 };
505
506 // Race three outcomes: the run finishing, the deadline, and a cancellation
507 // request. Reading and waiting are one future so a child that produces
508 // output forever is still bounded by the timeout.
509 let work = async {
510 read_stdout.await?;
511 child.child.wait().await
512 };
513 // A timeout is optional; `pending()` makes the un-timed case the same shape
514 // rather than duplicating the whole select.
515 let deadline = async {
516 match request.timeout {
517 Some(limit) => tokio::time::sleep(limit).await,
518 None => std::future::pending().await,
519 }
520 };
521
522 let status = tokio::select! {
523 // Biased so a finished run is reported as finished even if a deadline
524 // or cancellation lands in the same tick.
525 biased;
526 result = work => result,
527 () = deadline => {
528 // Order matters: signal the group *before* reaping. Reaping clears
529 // the child's pid, and the group kill needs that pid to target the
530 // group, so the other order silently leaves grandchildren running.
531 let partial = shut_down(&mut child, stderr_task).await;
532 reaped.store(true, std::sync::atomic::Ordering::SeqCst);
533 return Err(Error::Timeout {
534 bin,
535 timeout: request.timeout.unwrap_or_default(),
536 partial: parser.finish().text,
537 })
538 .inspect_err(|_| drop(partial));
539 }
540 _ = cancel => {
541 // Cooperative teardown: the caller is waiting on this, so the tree
542 // is signalled, reaped and joined before returning.
543 shut_down(&mut child, stderr_task).await;
544 reaped.store(true, std::sync::atomic::Ordering::SeqCst);
545 return Err(Error::Cancelled { bin });
546 }
547 }
548 .map_err(|source| Error::Spawn {
549 bin: bin.clone(),
550 source,
551 })?;
552
553 // The child has been reaped, so its pid must not be signalled again, by the
554 // guard here or by `Run::drop` racing this.
555 child.armed = false;
556 reaped.store(true, std::sync::atomic::Ordering::SeqCst);
557
558 drop(events);
559 let stderr = stderr_task.await.unwrap_or_default();
560 let saw_structured = parser.saw_structured_record();
561 let saw_terminal = parser.saw_terminal_record();
562 let terminal = parser.finish();
563 let exit_code = status.code().unwrap_or(-1);
564
565 // Under a structured format, silently handing back raw stdout would turn a
566 // protocol failure into a plausible-looking answer. A run that recognized
567 // nothing, or never reached its terminal record, did not produce a result
568 // this crate can vouch for, so it is reported rather than papered over.
569 let structured = plan.format != crate::Format::Text;
570 if structured && exit_code == 0 {
571 if !saw_structured {
572 return Err(Error::Parse {
573 agent: request.agent,
574 detail: format!(
575 "no recognizable {} records in {} lines of output; the CLI's output shape has probably changed",
576 request.agent,
577 raw.lines().count()
578 ),
579 });
580 }
581 if !saw_terminal {
582 return Err(Error::Parse {
583 agent: request.agent,
584 detail: "the stream ended without its terminal record, so the turn did not complete"
585 .into(),
586 });
587 }
588 }
589
590 // Plain text has no structure to validate: the stream is the answer.
591 let mut terminal = terminal;
592 if terminal.text.is_empty() && !structured {
593 terminal.text = raw.trim().to_string();
594 }
595
596 // A provider refusal is not always an exit code. Claude can report a
597 // blocking `rate_limit_event` and still exit 0, and the crate promises that
598 // quota refusals surface as `Error::RateLimited`, so the terminal state is
599 // checked regardless of how the process exited.
600 let quota_blocked = terminal
601 .rate_limit
602 .as_ref()
603 .is_some_and(crate::outcome::RateLimit::is_blocking);
604 // An unauthenticated Claude run exits 0 and reports the problem in its
605 // result text, so checking only the exit code would hand back a successful
606 // Outcome whose answer is "Please run /login".
607 let unauthenticated = looks_unauthenticated(&terminal.text);
608 if exit_code != 0 || quota_blocked || unauthenticated {
609 return Err(classify_run(
610 request.agent,
611 &bin,
612 exit_code,
613 &stderr,
614 &raw,
615 &terminal,
616 ));
617 }
618
619 // A fork lands on a *new* id the agent only reveals at the end, so the name
620 // has to be repointed once the run settles. Everything else was bound above.
621 persist_result?;
622 if let Some(token) = &terminal.session
623 && !bound
624 {
625 persist_session(&request, token)?;
626 }
627 Ok(Outcome {
628 agent: request.agent,
629 session: terminal.session,
630 text: terminal.text,
631 usage: terminal.usage,
632 stop: terminal.stop,
633 rate_limit: terminal.rate_limit,
634 exit_code,
635 stderr,
636 unparsed: terminal.unparsed,
637 first_unparsed: terminal.first_unparsed,
638 })
639}
640
641/// Kill the process group, reap the child, and join the stderr reader.
642///
643/// The orderly teardown both cancellation and timeout share. Returns whatever
644/// stderr had been captured, so a caller can still report why a run was stopped.
645async fn shut_down(child: &mut ChildGuard, stderr_task: tokio::task::JoinHandle<String>) -> String {
646 kill_process_group(&child.child);
647 // Reap, so the caller is not left with a zombie once this returns.
648 let _ = child.child.kill().await;
649 child.armed = false;
650 // The pipes are closed now that the child is gone, so this finishes
651 // promptly rather than hanging the cancellation.
652 stderr_task.await.unwrap_or_default()
653}
654
655/// Turn a failure into the most specific error available, agent included so an
656/// auth failure can carry the right login command.
657fn classify_run(
658 agent: crate::Agent,
659 bin: &str,
660 code: i32,
661 stderr: &str,
662 stdout: &str,
663 terminal: &Terminal,
664) -> Error {
665 // Checked before quota and before a plain failure: a login problem is the
666 // most specific reading of the output, and the only one a user can act on
667 // directly.
668 for source in [terminal.text.as_str(), stderr, stdout] {
669 if looks_unauthenticated(source) {
670 return Error::NotAuthenticated {
671 agent,
672 bin: bin.to_string(),
673 message: first_meaningful_line(source).unwrap_or_default(),
674 hint: agent.login_hint(),
675 };
676 }
677 }
678 classify(bin, code, stderr, stdout, terminal)
679}
680
681/// Whether text is an agent saying it has no usable credentials.
682///
683/// Narrow on purpose. Mislabelling an ordinary failure as an auth problem sends
684/// someone to re-login over something unrelated, so these are phrases the CLIs
685/// actually emit rather than every string containing "auth".
686fn looks_unauthenticated(text: &str) -> bool {
687 const PHRASES: &[&str] = &[
688 // Claude, verified: an unauthenticated run answers exactly this.
689 "not logged in",
690 "please run /login",
691 "invalid api key",
692 "authentication_error",
693 "unauthorized",
694 "not authenticated",
695 "no credentials",
696 "credentials not found",
697 "please log in",
698 "401",
699 ];
700 let lower = text.to_ascii_lowercase();
701 PHRASES.iter().any(|needle| lower.contains(needle))
702}
703
704/// Turn a non-zero exit into the most specific error available.
705fn classify(bin: &str, code: i32, stderr: &str, stdout: &str, terminal: &Terminal) -> Error {
706 let quota_signalled = terminal
707 .rate_limit
708 .as_ref()
709 .is_some_and(crate::outcome::RateLimit::is_blocking);
710 if quota_signalled || looks_rate_limited(stderr) || looks_rate_limited(stdout) {
711 return Error::RateLimited {
712 bin: bin.to_string(),
713 message: first_meaningful_line(stderr)
714 .or_else(|| first_meaningful_line(stdout))
715 .unwrap_or_else(|| "usage limit reached".to_string()),
716 };
717 }
718 // A rejected flag is not a failed request, it is this crate and the CLI
719 // disagreeing about what the CLI accepts. Naming that is the difference
720 // between "the run failed" and "your codex is a different version".
721 if let Some(detail) = rejected_flag(stderr).or_else(|| rejected_flag(stdout)) {
722 return Error::FlagRejected {
723 bin: bin.to_string(),
724 detail,
725 };
726 }
727 Error::Failed {
728 bin: bin.to_string(),
729 code,
730 stderr: first_meaningful_line(stderr).unwrap_or_default(),
731 }
732}
733
734/// The CLI's complaint, if it refused an argument.
735///
736/// The phrasings are clap's and commander's, which is what all three CLIs are
737/// built on. Matched narrowly: a false positive would relabel a genuine failure
738/// as a version problem and send someone chasing the wrong thing.
739fn rejected_flag(text: &str) -> Option<String> {
740 const REJECTIONS: &[&str] = &[
741 "unexpected argument",
742 "unknown option",
743 "unrecognized option",
744 "unknown flag",
745 "invalid option",
746 "unexpected option",
747 ];
748 let lower = text.to_ascii_lowercase();
749 REJECTIONS
750 .iter()
751 .any(|needle| lower.contains(needle))
752 .then(|| first_meaningful_line(text).unwrap_or_default())
753}
754
755/// Whether text carries a provider quota refusal.
756///
757/// Deliberately a small set of unambiguous phrases: a false positive here would
758/// relabel an ordinary failure as a quota problem and send a caller into a
759/// pointless backoff.
760fn looks_rate_limited(text: &str) -> bool {
761 let lower = text.to_ascii_lowercase();
762 [
763 "rate limit",
764 "rate_limit",
765 "usage limit",
766 "quota exceeded",
767 "too many requests",
768 "429",
769 ]
770 .iter()
771 .any(|needle| lower.contains(needle))
772}
773
774/// The first non-blank line, trimmed. Enough to identify a failure without
775/// pasting an entire stack trace into an error message.
776fn first_meaningful_line(text: &str) -> Option<String> {
777 text.lines()
778 .map(str::trim)
779 .find(|line| !line.is_empty())
780 .map(str::to_string)
781}
782
783/// Write the session binding back, reporting any store failure.
784///
785/// Called as soon as an id is known rather than only on a clean exit. Waiting
786/// for success would lose the binding for exactly the runs where continuity
787/// matters most: a timeout, a crash, or a cancelled turn.
788fn persist_session(request: &Request, token: &str) -> Result<()> {
789 let Some(binding) = &request.binding else {
790 return Ok(());
791 };
792 binding
793 .store
794 .bind(request.agent, &binding.project, &binding.name, token)
795 .map(|_| ())
796}
797
798/// The id this run is already known by before it starts, if any.
799///
800/// Only a caller-assigned id qualifies: a printed id does not exist yet. This
801/// is what makes an assigned session survive a run that never finishes.
802fn preassigned_token(request: &Request) -> Option<String> {
803 match &request.plan().cont {
804 Continue::NewWith(id) => Some(id.clone()),
805 _ => None,
806 }
807}
808
809/// Reported by an agent that exited cleanly but said nothing useful.
810impl Outcome {
811 /// Whether the agent produced any answer at all.
812 #[must_use]
813 pub fn is_empty(&self) -> bool {
814 self.text.trim().is_empty() && self.stop == Stop::Completed
815 }
816}
817
818#[cfg(test)]
819mod tests {
820 use super::*;
821 use crate::agent::Agent;
822
823 #[test]
824 fn quota_phrases_are_recognized_and_ordinary_errors_are_not() {
825 assert!(looks_rate_limited("Error: rate limit exceeded"));
826 assert!(looks_rate_limited("HTTP 429 Too Many Requests"));
827 assert!(looks_rate_limited("You have hit your usage limit"));
828 // A plain failure must not be mistaken for a quota problem.
829 assert!(!looks_rate_limited("error: no such file or directory"));
830 assert!(!looks_rate_limited("model not found"));
831 }
832
833 #[test]
834 fn a_blocking_rate_limit_event_classifies_as_rate_limited() {
835 let terminal = Terminal {
836 rate_limit: Some(crate::outcome::RateLimit {
837 status: "rejected".into(),
838 window: Some("five_hour".into()),
839 resets_at: None,
840 }),
841 ..Terminal::default()
842 };
843 assert!(matches!(
844 classify("claude", 1, "", "", &terminal),
845 Error::RateLimited { .. }
846 ));
847 }
848
849 #[test]
850 fn an_allowed_rate_limit_event_is_not_a_failure_cause() {
851 let terminal = Terminal {
852 rate_limit: Some(crate::outcome::RateLimit {
853 status: "allowed".into(),
854 window: None,
855 resets_at: None,
856 }),
857 ..Terminal::default()
858 };
859 assert!(matches!(
860 classify("claude", 1, "boom", "", &terminal),
861 Error::Failed { .. }
862 ));
863 }
864
865 /// Verified against the real CLI: with `USER` withheld, claude answers
866 /// "Not logged in · Please run /login" and exits **0**. Checking only the
867 /// exit code hands back a successful Outcome whose answer is a login
868 /// prompt.
869 #[test]
870 fn an_unauthenticated_run_is_named_even_though_it_exits_zero() {
871 let terminal = Terminal {
872 text: "Not logged in · Please run /login".into(),
873 ..Terminal::default()
874 };
875 let err = classify_run(Agent::Claude, "claude", 0, "", "", &terminal);
876 let Error::NotAuthenticated { agent, hint, .. } = &err else {
877 panic!("expected NotAuthenticated, got {err:?}")
878 };
879 assert_eq!(*agent, Agent::Claude);
880 assert!(hint.contains("/login"), "{hint}");
881 assert!(err.is_auth_failure());
882 }
883
884 /// Each agent's hint has to name its own login route, since they differ:
885 /// Codex and Copilot have `login` subcommands, Claude does not.
886 #[test]
887 fn every_agent_offers_its_own_login_route() {
888 for (agent, expected) in [
889 (Agent::Claude, "setup-token"),
890 (Agent::Codex, "codex login"),
891 (Agent::Copilot, "copilot login"),
892 ] {
893 let err = classify_run(
894 agent,
895 agent.bin(),
896 1,
897 "error: unauthorized",
898 "",
899 &Terminal::default(),
900 );
901 let Error::NotAuthenticated { hint, .. } = &err else {
902 panic!("{agent}: expected NotAuthenticated, got {err:?}")
903 };
904 assert!(hint.contains(expected), "{agent}: {hint}");
905 }
906 }
907
908 /// Auth is the most specific reading, so it wins over a generic failure,
909 /// but must not swallow unrelated errors.
910 #[test]
911 fn ordinary_failures_are_not_mistaken_for_auth_problems() {
912 for stderr in [
913 "error: no such file or directory",
914 "model not found",
915 "rate limit exceeded",
916 "error: unexpected argument '--sandbox' found",
917 ] {
918 let err = classify_run(Agent::Codex, "codex", 1, stderr, "", &Terminal::default());
919 assert!(
920 !err.is_auth_failure(),
921 "{stderr:?} was misread as an auth failure: {err:?}"
922 );
923 }
924 }
925
926 /// The exact failure that cost a round of debugging: `codex exec resume`
927 /// rejects `--sandbox`, which `Error::Failed` reported as a generic
928 /// non-zero exit naming a flag rather than a version mismatch.
929 #[test]
930 fn a_rejected_flag_is_named_as_a_version_mismatch() {
931 let err = classify(
932 "codex",
933 2,
934 "error: unexpected argument '--sandbox' found",
935 "",
936 &Terminal::default(),
937 );
938 let Error::FlagRejected { bin, detail } = err else {
939 panic!("expected FlagRejected, got {err:?}")
940 };
941 assert_eq!(bin, "codex");
942 assert!(detail.contains("--sandbox"), "{detail}");
943 }
944
945 #[test]
946 fn ordinary_failures_are_not_mistaken_for_version_drift() {
947 for stderr in [
948 "error: no such file or directory",
949 "model not found",
950 "permission denied",
951 ] {
952 assert!(
953 matches!(
954 classify("codex", 1, stderr, "", &Terminal::default()),
955 Error::Failed { .. }
956 ),
957 "{stderr:?} should stay a plain failure"
958 );
959 }
960 }
961
962 #[test]
963 fn failures_report_the_first_useful_line() {
964 let err = classify(
965 "claude",
966 2,
967 "\n\n real problem \nstack",
968 "",
969 &Terminal::default(),
970 );
971 let Error::Failed { code, stderr, .. } = err else {
972 panic!("expected a plain failure")
973 };
974 assert_eq!(code, 2);
975 assert_eq!(stderr, "real problem");
976 }
977
978 /// Prompts and session ids ride the argv, and `Run::argv` invites logging
979 /// it. The redacted form must keep the shape while dropping the content.
980 #[test]
981 fn redaction_removes_prompts_and_session_ids_but_keeps_flags() {
982 let request = crate::Request::new(Agent::Claude, "my secret prompt")
983 .system("secret system")
984 .session_id("11111111-2222-3333-4444-555555555555");
985 let safe = redact(&request.typed_argv().unwrap());
986
987 for secret in [
988 "my secret prompt",
989 "secret system",
990 "11111111-2222-3333-4444-555555555555",
991 ] {
992 assert!(
993 !safe.iter().any(|a| a.contains(secret)),
994 "{secret:?} survived redaction: {safe:?}"
995 );
996 }
997 // Still recognisable as the same command.
998 assert_eq!(safe[0], "claude");
999 assert!(safe.contains(&"--permission-mode".to_string()));
1000 assert!(safe.contains(&"--session-id".to_string()));
1001 }
1002
1003 #[test]
1004 fn codex_trailing_prompt_is_redacted_even_without_a_flag() {
1005 let request = crate::Request::new(Agent::Codex, "my secret prompt");
1006 let safe = redact(&request.typed_argv().unwrap());
1007 assert_eq!(safe.last().unwrap(), REDACTED);
1008 assert_eq!(safe[1], "exec", "the subcommand must survive");
1009 }
1010
1011 /// Redaction must cover the two shapes positional guesswork misses: Codex's
1012 /// bare trailing prompt, and raw arguments whose contents are unknowable.
1013 #[test]
1014 fn redaction_covers_positional_prompts_and_unchecked_arguments() {
1015 let request = crate::Request::new(Agent::Codex, "my secret prompt")
1016 .unchecked_args(["-c", "api_key=hunter2"]);
1017 let safe = redact(&request.typed_argv().unwrap());
1018 assert!(!safe.iter().any(|a| a.contains("my secret prompt")));
1019 assert!(
1020 !safe.iter().any(|a| a.contains("hunter2")),
1021 "unchecked arguments may hold secrets: {safe:?}"
1022 );
1023 assert_eq!(safe[1], "exec", "the subcommand must survive");
1024 }
1025
1026 /// A resume id is a capability: it continues someone's conversation.
1027 #[test]
1028 fn redaction_covers_the_codex_positional_resume_id() {
1029 let request = crate::Request::new(Agent::Codex, "hi").resume("thread-secret-9");
1030 let safe = redact(&request.typed_argv().unwrap());
1031 assert!(
1032 !safe.iter().any(|a| a.contains("thread-secret-9")),
1033 "{safe:?}"
1034 );
1035 assert!(safe.contains(&"resume".to_string()));
1036 }
1037
1038 /// `stream` is synchronous but spawns a task. Outside a runtime that would
1039 /// panic, which a `Result`-returning function must not do.
1040 #[test]
1041 fn stream_outside_a_runtime_errors_instead_of_panicking() {
1042 let err = stream(&crate::Request::new(Agent::Claude, "hi")).unwrap_err();
1043 assert!(matches!(err, Error::NoRuntime), "got {err:?}");
1044 }
1045
1046 #[tokio::test]
1047 async fn a_missing_binary_names_the_install_command() {
1048 let request = Request::new(Agent::Claude, "hi").bin("definitely-not-a-real-binary-xyz");
1049 let err = run(&request).await.unwrap_err();
1050 let Error::NotInstalled { hint, agent, .. } = err else {
1051 panic!("expected NotInstalled, got {err:?}")
1052 };
1053 assert_eq!(agent, Agent::Claude);
1054 assert!(hint.contains("claude-code"));
1055 }
1056
1057 #[test]
1058 fn transient_errors_are_distinguished_from_permanent_ones() {
1059 assert!(
1060 Error::RateLimited {
1061 bin: "claude".into(),
1062 message: String::new()
1063 }
1064 .is_transient()
1065 );
1066 assert!(
1067 !Error::NotInstalled {
1068 agent: Agent::Claude,
1069 bin: "claude".into(),
1070 hint: ""
1071 }
1072 .is_transient()
1073 );
1074 }
1075}