diff --git a/apps/staged/src-tauri/src/doctor.rs b/apps/staged/src-tauri/src/doctor.rs index 6feae12ca..a34517e68 100644 --- a/apps/staged/src-tauri/src/doctor.rs +++ b/apps/staged/src-tauri/src/doctor.rs @@ -1,13 +1,275 @@ //! Tauri command wrappers for the doctor health-check system. +use std::collections::{HashMap, VecDeque}; use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, OnceLock}; + +use serde::Serialize; pub use doctor::types::{AuthStatus, InstallSource}; pub use doctor::{ - AgentVersionInfo, CheckStatus, DoctorCheck, DoctorReport, ExecuteFixOptions, FixType, - RunChecksOptions, + AgentVersionInfo, CheckStatus, DoctorCheck, DoctorReport, ExecuteFixOptions, FixCancelHandle, + FixCancellation, FixStdin, FixStdinWriter, FixType, RunChecksOptions, }; +/// One `doctor-login-output` event: a line of a running login's output, or +/// its end. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DoctorLoginOutput { + pub check_id: String, + /// The run this event belongs to — see [`ActiveLogin::run_id`]. A client + /// follows one run and drops events carrying any other id: a check id + /// alone cannot tell an earlier run's late `done` from the current run's. + pub run_id: String, + pub line: Option, + /// Position of `line` in the run's output, counted from zero; on the final + /// event, the number of lines the run emitted. A client that attached late + /// compares it against [`DoctorLoginStatus::next_seq`] to tell a line its + /// snapshot already covered from one that arrived after the snapshot. + pub seq: u64, + pub done: bool, + /// The fix's failure, when it failed. `None` on a cancelled run: doctor's + /// runner reports a cancellation as an `Err`, but a client should render + /// "cancelled", not a failure — that is what `cancelled` is for. + pub error: Option, + /// The run ended because [`cancel_doctor_login`] was called on it. + pub cancelled: bool, +} + +/// Answer to [`doctor_login_status`]: whether a login is running for the check, +/// and what it has printed so far. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DoctorLoginStatus { + pub running: bool, + /// The running login's run id, which its events carry and which + /// [`send_doctor_login_code`] and [`cancel_doctor_login`] take. `None` + /// when nothing is running. + pub run_id: Option, + /// The last [`LOGIN_OUTPUT_TAIL_LINES`] lines the run emitted, oldest + /// first. Empty when nothing is running. + pub output: Vec, + /// The `seq` the run's next line will carry. `output` covers the `seq`s + /// from `next_seq - output.len()` up to but excluding `next_seq`, which is + /// how a client merges this snapshot with lines it received live. Counted + /// per run: a new run for the same check starts again from zero. + pub next_seq: u64, +} + +impl DoctorLoginStatus { + fn idle() -> Self { + Self { + running: false, + run_id: None, + output: Vec::new(), + next_seq: 0, + } + } +} + +/// What [`start_doctor_login`] did, and the run id of the login it either +/// began or found. Serialized as `{ "outcome": "started" | "alreadyRunning", +/// "runId": … }`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde( + tag = "outcome", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum LoginStart { + Started { + run_id: String, + }, + /// A login for the check was already running. The caller can attach to it: + /// [`doctor_login_status`] has its output so far, the `doctor-login-output` + /// stream carries the rest, and [`send_doctor_login_code`] reaches it — + /// all under this run id. + AlreadyRunning { + run_id: String, + }, +} + +/// Lines of a login's output retained for a client that attaches after they +/// were streamed — a web client refreshed mid-login, a second client, a +/// reloaded webview. The same count the frontend keeps (`MAX_OUTPUT_LINES` in +/// `agentLogin.svelte.ts`), so a replay restores exactly what a client that +/// watched from the start is showing. +const LOGIN_OUTPUT_TAIL_LINES: usize = 40; + +/// Bounded, oldest-first tail of a login's output, with a count of every line +/// that went through it so each line's event can carry its position. +#[derive(Debug, Default)] +struct LoginOutputTail { + lines: VecDeque, + /// Lines pushed so far — the `seq` the next one gets. + next_seq: u64, +} + +impl LoginOutputTail { + /// Record `line`, dropping the oldest once the tail is full, and return the + /// `seq` the line's event carries. + fn push(&mut self, line: &str) -> u64 { + let seq = self.next_seq; + self.next_seq += 1; + if self.lines.len() == LOGIN_OUTPUT_TAIL_LINES { + self.lines.pop_front(); + } + self.lines.push_back(line.to_string()); + seq + } + + /// The snapshot a late client replays, for the running login `run_id`. + fn status(&self, run_id: &str) -> DoctorLoginStatus { + DoctorLoginStatus { + running: true, + run_id: Some(run_id.to_string()), + output: self.lines.iter().cloned().collect(), + next_seq: self.next_seq, + } + } +} + +/// A login fix in flight, keyed by check id in [`ACTIVE_LOGINS`]. +struct ActiveLogin { + /// Identity of this run, minted by [`claim_login`] and unique across every + /// login Staged has ever run — a UUID, so it stays unique across a backend + /// restart that a web client's record may outlive. + /// + /// Events, the status snapshot and the start answer all carry it, and a + /// code or a cancel names it. The check id is the slot, not the run: the + /// slot is released *before* a run's `done` is emitted (see + /// [`run_login_fix`]), so a fresh start for the same check can land between + /// the two, and that run's client would otherwise take the earlier run's + /// `done` as its own end — and a code typed for the earlier run would be + /// delivered to the new one. + run_id: String, + /// The write end of the fix's stdin. Held here for the whole run — this + /// entry is its only long-lived owner — because dropping the last + /// `FixStdinWriter` is what closes the pipe: the clone + /// [`send_doctor_login_code`] takes lives for one write. Without this one + /// the CLI would read EOF at spawn, before the user had a code to give it. + /// + /// The flip side: a fix that reads stdin *to EOF* never gets it while its + /// slot is held, which is until the run ends — so such a fix would sit + /// until doctor's `FixTimeout`. None of the login commands does that (each + /// reads one line), and closing stdin is not how a login is ended anyway: + /// the Claude CLI ignores EOF and keeps waiting on its browser callback. + /// Ending a run early is `cancel`'s job. + writer: FixStdinWriter, + /// Stops the run through doctor's runner, which owns the child and kills + /// its whole process tree. See [`cancel_doctor_login`]. + cancel: FixCancelHandle, + /// The lines streamed so far, shared with the run's `on_line` callback, + /// which appends to it before emitting each event. + output: Arc>, +} + +/// Login fixes currently running, by check id. This is intentionally only a +/// lifetime map for active subprocesses, not a cache of authentication state; +/// doctor remains the source of truth for whether login is available. +static ACTIVE_LOGINS: OnceLock>> = OnceLock::new(); + +fn active_logins() -> &'static Mutex> { + ACTIVE_LOGINS.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// A claimed login slot in [`ACTIVE_LOGINS`], released when dropped. +/// +/// The release is structural rather than a statement at the end of +/// [`run_login_fix`]: a panic in `doctor_env_vars().await` or in the emit +/// closure, or the awaiting future being dropped, would otherwise leave the +/// entry in place — and with it [`claim_login`] refusing the check until Staged +/// restarts. +/// +/// It releases only its own run: the entry is removed when it still carries +/// this slot's `run_id`, never a later run's that took the slot after this one +/// was already gone. +#[derive(Debug)] +struct LoginSlot { + check_id: String, + run_id: String, +} + +impl Drop for LoginSlot { + fn drop(&mut self) { + let mut logins = active_logins().lock().unwrap_or_else(|e| e.into_inner()); + if logins + .get(&self.check_id) + .is_some_and(|login| login.run_id == self.run_id) + { + logins.remove(&self.check_id); + } + } +} + +/// Everything a claimed login needs to run: its run id, the pipe and token the +/// fix is given, the handle and tail the entry keeps, and the slot whose drop +/// releases the entry. +#[derive(Debug)] +struct ClaimedLogin { + run_id: String, + stdin: FixStdin, + cancellation: FixCancellation, + cancel: FixCancelHandle, + output: Arc>, + slot: LoginSlot, +} + +/// What [`claim_login`] found. +#[derive(Debug)] +enum LoginClaim { + /// The slot was free and is now this caller's. + Claimed(ClaimedLogin), + /// A login for the check is already running, under this run id. + AlreadyRunning { run_id: String }, +} + +/// Why a command aimed at one run of a check's login could not reach it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum StaleLoginRun { + /// Nothing is running for the check: the run has ended. + Ended, + /// The run has ended and a newer one has since taken the check's slot. + Superseded, +} + +impl StaleLoginRun { + /// The refusal a client shows for a code typed into a login that is gone. + fn code_refusal(self, check_id: &str) -> String { + match self { + Self::Ended => format!( + "The login this code was typed for has ended; no login is running for {check_id}" + ), + Self::Superseded => format!( + "The login this code was typed for has ended; a newer login is running for \ + {check_id} and the code was not delivered to it" + ), + } + } +} + +/// The entry for `check_id` in `logins`, provided it is the run `run_id` the +/// caller is attached to. The map is keyed by check id, so this is where a +/// command aimed at a finished run is told apart from one aimed at the run +/// that replaced it. +fn find_login_run<'a>( + logins: &'a HashMap, + check_id: &str, + run_id: &str, +) -> Result<&'a ActiveLogin, StaleLoginRun> { + match logins.get(check_id) { + None => Err(StaleLoginRun::Ended), + Some(login) if login.run_id != run_id => Err(StaleLoginRun::Superseded), + Some(login) => Ok(login), + } +} + +/// A fresh run id — see [`ActiveLogin::run_id`]. +fn new_login_run_id() -> String { + uuid::Uuid::new_v4().to_string() +} + /// Environment snapshot for doctor checks and fixes. Shaped through /// `apply_managed_tools_env` so checks resolve binaries from the same PATH /// the agent spawn path uses — a bridge Staged manages must never be @@ -55,10 +317,20 @@ fn execute_fix_options( command_override: Option, env_vars: Vec<(String, String)>, ) -> ExecuteFixOptions { + // Everything else stays at doctor's defaults: the fixes that reach this + // builder are the non-interactive ones (installs and updates), so nothing + // here feeds a prompt and the child keeps inheriting stdin rather than + // getting a piped one; the standard fix timeout is far above any install + // this runs. Interactive logins do *not* come through here — every + // `FixType::Auth` run goes through [`run_login_fix`], which pipes stdin so + // the code the CLI asks for can actually be delivered. Spelled with + // `..Default::default()` so a new doctor option doesn't break this + // workspace-excluded crate, which `cargo check` under `crates/` never + // compiles but `staged-ci.yml` does. ExecuteFixOptions { command_override, npm_registry: crate::managed_acp_tools::npm_registry().map(str::to_string), - env: None, + ..Default::default() } .with_env_snapshot(env_vars) } @@ -106,22 +378,304 @@ async fn run_doctor_report(check_freshness: bool) -> DoctorReport { report } -/// Run a fix for a doctor check, identified by check ID and fix type. +/// Reserve the login slot for `check_id` under a fresh run id, returning what +/// the run needs — or the id of the login already running for the check. The +/// entry parked in [`ACTIVE_LOGINS`] is also the "a login is running" flag: +/// doctor's `FixStdin` is single-use, so a second concurrent login for one +/// check is refused here rather than spawning a CLI nothing can type into. +fn claim_login(check_id: &str) -> Result { + doctor::agents::lookup_fix_command(check_id, &FixType::Auth) + .ok_or_else(|| format!("No login fix available for {check_id}"))?; + let run_id = new_login_run_id(); + let (writer, stdin) = FixStdin::pipe(); + // A fresh token per run: a cancelled one stays cancelled and would refuse + // the retry before it spawned. + let (cancel, cancellation) = FixCancellation::token(); + let output = Arc::new(Mutex::new(LoginOutputTail::default())); + let mut logins = active_logins().lock().unwrap_or_else(|e| e.into_inner()); + if let Some(running) = logins.get(check_id) { + return Ok(LoginClaim::AlreadyRunning { + run_id: running.run_id.clone(), + }); + } + logins.insert( + check_id.to_string(), + ActiveLogin { + run_id: run_id.clone(), + writer, + cancel: cancel.clone(), + output: output.clone(), + }, + ); + Ok(LoginClaim::Claimed(ClaimedLogin { + run_id: run_id.clone(), + stdin, + cancellation, + cancel, + output, + slot: LoginSlot { + check_id: check_id.to_string(), + run_id, + }, + })) +} + +/// The final `doctor-login-output` event for a run. +/// +/// A cancelled run comes back from doctor's runner as an `Err`; `cancelled` is +/// what lets a client tell it from a failure, and the runner's message is kept +/// out of `error` so no client renders it as one. A cancel that landed after +/// the fix had already finished changes nothing — the fix's own result stands, +/// as the runner documents — so `cancelled` is only reported on a run that +/// actually ended early. +fn login_done_event( + check_id: String, + run_id: String, + result: &Result<(), String>, + cancel_requested: bool, + lines_emitted: u64, +) -> DoctorLoginOutput { + let cancelled = result.is_err() && cancel_requested; + DoctorLoginOutput { + check_id, + run_id, + line: None, + seq: lines_emitted, + done: true, + error: if cancelled { + None + } else { + result.as_ref().err().cloned() + }, + cancelled, + } +} + +/// Run a claimed login fix to completion on a piped stdin, streaming every +/// output line to the frontend as a `doctor-login-output` event and releasing +/// the slot afterwards. +/// +/// The final `done` event carries the outcome *and* the outcome is returned, so +/// this serves both entry points: [`start_doctor_login`], which spawns it and +/// watches the stream, and [`run_doctor_fix`], which awaits it. +async fn run_login_fix( + app_handle: tauri::AppHandle, + check_id: String, + login: ClaimedLogin, +) -> Result<(), String> { + let ClaimedLogin { + run_id, + stdin, + cancellation, + cancel, + output, + slot, + } = login; + let env_vars = doctor_env_vars().await; + let event_check_id = check_id.clone(); + let event_run_id = run_id.clone(); + let event_app = app_handle.clone(); + let tail = output.clone(); + let result = doctor::execute_fix_streaming_with_env_options( + check_id.clone(), + FixType::Auth, + ExecuteFixOptions::default() + .with_env_snapshot(env_vars) + .with_stdin(stdin) + .with_cancellation(cancellation), + move |line| { + // Recorded before it is emitted, so a `doctor_login_status` + // snapshot taken between the two already covers the line whose + // event is about to follow it — the client's `seq` comparison then + // drops the event rather than showing the line twice. + let seq = tail.lock().unwrap_or_else(|e| e.into_inner()).push(line); + crate::web_server::emit_to_all( + &event_app, + "doctor-login-output", + DoctorLoginOutput { + check_id: event_check_id.clone(), + run_id: event_run_id.clone(), + line: Some(line.to_string()), + seq, + done: false, + error: None, + cancelled: false, + }, + ); + }, + ) + .await; + + let lines_emitted = output.lock().unwrap_or_else(|e| e.into_inner()).next_seq; + let done = login_done_event( + check_id.clone(), + run_id, + &result, + cancel.is_cancelled(), + lines_emitted, + ); + if done.cancelled { + if let Err(message) = &result { + log::info!("[doctor login {check_id}] cancelled: {message}"); + } + } + // Released *before* the `done` goes out. A client attaches by registering + // its listener and then asking `doctor_login_status`, so this order gives + // it a guarantee: a snapshot that says `running` was taken before the + // `done` was emitted, and the `done` is still ahead of the listener. The + // other order would let a snapshot report a run whose `done` had already + // passed, leaving that client waiting for an end it can never see. + // + // The price of this order is that a fresh start for the same check can be + // claimed between the release and the emit, and its client then sees this + // run's `done`. That is what `run_id` on the event is for: the client + // follows the run id its start returned and drops this one. + drop(slot); + crate::web_server::emit_to_all(&app_handle, "doctor-login-output", done); + result +} + +/// Start an interactive login fix and stream its output to the frontend. +/// +/// Returns as soon as the fix is claimed and spawned, with the run id its +/// events will carry; the caller learns the outcome from the `done` event, +/// which lets it feed a code through [`send_doctor_login_code`] while the fix +/// is still running. A login already running for the check is reported as +/// [`LoginStart::AlreadyRunning`] with *its* run id rather than as an error: +/// it is the same subprocess the caller wanted, and it can attach to it (see +/// [`doctor_login_status`]). +#[tauri::command] +pub async fn start_doctor_login( + app_handle: tauri::AppHandle, + check_id: String, +) -> Result { + let login = match claim_login(&check_id)? { + LoginClaim::Claimed(login) => login, + LoginClaim::AlreadyRunning { run_id } => return Ok(LoginStart::AlreadyRunning { run_id }), + }; + let run_id = login.run_id.clone(); + tokio::spawn(async move { + // A failure is reported to the frontend by the final `done` event; this + // handle has no caller to return it to. + let _ = run_login_fix(app_handle, check_id, login).await; + }); + Ok(LoginStart::Started { run_id }) +} + +/// Ask run `run_id` of `check_id`'s login to stop, reporting whether it was +/// found. Idempotent, and harmless when nothing is running — or when the run +/// has ended and a newer one holds the check's slot, which is left alone: it +/// is someone else's login, and `false` tells the caller its own is gone. +/// +/// The stop goes through doctor's cancellation token, which makes the runner — +/// the owner of the child — kill the fix's process tree, and the run then ends +/// with a `done` event carrying `cancelled: true`. It is deliberately *not* +/// done by dropping the stdin writer: the Claude CLI ignores EOF on stdin once +/// it has printed its URL and keeps waiting on its browser callback (the +/// stdin-vs-TTY experiments ran it with `< /dev/null` and killed it 25s later, +/// still waiting), so a closed pipe would leave the slot held until the fix +/// timeout with nothing to show for it. +#[tauri::command] +pub async fn cancel_doctor_login(check_id: String, run_id: String) -> bool { + let cancel = find_login_run( + &active_logins().lock().unwrap_or_else(|e| e.into_inner()), + &check_id, + &run_id, + ) + .map(|login| login.cancel.clone()); + match cancel { + Ok(cancel) => { + cancel.cancel(); + true + } + Err(_) => false, + } +} + +/// Whether a login is running for `check_id`, under which run id, and what it +/// has printed so far — for a client that lost its own record of the login (a +/// web refresh, a second client, a reloaded webview) and needs the sign-in URL +/// and code entry back. +/// +/// Register the `doctor-login-output` listener *before* calling this, then +/// follow the run id it names and merge that run's lines by `seq`: lines below +/// [`DoctorLoginStatus::next_seq`] are in the snapshot, lines at or above it +/// arrived after it. +#[tauri::command] +pub async fn doctor_login_status(check_id: String) -> DoctorLoginStatus { + // The map lock and the tail lock are never held together: the `on_line` + // callback takes only the tail's, the slot release only the map's. + let running = active_logins() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get(&check_id) + .map(|login| (login.run_id.clone(), login.output.clone())); + match running { + Some((run_id, output)) => output + .lock() + .unwrap_or_else(|e| e.into_inner()) + .status(&run_id), + None => DoctorLoginStatus::idle(), + } +} + +/// Deliver a line — in practice the authentication code the agent CLI asked +/// for — to run `run_id` of a login started by [`start_doctor_login`] or +/// [`run_doctor_fix`]. A code typed for a run that has since ended is refused, +/// with the refusal saying so, rather than delivered to whatever login now +/// holds the check's slot: that one printed a different URL, and a code for +/// the old one would only be rejected by it — or, worse, accepted. /// -/// The actual shell command is looked up from the static check definitions — -/// the caller never sends a raw command string. Two families of fixes are -/// native rather than shell commands: the node-runtime fix (re)installs the -/// pinned managed runtime, and install fixes for the managed ACP bridges run -/// the floating managed installer so the bridge lands in -/// `~/.staged/packages/tools` with an absolute-path shim instead of the -/// crate's `npm install -g`. Remaining npm-backed fixes install the managed -/// runtime first, since they run npm from it into the private prefix (the -/// existing "Running…" spinner covers the one-time download). +/// `async` deliberately: `send_line` writes into the fix's stdin pipe inline +/// and can block if the fix isn't reading, and under Tauri 2 a non-`async` +/// command body runs on the main thread — the worst possible place to discover +/// a full pipe. The write itself goes to a blocking thread for the same reason. #[tauri::command] -pub async fn run_doctor_fix(check_id: String, fix_type: FixType) -> Result<(), String> { +pub async fn send_doctor_login_code( + check_id: String, + run_id: String, + code: String, +) -> Result<(), String> { + let writer = find_login_run( + &active_logins().lock().unwrap_or_else(|e| e.into_inner()), + &check_id, + &run_id, + ) + .map(|login| login.writer.clone()) + .map_err(|stale| stale.code_refusal(&check_id))?; + tokio::task::spawn_blocking(move || writer.send_line(code)) + .await + .map_err(|e| format!("Failed to deliver the login code for {check_id}: {e}"))? +} + +#[tauri::command] +pub async fn run_doctor_fix( + app_handle: tauri::AppHandle, + check_id: String, + fix_type: FixType, +) -> Result<(), String> { if check_id == NODE_RUNTIME_CHECK_ID { return ensure_managed_node_runtime_for_fix().await; } + // An auth fix is the one interactive fix: it prints a verification URL and + // then blocks reading a code from stdin. Route it through the same piped, + // streamed path `start_doctor_login` uses so both entry points behave the + // same. On inherited stdin — `/dev/null` in the GUI — this printed its URL + // to a log nobody reads and could only ever finish through the CLI's own + // browser callback, otherwise dying at the fix timeout with no way to enter + // the code (block/berd#99). + if matches!(fix_type, FixType::Auth) { + // This entry point awaits the fix to its end, so there is no run to + // hand an "already running" answer to — the caller asked for a fix and + // there is one it can't have. + let login = match claim_login(&check_id)? { + LoginClaim::Claimed(login) => login, + LoginClaim::AlreadyRunning { .. } => { + return Err(format!("A login is already running for {check_id}")); + } + }; + return run_login_fix(app_handle, check_id, login).await; + } if matches!(fix_type, FixType::Command | FixType::Bridge) { if let Some(tool_id) = managed_tool_for_check(&check_id) { return install_managed_tool_logged(tool_id, &check_id).await; @@ -410,6 +964,7 @@ fn node_runtime_doctor_check( bridge_path: None, raw_output, auth_status: None, + login_command: None, installed_version: None, latest_version: None, update_available: None, @@ -559,4 +1114,339 @@ mod tests { assert!(expected.is_some()); } } + + /// The tail numbers every line from zero and keeps only the newest + /// `LOGIN_OUTPUT_TAIL_LINES`, and its snapshot states both — a client merges + /// live lines against `next_seq`, so a snapshot whose `output` did not sit + /// exactly below it would show lines twice or lose them. + #[test] + fn login_output_tail_numbers_lines_and_keeps_the_newest() { + let mut tail = LoginOutputTail::default(); + assert_eq!(tail.push("Opening browser to sign in…"), 0); + assert_eq!(tail.push("If the browser didn't open, visit: https://x"), 1); + assert_eq!( + tail.status("run-1"), + DoctorLoginStatus { + running: true, + run_id: Some("run-1".to_string()), + output: vec![ + "Opening browser to sign in…".to_string(), + "If the browser didn't open, visit: https://x".to_string(), + ], + next_seq: 2, + } + ); + + for i in 2..(LOGIN_OUTPUT_TAIL_LINES as u64 + 10) { + assert_eq!(tail.push(&format!("line {i}")), i); + } + let status = tail.status("run-1"); + assert_eq!(status.output.len(), LOGIN_OUTPUT_TAIL_LINES); + assert_eq!(status.next_seq, LOGIN_OUTPUT_TAIL_LINES as u64 + 10); + // `output[i]` carries seq `next_seq - output.len() + i`. + let first_seq = status.next_seq - status.output.len() as u64; + assert_eq!(status.output.first().unwrap(), &format!("line {first_seq}")); + assert_eq!( + status.output.last().unwrap(), + &format!("line {}", status.next_seq - 1) + ); + } + + /// A cancelled run is reported as cancelled and not as a failure; a run + /// that failed on its own keeps its error; and a cancel that only landed + /// after the fix had finished changes nothing about its result. + #[test] + fn login_done_event_tells_a_cancellation_from_a_failure() { + let cancelled_by_runner = + Err("Fix cancelled before finishing: claude-agent-acp --cli auth login".to_string()); + + let done = login_done_event( + "ai-agent-claude".into(), + "run-1".into(), + &cancelled_by_runner, + true, + 3, + ); + assert_eq!( + done, + DoctorLoginOutput { + check_id: "ai-agent-claude".into(), + run_id: "run-1".into(), + line: None, + seq: 3, + done: true, + error: None, + cancelled: true, + } + ); + + let failed = Err("Fix timed out after 10m without finishing: …".to_string()); + let done = login_done_event("ai-agent-claude".into(), "run-1".into(), &failed, false, 3); + assert!(!done.cancelled); + assert_eq!( + done.error.as_deref(), + Some("Fix timed out after 10m without finishing: …") + ); + + let done = login_done_event("ai-agent-claude".into(), "run-1".into(), &Ok(()), true, 0); + assert!(done.done && !done.cancelled && done.error.is_none()); + } + + /// The start answer is what a client keys its whole record on, so its wire + /// shape is pinned: one tag field for the outcome and the run id beside it + /// in camelCase, for both variants. + #[test] + fn login_start_serializes_as_a_tagged_object_with_the_run_id() { + assert_eq!( + serde_json::to_value(LoginStart::Started { + run_id: "run-1".into() + }) + .unwrap(), + serde_json::json!({ "outcome": "started", "runId": "run-1" }) + ); + assert_eq!( + serde_json::to_value(LoginStart::AlreadyRunning { + run_id: "run-1".into() + }) + .unwrap(), + serde_json::json!({ "outcome": "alreadyRunning", "runId": "run-1" }) + ); + // The status and the events spell it the same way. + let status = serde_json::to_value(LoginOutputTail::default().status("run-1")).unwrap(); + assert_eq!(status["runId"], "run-1"); + assert_eq!(status["nextSeq"], 0); + let idle = serde_json::to_value(DoctorLoginStatus::idle()).unwrap(); + assert_eq!(idle["runId"], serde_json::Value::Null); + let done = serde_json::to_value(login_done_event( + "ai-agent-claude".into(), + "run-1".into(), + &Ok(()), + false, + 0, + )) + .unwrap(); + assert_eq!(done["runId"], "run-1"); + } + + /// Build an entry the way `claim_login` does, without touching the global + /// map — for tests of the lookup logic alone. + fn active_login(run_id: &str) -> ActiveLogin { + let (writer, _stdin) = FixStdin::pipe(); + let (cancel, _cancellation) = FixCancellation::token(); + ActiveLogin { + run_id: run_id.to_string(), + writer, + cancel, + output: Arc::new(Mutex::new(LoginOutputTail::default())), + } + } + + /// A command names a run, and reaches it only while that run holds the + /// check's slot. Nothing running is one kind of stale, a *different* run + /// holding the slot is another, and the refusal for a code says which — + /// and, for the second, that the code did not go to the newer login. + #[test] + fn find_login_run_tells_a_finished_run_from_the_one_that_replaced_it() { + let mut logins = HashMap::new(); + assert_eq!( + find_login_run(&logins, "ai-agent-claude", "run-1").err(), + Some(StaleLoginRun::Ended) + ); + + logins.insert("ai-agent-claude".to_string(), active_login("run-2")); + let found = find_login_run(&logins, "ai-agent-claude", "run-2").expect("the live run"); + assert_eq!(found.run_id, "run-2"); + assert_eq!( + find_login_run(&logins, "ai-agent-claude", "run-1").err(), + Some(StaleLoginRun::Superseded) + ); + // The slot is per check: another check's run id never matches. + assert_eq!( + find_login_run(&logins, "ai-agent-codex", "run-2").err(), + Some(StaleLoginRun::Ended) + ); + + let ended = StaleLoginRun::Ended.code_refusal("ai-agent-claude"); + assert!(ended.contains("has ended"), "{ended}"); + assert!( + ended.contains("no login is running for ai-agent-claude"), + "{ended}" + ); + let superseded = StaleLoginRun::Superseded.code_refusal("ai-agent-claude"); + assert!(superseded.contains("has ended"), "{superseded}"); + assert!( + superseded.contains("a newer login is running for ai-agent-claude"), + "{superseded}" + ); + assert!(superseded.contains("was not delivered"), "{superseded}"); + } + + /// Every claim mints its own id, and ids are not recycled: two runs of one + /// check, and two checks' runs, never share one. + #[test] + fn login_run_ids_are_unique() { + let ids: std::collections::HashSet = (0..64).map(|_| new_login_run_id()).collect(); + assert_eq!(ids.len(), 64); + assert!(ids.iter().all(|id| !id.is_empty())); + } + + /// A slot releases only the run it was claimed for. Its `Drop` cannot + /// normally run while a later run holds the entry — the later claim needs + /// the entry gone first — but the release is keyed to the run id so that + /// stays true by construction rather than by call order. + /// + /// Uses `ai-agent-cursor`: `ACTIVE_LOGINS` is process-global and the other + /// login tests in this module claim other checks. + #[test] + fn login_slot_releases_only_its_own_run() { + let check_id = "ai-agent-cursor"; + let LoginClaim::Claimed(login) = claim_login(check_id).expect("cursor has a login fix") + else { + panic!("the slot is free"); + }; + let live_run_id = login.run_id.clone(); + + // A stale slot for a run that no longer holds the entry. + drop(LoginSlot { + check_id: check_id.to_string(), + run_id: "an-earlier-run".to_string(), + }); + assert_eq!( + active_logins() + .lock() + .unwrap() + .get(check_id) + .map(|login| login.run_id.clone()), + Some(live_run_id), + "a stale slot's drop leaves the live run's entry alone" + ); + + drop(login); + assert!( + !active_logins().lock().unwrap().contains_key(check_id), + "the run's own slot releases it" + ); + } + + /// The slot lifecycle end to end: a claim holds the check under a run id, a + /// second claim reports that id rather than an error, the status names it, + /// the cancel and code commands reach the run only under it, dropping the + /// claim releases the slot, and the next claim gets a new id and a token the + /// earlier cancel does not refuse. + /// + /// Uses `ai-agent-codex` because `ACTIVE_LOGINS` is process-global and the + /// other login tests in this module claim other checks. + #[tokio::test] + async fn login_slot_is_released_on_drop_and_the_cancel_reaches_the_run() { + let check_id = "ai-agent-codex"; + let LoginClaim::Claimed(login) = claim_login(check_id).expect("codex has a login fix") + else { + panic!("the first claim takes the slot"); + }; + let run_id = login.run_id.clone(); + assert_eq!( + login.slot.run_id, run_id, + "the slot releases the run it was claimed for" + ); + match claim_login(check_id).expect("still a valid check") { + LoginClaim::AlreadyRunning { run_id: running } => assert_eq!( + running, run_id, + "a second claim names the run that holds the slot" + ), + LoginClaim::Claimed(_) => panic!("the slot is held"), + } + + assert_eq!( + doctor_login_status(check_id.into()).await, + DoctorLoginStatus { + running: true, + run_id: Some(run_id.clone()), + output: Vec::new(), + next_seq: 0, + } + ); + // What the run's `on_line` records is what a late client is shown. + login + .output + .lock() + .unwrap() + .push("Opening browser to sign in…"); + assert_eq!( + doctor_login_status(check_id.into()).await.output, + vec!["Opening browser to sign in…".to_string()] + ); + + // A code for a run that isn't the one holding the slot is refused — + // and never reaches this run's pipe. The right id queues it (the fix + // has not spawned here, so `send_line` buffers it for the replay). + let refused = + send_doctor_login_code(check_id.into(), "an-earlier-run".into(), "abc".into()) + .await + .expect_err("a code for another run is refused"); + assert!( + refused.contains("a newer login is running for ai-agent-codex"), + "{refused}" + ); + send_doctor_login_code(check_id.into(), run_id.clone(), "abc".into()) + .await + .expect("a code for the live run reaches its pipe"); + + assert!(!login.cancel.is_cancelled()); + assert!( + !cancel_doctor_login(check_id.into(), "an-earlier-run".into()).await, + "a cancel for another run is not this run's" + ); + assert!(!login.cancel.is_cancelled()); + assert!(cancel_doctor_login(check_id.into(), run_id.clone()).await); + assert!( + login.cancel.is_cancelled(), + "the command reaches the token the run was given" + ); + assert!( + cancel_doctor_login(check_id.into(), run_id.clone()).await, + "repeating it is a no-op that still reports the running login" + ); + + drop(login); + assert_eq!( + doctor_login_status(check_id.into()).await, + DoctorLoginStatus::idle() + ); + assert!( + !cancel_doctor_login(check_id.into(), run_id.clone()).await, + "nothing running: nothing to cancel" + ); + let refused = send_doctor_login_code(check_id.into(), run_id.clone(), "abc".into()) + .await + .expect_err("a code for a run that has ended is refused"); + assert!( + refused.contains("no login is running for ai-agent-codex"), + "{refused}" + ); + + let LoginClaim::Claimed(again) = claim_login(check_id).expect("still a valid check") else { + panic!("a released slot can be claimed again"); + }; + assert_ne!( + again.run_id, run_id, + "each run gets its own id, so the earlier run's events and commands can't be taken for this one's" + ); + assert!( + !again.cancel.is_cancelled(), + "each run gets a fresh token, so the earlier cancel can't refuse the retry" + ); + } + + #[test] + fn claim_login_refuses_a_check_with_no_login_fix() { + let err = claim_login("ai-agent-goose").expect_err("goose has no auth command"); + assert!( + err.contains("No login fix available for ai-agent-goose"), + "{err}" + ); + assert!(!active_logins() + .lock() + .unwrap() + .contains_key("ai-agent-goose")); + } } diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index 1a6757ef2..d80df119d 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -2458,6 +2458,10 @@ pub fn run() { doctor::run_doctor, doctor::run_doctor_freshness, doctor::run_doctor_fix, + doctor::start_doctor_login, + doctor::cancel_doctor_login, + doctor::doctor_login_status, + doctor::send_doctor_login_code, doctor::run_doctor_update, ]) .build(tauri::generate_context!()) diff --git a/apps/staged/src-tauri/src/web_server.rs b/apps/staged/src-tauri/src/web_server.rs index b305efb52..01f645197 100644 --- a/apps/staged/src-tauri/src/web_server.rs +++ b/apps/staged/src-tauri/src/web_server.rs @@ -3820,10 +3820,33 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result { + let check_id: String = arg(&args, "checkId")?; + let start = crate::doctor::start_doctor_login(app_handle.clone(), check_id).await?; + Ok(serde_json::to_value(start).unwrap()) + } + "cancel_doctor_login" => { + let check_id: String = arg(&args, "checkId")?; + let run_id: String = arg(&args, "runId")?; + let cancelled = crate::doctor::cancel_doctor_login(check_id, run_id).await; + Ok(Value::Bool(cancelled)) + } + "doctor_login_status" => { + let check_id: String = arg(&args, "checkId")?; + let status = crate::doctor::doctor_login_status(check_id).await; + Ok(serde_json::to_value(status).unwrap()) + } + "send_doctor_login_code" => { + let check_id: String = arg(&args, "checkId")?; + let run_id: String = arg(&args, "runId")?; + let code: String = arg(&args, "code")?; + crate::doctor::send_doctor_login_code(check_id, run_id, code).await?; + Ok(Value::Null) + } "run_doctor_fix" => { let check_id: String = arg(&args, "checkId")?; let fix_type: doctor::FixType = arg(&args, "fixType")?; - crate::doctor::run_doctor_fix(check_id, fix_type).await?; + crate::doctor::run_doctor_fix(app_handle.clone(), check_id, fix_type).await?; Ok(Value::Null) } "run_doctor_update" => { diff --git a/apps/staged/src/lib/commands.ts b/apps/staged/src/lib/commands.ts index 622e4618c..f3c80c6e2 100644 --- a/apps/staged/src/lib/commands.ts +++ b/apps/staged/src/lib/commands.ts @@ -1578,6 +1578,16 @@ export interface DoctorCheck { bridgePath: string | null; rawOutput: string | null; authStatus: 'authenticated' | 'notAuthenticated' | 'notApplicable' | 'unknown' | null; + /** + * The provider's interactive login command whenever its binary resolved, + * regardless of `authStatus` — a static capability, not doctor's verdict. + * Unlike `fixCommand`, which is set only when the probe positively reported + * a signed-out agent, this says a login *exists*: the probe can't see an + * expired Claude token, so a live authentication failure has to be able to + * offer one on a check the probe calls `authenticated`. `null` for providers + * without a login command (Pi, Goose) and for non-agent checks. + */ + loginCommand: string | null; /** Flat version fields mirror the bridge readout (else main) for compat. */ installedVersion: string | null; latestVersion: string | null; @@ -1608,7 +1618,15 @@ export function runDoctorFreshness(): Promise { return invokeCommand('run_doctor_freshness'); } -/** Run a fix for a doctor check, identified by check ID and fix type. */ +/** + * Run a fix for a doctor check, identified by check ID and fix type. + * + * Resolves when the fix finishes. An `auth` fix is interactive, so the backend + * runs it on a piped stdin and streams it exactly as `startDoctorLogin` does — + * a caller that wants to show the sign-in URL or feed the code back should + * prefer `startDoctorLogin`, which resolves as soon as the fix is running + * rather than blocking until it ends. + */ export function runDoctorFix( checkId: string, fixType: 'command' | 'bridge' | 'auth' @@ -1616,6 +1634,94 @@ export function runDoctorFix( return invokeCommand('run_doctor_fix', { checkId, fixType }); } +/** + * What `startDoctorLogin` did — began a login, or found one already running for + * the check — and the run id of that login. The latter is not a failure: it is + * the same subprocess the caller wanted, reachable under that run id through + * `doctorLoginStatus`, the `doctor-login-output` stream and + * `sendDoctorLoginCode`. + * + * The run id is the login's identity, not the check id. The backend allows one + * login per check and releases the check's slot before the run's final event + * goes out, so a fresh start can land between the two and then see the earlier + * run's `done` under its own check id. Follow the run id instead. + */ +export interface DoctorLoginStart { + outcome: 'started' | 'alreadyRunning'; + runId: string; +} + +/** + * Start an interactive login fix, resolving once it is running with the run id + * its events carry. Its output and its completion are delivered through + * `doctor-login-output` events. + */ +export function startDoctorLogin(checkId: string): Promise { + return invokeCommand('start_doctor_login', { checkId }); +} + +/** + * Ask run `runId` of `checkId`'s login to stop, resolving with whether it was + * found. Idempotent, and harmless when nothing is running; `false` also when a + * newer run now holds the check's slot, which is left alone. The end arrives as + * a `doctor-login-output` event with `done` and `cancelled` set — this only asks. + */ +export function cancelDoctorLogin(checkId: string, runId: string): Promise { + return invokeCommand('cancel_doctor_login', { checkId, runId }); +} + +export interface DoctorLoginStatus { + running: boolean; + /** The running login's run id, which its events carry. Null when not running. */ + runId: string | null; + /** The last lines the login printed, oldest first. Empty when not running. */ + output: string[]; + /** + * The `seq` the login's next line will carry; `output` covers the `seq`s from + * `nextSeq - output.length` up to but excluding `nextSeq`. Counted per run. + */ + nextSeq: number; +} + +/** + * Whether a login is running for `checkId`, under which run id, and what it has + * printed so far — for a client that lost its record of the login (a web + * refresh, a second client, a reloaded webview). Register the + * `doctor-login-output` listener before calling this, then follow the run id it + * names and merge that run's lines by `seq`: a line below `nextSeq` is in the + * snapshot, one at or above it arrived after the snapshot. + */ +export function doctorLoginStatus(checkId: string): Promise { + return invokeCommand('doctor_login_status', { checkId }); +} + +/** + * Submit a line — in practice the authentication code the agent CLI asked for — + * to run `runId` of a login started by `startDoctorLogin` or by `runDoctorFix` + * with an `auth` fix type. Rejects, saying so, when that run has ended — even + * if a newer login is running for the check, which does not get the code. + */ +export function sendDoctorLoginCode(checkId: string, runId: string, code: string): Promise { + return invokeCommand('send_doctor_login_code', { checkId, runId, code }); +} + +export interface DoctorLoginOutput { + checkId: string; + /** The run this event belongs to. Drop events from any run but the one followed. */ + runId: string; + line: string | null; + /** + * Position of `line` in the login's output, from zero; on the final event, + * the number of lines it printed. See `DoctorLoginStatus.nextSeq`. + */ + seq: number; + done: boolean; + /** The login's failure. Null on a cancelled login, which is not a failure. */ + error: string | null; + /** The login ended because `cancelDoctorLogin` was called on it. */ + cancelled: boolean; +} + /** * Run a source-aware update for a single readout (main CLI or ACP bridge). * diff --git a/apps/staged/src/lib/features/doctor/AgentLoginPrompt.svelte b/apps/staged/src/lib/features/doctor/AgentLoginPrompt.svelte new file mode 100644 index 000000000..71d49906e --- /dev/null +++ b/apps/staged/src/lib/features/doctor/AgentLoginPrompt.svelte @@ -0,0 +1,146 @@ + + + +{#if login} + {#if login.running} + + {/if} + {#if login.error} + + {/if} +{/if} + + diff --git a/apps/staged/src/lib/features/doctor/DoctorCheckRow.svelte b/apps/staged/src/lib/features/doctor/DoctorCheckRow.svelte index f54026081..8fe9afbee 100644 --- a/apps/staged/src/lib/features/doctor/DoctorCheckRow.svelte +++ b/apps/staged/src/lib/features/doctor/DoctorCheckRow.svelte @@ -20,6 +20,16 @@ isReadoutActionable, hasActionableUpdate, } from './doctor.svelte'; + import { + agentLogin, + attachAgentLogin, + cancelAgentLogin, + clearAgentLogin, + startAgentLogin, + type AgentLoginOutcome, + } from './agentLogin.svelte'; + import AgentLoginPrompt from './AgentLoginPrompt.svelte'; + import { closingFixDialogCancelsLogin } from './fixDialog'; import { Button } from '$lib/components/ui/button'; import * as AlertDialog from '$lib/components/ui/alert-dialog'; import Spinner from '../../shared/Spinner.svelte'; @@ -79,20 +89,68 @@ let showUpdateDialog = $state(false); let updateError = $state(null); - function promptFix() { + const isAuthFix = $derived(check.fixType === 'auth'); + /** + * Whether the shared login record is running this check's login — whichever + * entry point started it. For an `auth` fix this, not `fixing`, is what the + * dialog reports: `fixing` is only this dialog's wait on that record. + */ + const loginRunning = $derived(isAuthFix && agentLogin.running && agentLogin.checkId === check.id); + const fixRunning = $derived(isAuthFix ? loginRunning : fixing); + /** + * This dialog asked for the login the record shows — its Run was confirmed — + * rather than attaching on open to one already running. Only what the dialog + * knows on its own: whether that request began a run or re-attached to a + * login someone else started is the record's `origin`, and leaving the + * dialog reads both — see `closingFixDialogCancelsLogin`. Read only from + * handlers, never rendered, so plain state. + */ + let loginRequestedHere = false; + /** + * Opens of the fix dialog so far. A login follower from an earlier open — one + * this dialog detached from on close and, re-opened, attached to again — is + * waiting on the same login as the current open's; it stands down so the end + * is acted on once. + */ + let fixDialogOpens = 0; + + async function promptFix() { if (!check.fixType) return; fixError = null; fixing = false; + fixDialogOpens += 1; + if (!isAuthFix) { + showFixDialog = true; + return; + } + // Don't open on the last attempt's leftovers. + clearAgentLogin(check.id); showFixDialog = true; + // A login for this check may already be running — started from the + // session pane, from another client, or before this view reloaded. Pick it + // up so the dialog shows its URL and code box, instead of a Run the backend + // would answer "already running". Not this dialog's login to end. + loginRequestedHere = false; + await followLogin(attachAgentLogin(check.id)); } async function confirmFix() { if (!check.fixType) return; + if (isAuthFix) { + // The one interactive fix: it prints a sign-in URL and then waits for + // the code that page hands back. Started through the shared login + // record so the dialog can show both, instead of running blind and + // expiring at the fix timeout. Whether the backend in fact starts one or + // answers "already running" is on the record, not known here. + loginRequestedHere = true; + await followLogin(startAgentLogin(check.id)); + return; + } fixing = true; fixError = null; try { // canFix guarantees fixType is one of the non-update kinds here. - await runDoctorFix(check.id, check.fixType as 'command' | 'bridge' | 'auth'); + await runDoctorFix(check.id, check.fixType as 'command' | 'bridge'); showFixDialog = false; onFixed?.(); } catch (e) { @@ -102,7 +160,68 @@ } } + /** + * Wait on a login from this dialog: close it when the login ends, and refresh + * the report only if it signed in. `null` is an attach that found nothing + * running, which leaves the dialog offering Run. + * + * Two followers stand down rather than act. One outlived by a re-open of the + * dialog leaves the end to the current open's follower, which waits on the + * same login. One whose dialog was left before the login ended — detached from + * it on the way out — has handed the login back to whoever is still watching + * it, the session pane that started it, and that watcher re-runs the checks; + * a refresh from here as well would be two full scans at once. A login this + * dialog started and left is on its way out too (leaving cancelled it), and a + * cancelled end refreshes nothing. + */ + async function followLogin(login: Promise) { + const opened = fixDialogOpens; + fixing = true; + fixError = null; + let outcome: AgentLoginOutcome | null; + try { + outcome = await login; + } catch (e) { + if (opened !== fixDialogOpens) return; + fixing = false; + // A failed login is already rendered from the shared record — unless + // another check's login owns that record, which is what a rejection + // before the login even started means. + if (agentLogin.checkId !== check.id) fixError = String(e); + return; + } + if (opened !== fixDialogOpens) return; + fixing = false; + if (outcome === null) return; + if (!showFixDialog) return; + showFixDialog = false; + if (outcome === 'completed') onFixed?.(); + } + + /** + * Leave the fix dialog. The footer's Cancel, Escape and a click outside all + * land here through `onOpenChange`. For a login this dialog started, leaving + * is also the abort: nothing else is watching that login, and it must not + * hold the check's login slot until doctor's fix timeout — the CLI ignores a + * closed stdin, so only a kill ends it. A login the dialog merely attached to + * is left running: the session pane that started it is still showing its URL + * and code box, and this was only a look. That holds whether the dialog + * attached on open or its own Run was answered "already running" — the record + * knows which, this dialog only what it asked for. An install can't be + * cancelled yet and keeps Cancel disabled while it runs; Escape still closes + * its dialog, and `fixing` clears when it ends. + */ function cancelFix() { + if (isAuthFix) { + const cancels = closingFixDialogCancelsLogin({ + running: loginRunning, + requestedHere: loginRequestedHere, + origin: agentLogin.origin, + }); + if (cancels) void cancelAgentLogin(); + showFixDialog = false; + return; + } if (fixing) return; showFixDialog = false; } @@ -212,7 +331,11 @@ {/if} - + + !open && cancelFix()}> Run fix command? @@ -220,20 +343,23 @@ {check.fixCommand} + + {#if fixError}

{fixError}

{/if} - Cancel + Cancel { e.preventDefault(); confirmFix(); }} > - {fixing ? 'Running' : fixError ? 'Retry' : 'Run'} + {fixRunning ? 'Running' : fixError ? 'Retry' : 'Run'}
diff --git a/apps/staged/src/lib/features/doctor/agentLogin.svelte.ts b/apps/staged/src/lib/features/doctor/agentLogin.svelte.ts new file mode 100644 index 000000000..e3551fdb4 --- /dev/null +++ b/apps/staged/src/lib/features/doctor/agentLogin.svelte.ts @@ -0,0 +1,664 @@ +/** + * agentLogin.svelte.ts — shared state for an interactive agent CLI login. + * + * A login is a streamed subprocess, not a request/response: the CLI prints a + * verification URL, tries to open a browser, and then blocks reading the code + * the sign-in page hands back. Doctor's fix runner gives it a piped stdin and + * streams its output as `doctor-login-output` events, and allows exactly one + * login per check id — so the two entry points that can start one (the session + * pane's authentication alert and the Doctor panel's `Fix` on an `auth` check) + * share the single record here rather than each keeping their own. + * + * The code entry is deliberately *not* gated on detecting a prompt. Claude's + * prompt is `Paste code here if prompted > ` with no trailing newline, and + * doctor's reader splits the fix's output into lines, so the prompt is only + * delivered once stdout closes at exit — far too late to trigger anything. The + * input is offered for as long as the login runs instead, which also leaves a + * mistyped code a way to be retried: the CLI re-prompts (again with no newline) + * on a code it rejects locally. + * + * The record follows one *run*, identified by the run id the backend mints when + * it claims the check's login slot and stamps on every event. The check id is + * the slot, not the run: the backend releases the slot before a run's `done` + * goes out, so a fresh start for the same check can be claimed between the two + * and would otherwise take the earlier run's `done` as its own end — and a code + * typed for the earlier run would go to the new one. Events from any other run + * are dropped, and codes and cancels name the run. Events that arrive before + * the backend has named the run (its answer to the start or the status is still + * in flight) are held, then replayed once it has. + * + * The backend is the source of truth for whether a login is running, and this + * record can lose it — a web client refreshed mid-login, a second client, a + * reloaded webview. Both ways in re-attach rather than fail: a start the backend + * answers "already running" adopts that run, and `attachAgentLogin` asks before + * a UI offers to start one. Both replay the backend's output tail through + * `doctorLoginStatus`, with the listener registered first; every line carries a + * sequence number, per run, so a line delivered live while the snapshot was in + * flight is shown once whichever arrived first. The record says which it did — + * began the run, or picked up one already running (`origin`) — for a UI that + * has to decide whether the login it shows is its own to end. + * + * Ending a login early is `cancelAgentLogin`, which goes through doctor's + * cancellation token and kills the CLI. Closing its stdin would not do: the CLI + * ignores EOF once it is waiting on its browser callback. + */ +import { + cancelDoctorLogin, + doctorLoginStatus, + sendDoctorLoginCode, + startDoctorLogin, + type DoctorLoginOutput, + type DoctorLoginStatus, +} from '../../api/commands'; +import { listenToEvent, type UnlistenFn } from '../../transport'; + +/** Output lines kept for display, oldest first. */ +const MAX_OUTPUT_LINES = 40; + +/** How a login ended, short of failing. */ +export type AgentLoginOutcome = 'completed' | 'cancelled'; + +/** + * How this record came to follow the run it shows: `started` if this client's + * `startAgentLogin` began it, `attached` if the run was already running when + * this client picked it up — a start the backend answered "already running", or + * an `attachAgentLogin` that found it. + */ +export type AgentLoginOrigin = 'started' | 'attached'; + +export interface AgentLoginState { + /** Check id of the login in flight, or of the last one that ran. */ + checkId: string | null; + /** + * Run id of that login — the identity its events, codes and cancel go by. + * Null until the backend has named the run it started or was found running. + */ + runId: string | null; + /** + * Whether this client began that run or picked up one already running — see + * `AgentLoginOrigin`. Null until the backend has answered the start, and + * whenever nothing is followed. For a UI deciding whether the login it shows + * is its own to end on the way out: a start it confirmed may have re-attached + * to a login someone else is watching, and only the backend's answer says so. + */ + origin: AgentLoginOrigin | null; + running: boolean; + /** + * The sign-in URL the CLI printed, when it printed one. The whole point of + * showing it: the CLI's own `open` can fail silently inside the Tauri + * process, and a user driving Staged through the web server never had a + * browser on the host at all. + */ + url: string | null; + /** Tail of the fix's output, so the user can see what it is waiting on. */ + output: string[]; + /** Failure of the fix itself, of starting it, or of sending a code. */ + error: string | null; + /** The code being typed. Kept here so it survives the pane re-rendering. */ + code: string; + sending: boolean; + /** A cancel has been asked for and the backend has yet to report the end. */ + cancelling: boolean; +} + +export const agentLogin: AgentLoginState = $state({ + checkId: null, + runId: null, + origin: null, + running: false, + url: null, + output: [], + error: null, + code: '', + sending: false, + cancelling: false, +}); + +/** The shared record, but only when it describes `checkId`'s login. */ +export function agentLoginFor(checkId: string | null | undefined): AgentLoginState | null { + if (!checkId || agentLogin.checkId !== checkId) return null; + return agentLogin; +} + +/** + * The sign-in URL carried by a login output line, if any. + * + * The first URL in the run wins: the CLIs print the authorize URL before + * anything else (`If the browser didn't open, visit: `), so a later one is + * more likely to be a docs or support link than a better address. + */ +export function extractLoginUrl(line: string): string | null { + const match = /https?:\/\/[^\s<>"'`]+/.exec(line); + if (!match) return null; + // Trailing sentence punctuation is not part of the URL. + return match[0].replace(/[.,;:!)\]]+$/, ''); +} + +function resetRecord(checkId: string | null, running: boolean) { + agentLogin.checkId = checkId; + agentLogin.runId = null; + agentLogin.origin = null; + agentLogin.running = running; + agentLogin.url = null; + agentLogin.output = []; + agentLogin.error = null; + agentLogin.code = ''; + agentLogin.sending = false; + agentLogin.cancelling = false; +} + +/** + * Discard what a finished login left behind, so a UI that opens on `checkId` + * again doesn't lead with the last attempt's error. A running login is never + * cleared — it is still writing to the record. + */ +export function clearAgentLogin(checkId: string) { + if (agentLogin.running || agentLogin.checkId !== checkId) return; + resetRecord(null, false); +} + +interface OutputLine { + seq: number; + text: string; +} + +/** + * One start or attach, and the run it follows. Every continuation — the start's + * answer, a status snapshot, an event — checks that its attempt is still the + * current one before touching the record, so an attempt superseded by a newer + * one can't write into it. + */ +interface Attempt { + checkId: string; + /** + * The run followed, once the backend has named it: the start's answer names + * the run it began or found, the status snapshot the run it has. Until then + * events for the check are held in `pending`. + */ + runId: string | null; + /** + * Resolves with the run id once it is known, or with null if the attempt + * ended first — for a code or a cancel asked for before the start answered. + */ + runIdKnown: Promise; + resolveRunId: (runId: string | null) => void; + /** Events received before `runId` was known, in arrival order. */ + pending: DoctorLoginOutput[]; + /** + * Still asking the backend whether a login is running. Until it says so the + * record is left alone: nothing may be running, and a UI must not show a + * login that doesn't exist. + */ + probing: boolean; + /** + * The backend has answered the start or the probe, so a re-sync on reconnect + * has a run to ask about. Before that, a reconnect only records itself in + * `gapBeforeAnswer` for the answer to act on. + */ + answered: boolean; + /** + * The event channel reconnected while the answer was still in flight, so + * events emitted in that gap were missed — possibly the run's first lines, + * the sign-in URL among them. Either answer catches up from the backend when + * this is set. A probe's answer is itself a snapshot, but not necessarily one + * taken after the reconnect: in web mode the status is an HTTP fetch while + * events ride the socket, so the backend can take the snapshot, the socket + * can drop and come back, and the answer land last — with the lines emitted + * between the snapshot and the reconnect in neither, and the next live line + * moving `nextSeq` past them for good. + */ + gapBeforeAnswer: boolean; + /** The lines shown, with the `seq` each arrived under. */ + lines: OutputLine[]; + /** `seq` of the next line expected; one below it was already shown or replayed. */ + nextSeq: number; + settled: boolean; + promise: Promise; + resolve: (outcome: AgentLoginOutcome | null) => void; + reject: (error: Error) => void; +} + +let unlisten: UnlistenFn | null = null; +/** The attempt in flight, if any — owner of the record and the listener. */ +let current: Attempt | null = null; + +function stopWatching() { + unlisten?.(); + unlisten = null; +} + +function errorText(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} + +function newAttempt(checkId: string, probing: boolean): Attempt { + let resolve!: Attempt['resolve']; + let reject!: Attempt['reject']; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + let resolveRunId!: Attempt['resolveRunId']; + const runIdKnown = new Promise((res) => { + resolveRunId = res; + }); + const attempt: Attempt = { + checkId, + runId: null, + runIdKnown, + resolveRunId, + pending: [], + probing, + answered: false, + gapBeforeAnswer: false, + lines: [], + nextSeq: 0, + settled: false, + promise, + resolve, + reject, + }; + current = attempt; + return attempt; +} + +/** Whether `attempt` still owns the record and the listener. */ +function live(attempt: Attempt): boolean { + return current === attempt && !attempt.settled; +} + +/** Give up the record and the listener. Every end goes through here. */ +function finish(attempt: Attempt) { + attempt.settled = true; + attempt.pending = []; + // A no-op if the run was named; otherwise releases a code or cancel that was + // waiting for the name, which now has nothing to send to. + attempt.resolveRunId(null); + stopWatching(); + if (current === attempt) current = null; +} + +function complete(attempt: Attempt, outcome: AgentLoginOutcome) { + finish(attempt); + if (outcome === 'cancelled') { + // A neutral end: nothing to render, no error to lead with next time. + resetRecord(null, false); + } else { + agentLogin.running = false; + agentLogin.sending = false; + agentLogin.cancelling = false; + } + attempt.resolve(outcome); +} + +function fail(attempt: Attempt, error: string) { + finish(attempt); + agentLogin.running = false; + agentLogin.sending = false; + agentLogin.cancelling = false; + agentLogin.error = error; + attempt.reject(new Error(error)); +} + +/** A probe that found nothing — or was superseded — leaves the record alone. */ +function abandon(attempt: Attempt) { + finish(attempt); + attempt.resolve(null); +} + +function render(attempt: Attempt) { + agentLogin.output = attempt.lines.map((line) => line.text); + if (agentLogin.url) return; + for (const line of attempt.lines) { + const url = extractLoginUrl(line.text); + if (url) { + agentLogin.url = url; + return; + } + } +} + +/** + * Show a line delivered live — unless it, or a later one, was already shown: + * a redelivery, or a line a status snapshot had covered. + */ +function applyLine(attempt: Attempt, seq: number, text: string) { + if (seq < attempt.nextSeq) return; + attempt.nextSeq = seq + 1; + attempt.lines = [...attempt.lines, { seq, text }].slice(-MAX_OUTPUT_LINES); + if (!attempt.probing) render(attempt); +} + +/** + * Replace what is shown with the backend's snapshot, keeping the lines that + * arrived live after it was taken. The snapshot's `output` covers the `seq`s + * from `nextSeq - output.length` up to `nextSeq`: a live line below that is + * either in it or older than the tail, and one at or above it came later. + */ +function applySnapshot(attempt: Attempt, status: DoctorLoginStatus) { + const firstSeq = status.nextSeq - status.output.length; + const replayed = status.output.map((text, i) => ({ seq: firstSeq + i, text })); + const since = attempt.lines.filter((line) => line.seq >= status.nextSeq); + attempt.lines = [...replayed, ...since].slice(-MAX_OUTPUT_LINES); + attempt.nextSeq = Math.max(attempt.nextSeq, status.nextSeq); + render(attempt); +} + +/** + * The backend has named the run this attempt follows. From here on only that + * run's events count; the ones held while the name was in flight are replayed + * through the same filter, so an earlier run's late `done` in that window is + * dropped and the followed run's own lines are shown. + * + * The replay can end the attempt (a held `done` of the followed run), so a + * caller with more to do checks `live` afterwards. + */ +function adoptRun(attempt: Attempt, runId: string) { + attempt.runId = runId; + agentLogin.runId = runId; + attempt.resolveRunId(runId); + const held = attempt.pending; + attempt.pending = []; + for (const output of held) handleEvent(attempt, output); +} + +/** + * Bring a running attempt back in line with the backend: lines it missed are + * replayed from the tail, and a login the backend no longer has is settled. + * + * "No longer has" includes a newer run holding the check's slot: the followed + * run's `done` was missed, and the newer run is someone else's login. That + * settle is `completed`, not because the login necessarily succeeded but + * because its `done` is gone — it either passed before this client's listener + * existed or was lost across a reconnect — and the record must not stay + * `running` for a subprocess that has exited. Callers re-run the doctor checks + * on `completed`, and the auth probe reports whether it actually signed in. + */ +async function syncFromBackend(attempt: Attempt) { + const status = await doctorLoginStatus(attempt.checkId); + if (!live(attempt)) return; + if (!status.running || status.runId !== attempt.runId) { + complete(attempt, 'completed'); + return; + } + applySnapshot(attempt, status); +} + +/** + * `syncFromBackend` for a run the backend has confirmed, tolerating a failed + * status: the login is alive whether or not this client could ask about it, so + * a rejected sync is logged and the run kept — the next event or reconnect + * catches up — rather than reported as the login's failure. + */ +function catchUp(attempt: Attempt) { + syncFromBackend(attempt).catch((e) => { + console.warn(`[agentLogin] re-sync of ${attempt.checkId} failed:`, e); + }); +} + +/** A reconnect of the event channel: events emitted in the gap were missed. */ +function resync(attempt: Attempt) { + if (!attempt.answered) { + // No run to ask about yet. Recorded for the answer, which otherwise would + // take the gap for a quiet stretch and never fetch what it dropped. + attempt.gapBeforeAnswer = true; + return; + } + catchUp(attempt); +} + +function handleEvent(attempt: Attempt, output: DoctorLoginOutput) { + // The check id is only a cheap pre-filter; the run id is the identity. + if (!live(attempt) || output.checkId !== attempt.checkId) return; + if (attempt.runId === null) { + // Which run this attempt follows isn't known yet. Held rather than judged + // by check id: this is exactly the window in which an earlier run's `done` + // can arrive for the check. Bounded to what could ever be shown. + attempt.pending = [...attempt.pending, output].slice(-MAX_OUTPUT_LINES); + return; + } + if (output.runId !== attempt.runId) return; + if (output.line !== null) applyLine(attempt, output.seq, output.line); + if (!output.done) return; + if (output.cancelled) complete(attempt, 'cancelled'); + else if (output.error !== null) fail(attempt, output.error); + else complete(attempt, 'completed'); +} + +/** + * Start a login for `checkId`, resolving with how it ended and rejecting with + * its failure (which is also left on the shared record for the UI to render). + * + * The fix is started from `onEstablished`, not beside the `listenToEvent` call: + * registration is asynchronous, and a login that fails to spawn emits its + * `done` event immediately — lost in that gap, it would leave `running` true + * with no way back. `onEstablished` also fires on every web-socket reconnect: + * the start is latched to the first one, and each later one re-syncs the record + * from the backend, since events emitted in the gap were missed. A reconnect + * that lands while the start's answer is still in flight can't ask yet — there + * is no run id to ask about — so it is noted, and the answer does the asking. + * + * A start the backend answers "already running" is a re-attach, not a failure: + * the CLI is alive and waiting for exactly the code this record can send it, so + * its output so far is replayed and the record follows it to its end. Either + * way the answer names the run, and the record follows that run alone. + * + * A start answered "started" with no gap asks nothing more: this listener was + * live before the fix existed, so every line is on its way here — and a status + * that found the run already over would settle it as `completed` ahead of a + * `done` still in flight, trading a fast failure's error for a blank end. + */ +export function startAgentLogin(checkId: string): Promise { + if (current) { + if (current.probing) { + // A probe is only a question; the start answers it. + abandon(current); + } else if (current.checkId === checkId) { + // Same login: follow the one that is running rather than start another + // the backend would refuse. + return current.promise.then(asOutcome); + } else { + // One record, one login: taking it over would strand the running fix's + // awaiter and hide the record it is still writing to. + return Promise.reject(new Error(`A login is already running for ${current.checkId}`)); + } + } + stopWatching(); + resetRecord(checkId, true); + const attempt = newAttempt(checkId, false); + + let started = false; + unlisten = listenToEvent( + 'doctor-login-output', + (output) => handleEvent(attempt, output), + { + onEstablished: () => { + if (!live(attempt)) return; + if (started) { + resync(attempt); + return; + } + started = true; + startDoctorLogin(checkId).then( + (start) => { + if (!live(attempt)) return; + try { + attempt.answered = true; + // Which the backend says it did — began the run, or found one + // already running — is what a UI reads to tell its own login + // from one this click re-attached to. + agentLogin.origin = start.outcome === 'alreadyRunning' ? 'attached' : 'started'; + adoptRun(attempt, start.runId); + if (!live(attempt)) return; + // A re-attach has the run's earlier output to fetch; a start only + // has something to fetch if a reconnect while the answer was in + // flight dropped lines. Tolerant of a failed status either way: + // the backend has just confirmed the run is alive. + if (start.outcome === 'alreadyRunning' || attempt.gapBeforeAnswer) { + catchUp(attempt); + } + } catch (e) { + // A throw in the answer's own handling — the held-events replay, + // say — is not the start's failure, but left uncaught it would + // escape as an unhandled rejection with the record still + // `running` for a login nothing follows. + if (live(attempt)) fail(attempt, errorText(e)); + } + }, + (e) => { + // The start itself was refused or never spawned. Only that is the + // login's failure — hence the two-argument `then`, which keeps a + // rejection in the catch-up above out of this handler. + if (live(attempt)) fail(attempt, errorText(e)); + } + ); + }, + } + ); + return attempt.promise.then(asOutcome); +} + +/** A start never resolves `null` — only a probe does; this satisfies the type. */ +function asOutcome(outcome: AgentLoginOutcome | null): AgentLoginOutcome { + return outcome ?? 'completed'; +} + +/** + * Pick up a login for `checkId` that is already running on the backend — one + * started from the other entry point, from another client, or before this view + * reloaded — so its URL and code box come back instead of a button the backend + * would answer "already running". Resolves `null` straight away when nothing is + * running, leaving the record untouched; otherwise it takes the record, follows + * the run the status names, and resolves like `startAgentLogin` when that run + * ends. + * + * The listener is registered before the backend is asked, so nothing the login + * prints after the snapshot can be missed; events that arrive while the snapshot + * is in flight are held, then the run's own are merged by `seq` once it lands. + * Unless the channel reconnected in the meantime — then the snapshot may predate + * the gap, and the answer catches up from the backend as well. + */ +export function attachAgentLogin(checkId: string): Promise { + if (current) { + // A run or a probe for this check is already being followed. + if (current.checkId === checkId) return current.promise; + // Another check's login owns the record. + if (!current.probing) return Promise.resolve(null); + // A probe for another check that hasn't answered yet: the latest ask wins. + abandon(current); + } + stopWatching(); + const attempt = newAttempt(checkId, true); + + let asked = false; + unlisten = listenToEvent( + 'doctor-login-output', + (output) => handleEvent(attempt, output), + { + onEstablished: () => { + if (!live(attempt)) return; + if (asked) { + resync(attempt); + return; + } + asked = true; + doctorLoginStatus(checkId) + .then((status) => { + if (!live(attempt)) return; + attempt.answered = true; + if (!status.running || status.runId === null) { + abandon(attempt); + return; + } + resetRecord(checkId, true); + agentLogin.origin = 'attached'; + attempt.probing = false; + adoptRun(attempt, status.runId); + if (!live(attempt)) return; + applySnapshot(attempt, status); + // The snapshot is only current if the backend took it after the + // channel's last reconnect, and a reconnect while it was in flight + // says nothing about that (see `gapBeforeAnswer`). Asking again is + // redundant when it was — the merge is by `seq` — and the backend + // has just confirmed the run alive, so a failed catch-up is a + // warning, not the login's failure. + if (attempt.gapBeforeAnswer) catchUp(attempt); + }) + .catch((e) => { + if (!live(attempt)) return; + finish(attempt); + attempt.reject(new Error(errorText(e))); + }); + }, + } + ); + return attempt.promise; +} + +/** + * The attempt that owns the record while a login is shown as running, if any. + * A probe never owns it, and a settled attempt has let go. + */ +function runningAttempt(): Attempt | null { + const attempt = current; + if (!attempt || attempt.probing || !agentLogin.running) return null; + return attempt; +} + +/** + * Send the typed code to the running login — to the run this record follows, + * so a code typed for a login that has since ended is refused by the backend + * (and the refusal shown) rather than delivered to a newer login for the check. + * A send made before the backend has named the run waits for the name. + * + * The input stays open afterwards — see the module docs on why nothing in the + * stream announces a re-prompt — so only the sent text is cleared. + */ +export async function submitAgentLoginCode(): Promise { + const attempt = runningAttempt(); + const code = agentLogin.code.trim(); + if (!attempt || !code || agentLogin.sending) return; + agentLogin.sending = true; + agentLogin.error = null; + try { + const runId = await attempt.runIdKnown; + // Ended before it was named: its end is already on the record. + if (runId === null || !live(attempt)) return; + await sendDoctorLoginCode(attempt.checkId, runId, code); + agentLogin.code = ''; + } catch (e) { + agentLogin.error = errorText(e); + } finally { + agentLogin.sending = false; + } +} + +/** + * Ask the backend to stop the running login — the run this record follows. The + * end itself arrives as a `done` event with `cancelled` set, which settles the + * record as a neutral end — no error, code box gone, record cleared — so + * `cancelling` stays up until then. Idempotent while that is pending. A cancel + * asked for before the backend has named the run waits for the name. + * + * A backend that reports the run not found has already lost the login this + * record still shows — its `done` was missed, and any login now running for the + * check is a newer one — so the record is re-synced from it instead of waiting + * for an end that won't come. + */ +export async function cancelAgentLogin(): Promise { + const attempt = runningAttempt(); + if (!attempt || agentLogin.cancelling) return; + agentLogin.cancelling = true; + agentLogin.error = null; + try { + const runId = await attempt.runIdKnown; + // Ended before it was named: its end is already on the record. + if (runId === null || !live(attempt)) return; + const cancelled = await cancelDoctorLogin(attempt.checkId, runId); + if (!cancelled && live(attempt)) await syncFromBackend(attempt); + } catch (e) { + if (!live(attempt)) return; + agentLogin.cancelling = false; + agentLogin.error = errorText(e); + } +} diff --git a/apps/staged/src/lib/features/doctor/agentLogin.test.ts b/apps/staged/src/lib/features/doctor/agentLogin.test.ts new file mode 100644 index 000000000..2ebe5c21d --- /dev/null +++ b/apps/staged/src/lib/features/doctor/agentLogin.test.ts @@ -0,0 +1,967 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { DoctorLoginOutput, DoctorLoginStart, DoctorLoginStatus } from '../../api/commands'; + +/** A registration made by the module under test through `listenToEvent`. */ +interface Registration { + event: string; + callback: (payload: DoctorLoginOutput) => void; + onEstablished?: () => void; + unlisten: ReturnType; +} + +const AUTHORIZE_LINE = 'If the browser didn’t open, visit: https://claude.ai/oauth'; +/** The run the backend names unless a test says otherwise. */ +const RUN = 'run-1'; +/** An earlier run of the same check, whose events must not reach the record. */ +const EARLIER_RUN = 'run-0'; + +describe('agentLogin', () => { + let startDoctorLogin: ReturnType; + let sendDoctorLoginCode: ReturnType; + let cancelDoctorLogin: ReturnType; + let doctorLoginStatus: ReturnType; + let registrations: Registration[]; + /** Sequence number the next `line()` carries, as the backend would number it. */ + let nextSeq: number; + + beforeEach(() => { + vi.resetModules(); + // The store is a .svelte.ts module compiled without the Svelte plugin + // here, so the rune calls resolve to this pass-through global. + vi.stubGlobal('$state', (initial: unknown) => initial); + + registrations = []; + nextSeq = 0; + startDoctorLogin = vi.fn().mockResolvedValue(started()); + sendDoctorLoginCode = vi.fn().mockResolvedValue(undefined); + cancelDoctorLogin = vi.fn().mockResolvedValue(true); + doctorLoginStatus = vi.fn().mockResolvedValue(idle()); + vi.doMock('../../api/commands', () => ({ + startDoctorLogin, + sendDoctorLoginCode, + cancelDoctorLogin, + doctorLoginStatus, + })); + vi.doMock('../../transport', () => ({ + listenToEvent: ( + event: string, + callback: (payload: DoctorLoginOutput) => void, + opts?: { onEstablished?: () => void } + ) => { + const unlisten = vi.fn(); + registrations.push({ event, callback, onEstablished: opts?.onEstablished, unlisten }); + return unlisten; + }, + })); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.doUnmock('../../api/commands'); + vi.doUnmock('../../transport'); + }); + + function load() { + return import('./agentLogin.svelte'); + } + + /** The single live registration, failing loudly if there isn't exactly one. */ + function only(): Registration { + const live = registrations.filter((r) => r.unlisten.mock.calls.length === 0); + expect(live).toHaveLength(1); + return live[0]; + } + + function started(runId = RUN): DoctorLoginStart { + return { outcome: 'started', runId }; + } + + function alreadyRunning(runId = RUN): DoctorLoginStart { + return { outcome: 'alreadyRunning', runId }; + } + + function line( + checkId: string, + text: string, + seq: number = nextSeq++, + runId = RUN + ): DoctorLoginOutput { + return { checkId, runId, line: text, seq, done: false, error: null, cancelled: false }; + } + + function done( + checkId: string, + error: string | null = null, + cancelled = false, + runId = RUN + ): DoctorLoginOutput { + return { checkId, runId, line: null, seq: nextSeq, done: true, error, cancelled }; + } + + function idle(): DoctorLoginStatus { + return { running: false, runId: null, output: [], nextSeq: 0 }; + } + + function running(output: string[], runId = RUN): DoctorLoginStatus { + return { running: true, runId, output, nextSeq: output.length }; + } + + /** Let the promise chains behind a start or a status answer run to the end. */ + function flush(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); + } + + it('pulls the sign-in URL out of the line the CLI actually prints', async () => { + const { extractLoginUrl } = await load(); + + expect( + extractLoginUrl( + 'If the browser didn’t open, visit: ' + + 'https://claude.ai/oauth/authorize?code=true&client_id=abc&response_type=code' + ) + ).toBe('https://claude.ai/oauth/authorize?code=true&client_id=abc&response_type=code'); + // Sentence punctuation is not part of the address. + expect(extractLoginUrl('Open https://example.com/login.')).toBe('https://example.com/login'); + expect(extractLoginUrl('Opening browser to sign in…')).toBeNull(); + }); + + it('starts the fix only once the listener is live', async () => { + const { agentLogin, startAgentLogin } = await load(); + + const settled = startAgentLogin('ai-agent-claude'); + // Registration is asynchronous: a `done` emitted before it went live would + // be lost, so nothing may be started until `onEstablished`. + expect(startDoctorLogin).not.toHaveBeenCalled(); + expect(agentLogin.running).toBe(true); + expect(agentLogin.runId).toBeNull(); + + const registration = only(); + expect(registration.event).toBe('doctor-login-output'); + registration.onEstablished?.(); + expect(startDoctorLogin).toHaveBeenCalledWith('ai-agent-claude'); + await flush(); + // The start's answer names the run the record follows from here on. + expect(agentLogin.runId).toBe(RUN); + + // A web-socket reconnect re-establishes the same listener; the fix is + // already running, and starting a second would be refused by the backend. + // The reconnect re-syncs from the backend instead, which still has the run. + doctorLoginStatus.mockResolvedValue(running([])); + registration.onEstablished?.(); + await flush(); + expect(startDoctorLogin).toHaveBeenCalledTimes(1); + expect(agentLogin.running).toBe(true); + + registration.callback(done('ai-agent-claude')); + await expect(settled).resolves.toBe('completed'); + expect(agentLogin.running).toBe(false); + expect(registration.unlisten).toHaveBeenCalled(); + }); + + it('records the sign-in URL and the output tail, ignoring other checks', async () => { + const { agentLogin, startAgentLogin } = await load(); + + const settled = startAgentLogin('ai-agent-claude'); + const registration = only(); + registration.onEstablished?.(); + await flush(); + registration.callback(line('ai-agent-claude', 'Opening browser to sign in…')); + registration.callback(line('ai-agent-claude', AUTHORIZE_LINE)); + registration.callback(line('ai-agent-codex', 'https://auth.openai.com/other')); + registration.callback(done('ai-agent-codex')); + + expect(agentLogin.url).toBe('https://claude.ai/oauth'); + expect(agentLogin.output).toEqual(['Opening browser to sign in…', AUTHORIZE_LINE]); + // Another check's `done` must not finish this login. + expect(agentLogin.running).toBe(true); + + registration.callback(done('ai-agent-claude')); + await settled; + }); + + it('ignores a done for the same check from an earlier run', async () => { + const { agentLogin, startAgentLogin } = await load(); + + const settled = startAgentLogin('ai-agent-claude'); + const registration = only(); + registration.onEstablished?.(); + await flush(); + registration.callback(line('ai-agent-claude', AUTHORIZE_LINE)); + + // The earlier run of this check ended — with a failure, a cancel, and + // plainly — after this one was started. None of those ends is this run's. + registration.callback(done('ai-agent-claude', 'login failed', false, EARLIER_RUN)); + registration.callback(done('ai-agent-claude', null, true, EARLIER_RUN)); + registration.callback(done('ai-agent-claude', null, false, EARLIER_RUN)); + expect(agentLogin.running).toBe(true); + expect(agentLogin.error).toBeNull(); + expect(agentLogin.url).toBe('https://claude.ai/oauth'); + + registration.callback(done('ai-agent-claude')); + await expect(settled).resolves.toBe('completed'); + }); + + it('ignores output from an earlier run of the same check', async () => { + const { agentLogin, startAgentLogin } = await load(); + + const settled = startAgentLogin('ai-agent-claude'); + const registration = only(); + registration.onEstablished?.(); + await flush(); + + // Sequence numbers are per run, so the earlier run's line 0 must not be + // mistaken for this run's — neither shown nor counted against `nextSeq`. + registration.callback( + line('ai-agent-claude', 'visit: https://claude.ai/oauth/stale', 0, EARLIER_RUN) + ); + expect(agentLogin.output).toEqual([]); + expect(agentLogin.url).toBeNull(); + + registration.callback(line('ai-agent-claude', AUTHORIZE_LINE, 0)); + expect(agentLogin.output).toEqual([AUTHORIZE_LINE]); + expect(agentLogin.url).toBe('https://claude.ai/oauth'); + + registration.callback(done('ai-agent-claude')); + await settled; + }); + + it('holds events that arrive before the start is answered and keeps only the new run’s', async () => { + const { agentLogin, startAgentLogin } = await load(); + + const settled = startAgentLogin('ai-agent-claude'); + const registration = only(); + registration.onEstablished?.(); + expect(agentLogin.runId).toBeNull(); + + // The race this exists for: the earlier run released the check's slot, this + // start claimed it, and only then did the earlier run's `done` go out — so + // it lands here before the start's answer has named the new run. The new + // run's first line is right behind it. + registration.callback(done('ai-agent-claude', null, false, EARLIER_RUN)); + registration.callback(line('ai-agent-claude', 'Opening browser to sign in…', 0)); + expect(agentLogin.running).toBe(true); + expect(agentLogin.output).toEqual([]); + + await flush(); + expect(agentLogin.runId).toBe(RUN); + expect(agentLogin.running).toBe(true); + expect(agentLogin.output).toEqual(['Opening browser to sign in…']); + + registration.callback(line('ai-agent-claude', AUTHORIZE_LINE, 1)); + expect(agentLogin.url).toBe('https://claude.ai/oauth'); + registration.callback(done('ai-agent-claude')); + await expect(settled).resolves.toBe('completed'); + }); + + it('reports a failed login on the record and to the caller', async () => { + const { agentLogin, startAgentLogin } = await load(); + + const settled = startAgentLogin('ai-agent-claude'); + const registration = only(); + registration.onEstablished?.(); + registration.callback(done('ai-agent-claude', 'doctor: fix timed out after 600s')); + + await expect(settled).rejects.toThrow('doctor: fix timed out after 600s'); + expect(agentLogin.error).toBe('doctor: fix timed out after 600s'); + expect(agentLogin.running).toBe(false); + }); + + it('reports a login that never spawned instead of waiting on it', async () => { + startDoctorLogin.mockRejectedValue(new Error('No login fix available for ai-agent-goose')); + const { agentLogin, startAgentLogin } = await load(); + + const settled = startAgentLogin('ai-agent-goose'); + only().onEstablished?.(); + + await expect(settled).rejects.toThrow('No login fix available for ai-agent-goose'); + expect(agentLogin.running).toBe(false); + }); + + it('keeps the code box open after a send, so a rejected code can be retried', async () => { + const { agentLogin, startAgentLogin, submitAgentLoginCode } = await load(); + + const settled = startAgentLogin('ai-agent-claude'); + const registration = only(); + registration.onEstablished?.(); + + agentLogin.code = ' abc-123 '; + await submitAgentLoginCode(); + // Sent to the run the start named, not just the check. + expect(sendDoctorLoginCode).toHaveBeenCalledWith('ai-agent-claude', RUN, 'abc-123'); + // Only the sent text is cleared: the CLI re-prompts on a code it rejects, + // and nothing in the stream announces that prompt. + expect(agentLogin.code).toBe(''); + expect(agentLogin.running).toBe(true); + expect(agentLogin.error).toBeNull(); + + // Nothing to send is not an error, and neither is a finished login. + await submitAgentLoginCode(); + registration.callback(done('ai-agent-claude')); + await settled; + agentLogin.code = 'late'; + await submitAgentLoginCode(); + expect(sendDoctorLoginCode).toHaveBeenCalledTimes(1); + }); + + it('surfaces a refused code without ending the login', async () => { + sendDoctorLoginCode.mockRejectedValue(new Error('No active login for ai-agent-claude')); + const { agentLogin, startAgentLogin, submitAgentLoginCode } = await load(); + + const settled = startAgentLogin('ai-agent-claude'); + const registration = only(); + registration.onEstablished?.(); + agentLogin.code = 'abc-123'; + await submitAgentLoginCode(); + + expect(agentLogin.error).toBe('No active login for ai-agent-claude'); + expect(agentLogin.sending).toBe(false); + expect(agentLogin.running).toBe(true); + + registration.callback(done('ai-agent-claude')); + await settled; + }); + + it('refuses a code for a run the backend has replaced, and settles on the cancel', async () => { + const refusal = + 'The login this code was typed for has ended; a newer login is running for ' + + 'ai-agent-claude and the code was not delivered to it'; + sendDoctorLoginCode.mockRejectedValue(new Error(refusal)); + // This record's run ended and its `done` was missed; another client has + // since started a new run for the same check. + cancelDoctorLogin.mockResolvedValue(false); + doctorLoginStatus.mockResolvedValue(running([AUTHORIZE_LINE], 'run-2')); + const { agentLogin, cancelAgentLogin, startAgentLogin, submitAgentLoginCode } = await load(); + + const settled = startAgentLogin('ai-agent-claude'); + only().onEstablished?.(); + await flush(); + expect(agentLogin.runId).toBe(RUN); + + // The code names this record's run, so the backend refuses rather than + // handing it to the newer login — and the refusal is what the user sees. + agentLogin.code = 'abc-123'; + await submitAgentLoginCode(); + expect(sendDoctorLoginCode).toHaveBeenCalledWith('ai-agent-claude', RUN, 'abc-123'); + expect(agentLogin.error).toBe(refusal); + expect(agentLogin.code).toBe('abc-123'); + expect(agentLogin.running).toBe(true); + + // Cancelling names the run too: the newer login is left alone, and the + // backend not finding this run is the cue to re-sync — which finds a + // different run holding the slot and settles this one. + await cancelAgentLogin(); + expect(cancelDoctorLogin).toHaveBeenCalledWith('ai-agent-claude', RUN); + await expect(settled).resolves.toBe('completed'); + expect(agentLogin.running).toBe(false); + expect(agentLogin.cancelling).toBe(false); + // The newer run's tail was not adopted: it is someone else's login. + expect(agentLogin.output).toEqual([]); + expect(agentLogin.runId).toBe(RUN); + }); + + it('refuses a second login rather than taking the record from the first', async () => { + const { agentLogin, startAgentLogin } = await load(); + + const settled = startAgentLogin('ai-agent-claude'); + const registration = only(); + registration.onEstablished?.(); + + await expect(startAgentLogin('ai-agent-codex')).rejects.toThrow(/already running/); + expect(agentLogin.checkId).toBe('ai-agent-claude'); + expect(startDoctorLogin).toHaveBeenCalledTimes(1); + + // The same check's login is joined, not refused: both entry points end up + // following the one run. + const joined = startAgentLogin('ai-agent-claude'); + expect(startDoctorLogin).toHaveBeenCalledTimes(1); + + registration.callback(done('ai-agent-claude')); + await expect(settled).resolves.toBe('completed'); + await expect(joined).resolves.toBe('completed'); + }); + + it('scopes the record to one check and clears only a finished one', async () => { + const { agentLogin, agentLoginFor, clearAgentLogin, startAgentLogin } = await load(); + + const settled = startAgentLogin('ai-agent-claude'); + const registration = only(); + registration.onEstablished?.(); + + expect(agentLoginFor('ai-agent-claude')).toBe(agentLogin); + expect(agentLoginFor('ai-agent-codex')).toBeNull(); + expect(agentLoginFor(null)).toBeNull(); + + // A running login owns the record; clearing it would hide live output. + clearAgentLogin('ai-agent-claude'); + expect(agentLoginFor('ai-agent-claude')).toBe(agentLogin); + + registration.callback(done('ai-agent-claude', 'login failed')); + await expect(settled).rejects.toThrow('login failed'); + + clearAgentLogin('ai-agent-codex'); + expect(agentLogin.error).toBe('login failed'); + clearAgentLogin('ai-agent-claude'); + expect(agentLoginFor('ai-agent-claude')).toBeNull(); + expect(agentLogin.error).toBeNull(); + expect(agentLogin.runId).toBeNull(); + }); + + it('cancels through the backend and ends the record without an error', async () => { + const { agentLogin, agentLoginFor, cancelAgentLogin, startAgentLogin } = await load(); + + const settled = startAgentLogin('ai-agent-claude'); + const registration = only(); + registration.onEstablished?.(); + registration.callback(line('ai-agent-claude', AUTHORIZE_LINE)); + + await cancelAgentLogin(); + expect(cancelDoctorLogin).toHaveBeenCalledWith('ai-agent-claude', RUN); + // The request only asks; the end comes from the backend once the CLI is + // dead, so the code box stays up until then — marked as on its way out. + expect(agentLogin.running).toBe(true); + expect(agentLogin.cancelling).toBe(true); + // A second click while that is pending asks nothing more. + await cancelAgentLogin(); + expect(cancelDoctorLogin).toHaveBeenCalledTimes(1); + + registration.callback(done('ai-agent-claude', null, true)); + await expect(settled).resolves.toBe('cancelled'); + expect(agentLogin.running).toBe(false); + expect(agentLogin.cancelling).toBe(false); + expect(agentLogin.error).toBeNull(); + // A neutral end: nothing left for a UI to render, and nothing for the next + // open to lead with. + expect(agentLoginFor('ai-agent-claude')).toBeNull(); + expect(agentLogin.output).toEqual([]); + expect(agentLogin.runId).toBeNull(); + expect(agentLogin.origin).toBeNull(); + expect(registration.unlisten).toHaveBeenCalled(); + }); + + it('records whether the start began the run or re-attached to one already running', async () => { + const { agentLogin, startAgentLogin } = await load(); + + const settled = startAgentLogin('ai-agent-claude'); + const registration = only(); + // Nothing is claimed until the backend answers: a start may yet turn out to + // be a re-attach, and a UI that assumed otherwise would end a login someone + // else is watching on its way out. + expect(agentLogin.origin).toBeNull(); + registration.onEstablished?.(); + await flush(); + expect(agentLogin.origin).toBe('started'); + registration.callback(done('ai-agent-claude')); + await settled; + // A finished login keeps its origin, like the rest of what it left behind. + expect(agentLogin.origin).toBe('started'); + + // The same start, answered "already running": a login this client did not + // begin, so not its caller's to end. + startDoctorLogin.mockResolvedValue(alreadyRunning('run-7')); + doctorLoginStatus.mockResolvedValue(running([AUTHORIZE_LINE], 'run-7')); + const reattached = startAgentLogin('ai-agent-claude'); + const again = only(); + expect(agentLogin.origin).toBeNull(); + again.onEstablished?.(); + await flush(); + expect(agentLogin.origin).toBe('attached'); + expect(agentLogin.runId).toBe('run-7'); + expect(agentLogin.running).toBe(true); + + again.callback(done('ai-agent-claude', null, false, 'run-7')); + await expect(reattached).resolves.toBe('completed'); + }); + + it('reports a throw in the start answer’s own handling as the login’s failure', async () => { + // Fault injection: nothing on that path throws today, so the record itself + // is made to refuse the run id the answer hands it. + vi.stubGlobal( + '$state', + (initial: object) => + new Proxy(initial, { + set(target, key, value) { + if (key === 'runId' && value === RUN) throw new Error('record refused the run'); + return Reflect.set(target, key, value); + }, + }) + ); + const { agentLogin, startAgentLogin } = await load(); + + const settled = startAgentLogin('ai-agent-claude'); + only().onEstablished?.(); + + // Left uncaught this escaped `then` as an unhandled rejection, with the + // record still `running` for a login nothing was following. + await expect(settled).rejects.toThrow('record refused the run'); + expect(agentLogin.running).toBe(false); + expect(agentLogin.error).toBe('record refused the run'); + }); + + it('waits for the run to be named before cancelling it', async () => { + let answer!: (start: DoctorLoginStart) => void; + startDoctorLogin.mockImplementation( + () => + new Promise((resolve) => { + answer = resolve; + }) + ); + const { agentLogin, cancelAgentLogin, startAgentLogin } = await load(); + + const settled = startAgentLogin('ai-agent-claude'); + const registration = only(); + registration.onEstablished?.(); + + // Cancel clicked before the start answered: there is no run id to name yet, + // and cancelling "whatever is running for the check" could hit another + // client's newer login. The cancel waits for the name instead of being + // dropped — a dropped cancel would leave the CLI holding the slot until + // doctor's fix timeout. + const cancelling = cancelAgentLogin(); + await flush(); + expect(agentLogin.cancelling).toBe(true); + expect(cancelDoctorLogin).not.toHaveBeenCalled(); + + answer(started()); + await cancelling; + expect(cancelDoctorLogin).toHaveBeenCalledWith('ai-agent-claude', RUN); + + registration.callback(done('ai-agent-claude', null, true)); + await expect(settled).resolves.toBe('cancelled'); + }); + + it('treats a cancel from another client as the same neutral end', async () => { + const { agentLogin, agentLoginFor, startAgentLogin } = await load(); + + const settled = startAgentLogin('ai-agent-claude'); + const registration = only(); + registration.onEstablished?.(); + + // No cancel was asked for here; the `done` says the run was cancelled. + registration.callback(done('ai-agent-claude', null, true)); + await expect(settled).resolves.toBe('cancelled'); + expect(cancelDoctorLogin).not.toHaveBeenCalled(); + expect(agentLogin.error).toBeNull(); + expect(agentLogin.running).toBe(false); + expect(agentLoginFor('ai-agent-claude')).toBeNull(); + }); + + it('re-syncs from the backend when a cancel finds nothing running', async () => { + cancelDoctorLogin.mockResolvedValue(false); + const { agentLogin, cancelAgentLogin, startAgentLogin } = await load(); + + const settled = startAgentLogin('ai-agent-claude'); + only().onEstablished?.(); + await flush(); + + // The backend has no login for this check — its `done` never reached this + // record — so the record must not stay `running` waiting for one. + await cancelAgentLogin(); + expect(doctorLoginStatus).toHaveBeenCalledWith('ai-agent-claude'); + await expect(settled).resolves.toBe('completed'); + expect(agentLogin.running).toBe(false); + expect(agentLogin.cancelling).toBe(false); + }); + + it('re-attaches when the backend already has this login running, replaying its tail', async () => { + startDoctorLogin.mockResolvedValue(alreadyRunning('run-7')); + doctorLoginStatus.mockResolvedValue( + running(['Opening browser to sign in…', AUTHORIZE_LINE], 'run-7') + ); + const { agentLogin, startAgentLogin, submitAgentLoginCode } = await load(); + + const settled = startAgentLogin('ai-agent-claude'); + const registration = only(); + registration.onEstablished?.(); + await flush(); + + // Not a failure: the CLI is alive and waiting for exactly the code this + // record can send it. The listener stays, the record takes the run id the + // backend named, and the tail restores the URL. + expect(doctorLoginStatus).toHaveBeenCalledWith('ai-agent-claude'); + expect(agentLogin.runId).toBe('run-7'); + expect(agentLogin.running).toBe(true); + expect(agentLogin.error).toBeNull(); + expect(agentLogin.url).toBe('https://claude.ai/oauth'); + expect(agentLogin.output).toEqual(['Opening browser to sign in…', AUTHORIZE_LINE]); + expect(registration.unlisten).not.toHaveBeenCalled(); + + // Lines after the snapshot keep arriving live — under that run id. + registration.callback(line('ai-agent-claude', 'Paste code here if prompted >', 2, 'run-7')); + expect(agentLogin.output).toHaveLength(3); + // And a code goes to that run. + agentLogin.code = 'abc-123'; + await submitAgentLoginCode(); + expect(sendDoctorLoginCode).toHaveBeenCalledWith('ai-agent-claude', 'run-7', 'abc-123'); + + registration.callback(done('ai-agent-claude', null, false, 'run-7')); + await expect(settled).resolves.toBe('completed'); + }); + + it('settles a re-attach whose login ended before the backend answered', async () => { + startDoctorLogin.mockResolvedValue(alreadyRunning()); + const { agentLogin, startAgentLogin } = await load(); + + const settled = startAgentLogin('ai-agent-claude'); + only().onEstablished?.(); + + // Nothing running by the time the status answers, and no `done` left for + // this listener: the record must not stay `running` for a dead process. + await expect(settled).resolves.toBe('completed'); + expect(agentLogin.running).toBe(false); + }); + + it('settles a re-attach whose run the backend has since replaced', async () => { + startDoctorLogin.mockResolvedValue(alreadyRunning()); + // Between the start's answer and the status, that run ended and another + // client started a new one for the check. + doctorLoginStatus.mockResolvedValue(running([AUTHORIZE_LINE], 'run-2')); + const { agentLogin, startAgentLogin } = await load(); + + const settled = startAgentLogin('ai-agent-claude'); + only().onEstablished?.(); + + // The run this record was attached to is gone, and the one running is not + // this record's to show: its `done` was missed, so it is settled. + await expect(settled).resolves.toBe('completed'); + expect(agentLogin.running).toBe(false); + expect(agentLogin.output).toEqual([]); + }); + + it('shows a line delivered while the snapshot was in flight exactly once', async () => { + startDoctorLogin.mockResolvedValue(alreadyRunning()); + let answer!: (status: DoctorLoginStatus) => void; + doctorLoginStatus.mockImplementation( + () => + new Promise((resolve) => { + answer = resolve; + }) + ); + const { agentLogin, startAgentLogin } = await load(); + + const settled = startAgentLogin('ai-agent-claude'); + const registration = only(); + registration.onEstablished?.(); + await flush(); + expect(doctorLoginStatus).toHaveBeenCalled(); + + // Two lines arrive live before the snapshot answers: seq 1 was recorded + // before the snapshot was taken (so it is in it), seq 2 after. + registration.callback(line('ai-agent-claude', AUTHORIZE_LINE, 1)); + registration.callback(line('ai-agent-claude', 'Paste code here if prompted >', 2)); + answer({ + running: true, + runId: RUN, + output: ['Opening browser to sign in…', AUTHORIZE_LINE], + nextSeq: 2, + }); + await flush(); + + expect(agentLogin.output).toEqual([ + 'Opening browser to sign in…', + AUTHORIZE_LINE, + 'Paste code here if prompted >', + ]); + expect(agentLogin.url).toBe('https://claude.ai/oauth'); + + // A redelivery of a line already shown is dropped. + registration.callback(line('ai-agent-claude', 'Paste code here if prompted >', 2)); + expect(agentLogin.output).toHaveLength(3); + + registration.callback(done('ai-agent-claude')); + await settled; + }); + + it('attaches on open to a login the backend reports running', async () => { + doctorLoginStatus.mockResolvedValue(running([AUTHORIZE_LINE], 'run-7')); + const { agentLogin, attachAgentLogin } = await load(); + + const attached = attachAgentLogin('ai-agent-claude'); + // The listener goes live before the backend is asked, so nothing the login + // prints after the snapshot can be missed — and nothing is shown before the + // backend has confirmed there is a login at all. + const registration = only(); + expect(doctorLoginStatus).not.toHaveBeenCalled(); + expect(agentLogin.running).toBe(false); + + registration.onEstablished?.(); + await flush(); + expect(startDoctorLogin).not.toHaveBeenCalled(); + expect(agentLogin.checkId).toBe('ai-agent-claude'); + // The status names the run; the record follows it, as a run this client + // picked up rather than began. + expect(agentLogin.runId).toBe('run-7'); + expect(agentLogin.origin).toBe('attached'); + expect(agentLogin.running).toBe(true); + expect(agentLogin.url).toBe('https://claude.ai/oauth'); + expect(agentLogin.output).toEqual([AUTHORIZE_LINE]); + // No reconnect while the status was in flight, so the snapshot is current + // and nothing more is asked. + expect(doctorLoginStatus).toHaveBeenCalledTimes(1); + + // An earlier run's late `done` for the check is not this run's end. + registration.callback(done('ai-agent-claude', null, false, EARLIER_RUN)); + expect(agentLogin.running).toBe(true); + + registration.callback(done('ai-agent-claude', null, false, 'run-7')); + await expect(attached).resolves.toBe('completed'); + expect(agentLogin.running).toBe(false); + }); + + it('catches up after the probe when the channel reconnected while the status was in flight', async () => { + let answer!: (status: DoctorLoginStatus) => void; + doctorLoginStatus.mockImplementationOnce( + () => + new Promise((resolve) => { + answer = resolve; + }) + ); + const { agentLogin, attachAgentLogin } = await load(); + + const attached = attachAgentLogin('ai-agent-claude'); + const registration = only(); + registration.onEstablished?.(); + expect(doctorLoginStatus).toHaveBeenCalledTimes(1); + + // The socket dropped and came back while the status was in flight. In web + // mode the status is an HTTP fetch and events ride the socket, so the + // backend may have taken its snapshot before the drop: a line the login + // printed between the two is in neither, and the next live line would move + // `nextSeq` past it for good. Nothing can be asked yet — no run is named. + registration.onEstablished?.(); + await flush(); + expect(doctorLoginStatus).toHaveBeenCalledTimes(1); + expect(agentLogin.running).toBe(false); + + // The snapshot from before the gap lands; the gap makes the answer ask + // again, and the second snapshot has the line the first predates. + doctorLoginStatus.mockResolvedValue(running(['Opening browser to sign in…', AUTHORIZE_LINE])); + answer(running(['Opening browser to sign in…'])); + await flush(); + expect(doctorLoginStatus).toHaveBeenCalledTimes(2); + expect(agentLogin.running).toBe(true); + expect(agentLogin.runId).toBe(RUN); + expect(agentLogin.output).toEqual(['Opening browser to sign in…', AUTHORIZE_LINE]); + expect(agentLogin.url).toBe('https://claude.ai/oauth'); + + // Lines after that keep arriving live, merged by `seq`. + registration.callback(line('ai-agent-claude', 'Paste code here if prompted >', 2)); + expect(agentLogin.output).toHaveLength(3); + + registration.callback(done('ai-agent-claude')); + await expect(attached).resolves.toBe('completed'); + }); + + it('holds events that arrive during the probe and keeps only the found run’s', async () => { + let answer!: (status: DoctorLoginStatus) => void; + doctorLoginStatus.mockImplementation( + () => + new Promise((resolve) => { + answer = resolve; + }) + ); + const { agentLogin, attachAgentLogin } = await load(); + + const attached = attachAgentLogin('ai-agent-claude'); + const registration = only(); + registration.onEstablished?.(); + + // While the status is in flight: an earlier run's `done`, then a line of + // the run the status is about to name that the snapshot won't cover. + registration.callback(done('ai-agent-claude', null, false, EARLIER_RUN)); + registration.callback(line('ai-agent-claude', 'Paste code here if prompted >', 1)); + expect(agentLogin.running).toBe(false); + + answer({ running: true, runId: RUN, output: [AUTHORIZE_LINE], nextSeq: 1 }); + await flush(); + expect(agentLogin.running).toBe(true); + expect(agentLogin.runId).toBe(RUN); + expect(agentLogin.output).toEqual([AUTHORIZE_LINE, 'Paste code here if prompted >']); + + registration.callback(done('ai-agent-claude')); + await expect(attached).resolves.toBe('completed'); + }); + + it('leaves the record alone when there is nothing to attach to', async () => { + const { agentLogin, attachAgentLogin } = await load(); + + const attached = attachAgentLogin('ai-agent-claude'); + const registration = only(); + registration.onEstablished?.(); + + await expect(attached).resolves.toBeNull(); + expect(agentLogin.checkId).toBeNull(); + expect(agentLogin.running).toBe(false); + expect(registration.unlisten).toHaveBeenCalled(); + }); + + it('lets a start take over an attach that has not been answered', async () => { + let answer!: (status: DoctorLoginStatus) => void; + doctorLoginStatus.mockImplementation( + () => + new Promise((resolve) => { + answer = resolve; + }) + ); + const { agentLogin, attachAgentLogin, startAgentLogin } = await load(); + + const attached = attachAgentLogin('ai-agent-claude'); + const probe = only(); + probe.onEstablished?.(); + + // The user clicked `Log in` before the probe came back: the probe was only + // a question, and the start answers it. + const settled = startAgentLogin('ai-agent-claude'); + await expect(attached).resolves.toBeNull(); + expect(probe.unlisten).toHaveBeenCalled(); + const registration = only(); + registration.onEstablished?.(); + expect(startDoctorLogin).toHaveBeenCalledTimes(1); + + // The probe's late answer has no record to write into. + answer(running(['stale'])); + await flush(); + expect(agentLogin.output).toEqual([]); + expect(agentLogin.running).toBe(true); + + registration.callback(done('ai-agent-claude')); + await expect(settled).resolves.toBe('completed'); + }); + + it('re-syncs from the backend when the event channel reconnects', async () => { + const { agentLogin, startAgentLogin } = await load(); + + const settled = startAgentLogin('ai-agent-claude'); + const registration = only(); + registration.onEstablished?.(); + await flush(); + registration.callback(line('ai-agent-claude', 'Opening browser to sign in…')); + + // Two lines were emitted while the socket was down; the reconnect replays + // them from the tail without showing the one already here twice. + doctorLoginStatus.mockResolvedValue( + running(['Opening browser to sign in…', AUTHORIZE_LINE, 'Paste code here if prompted >']) + ); + registration.onEstablished?.(); + await flush(); + expect(startDoctorLogin).toHaveBeenCalledTimes(1); + expect(agentLogin.output).toEqual([ + 'Opening browser to sign in…', + AUTHORIZE_LINE, + 'Paste code here if prompted >', + ]); + expect(agentLogin.url).toBe('https://claude.ai/oauth'); + expect(agentLogin.running).toBe(true); + + // A login that ended in the gap has no `done` left to deliver; the re-sync + // settles it rather than leaving the record running forever. + doctorLoginStatus.mockResolvedValue(idle()); + registration.onEstablished?.(); + await expect(settled).resolves.toBe('completed'); + expect(agentLogin.running).toBe(false); + }); + + it('catches up from the backend when the channel reconnected before the start was answered', async () => { + let answer!: (start: DoctorLoginStart) => void; + startDoctorLogin.mockImplementation( + () => + new Promise((resolve) => { + answer = resolve; + }) + ); + const { agentLogin, startAgentLogin } = await load(); + + const settled = startAgentLogin('ai-agent-claude'); + const registration = only(); + registration.onEstablished?.(); + expect(startDoctorLogin).toHaveBeenCalledTimes(1); + + // The socket dropped and came back while the start's answer was in flight. + // There is no run id to ask about yet, so nothing can be fetched here — + // but the fix printed its first lines, the sign-in URL among them, into + // that gap. + registration.onEstablished?.(); + await flush(); + expect(startDoctorLogin).toHaveBeenCalledTimes(1); + expect(doctorLoginStatus).not.toHaveBeenCalled(); + + // The answer names the run; the gap is what makes it ask the backend. + doctorLoginStatus.mockResolvedValue(running(['Opening browser to sign in…', AUTHORIZE_LINE])); + answer(started()); + await flush(); + expect(doctorLoginStatus).toHaveBeenCalledWith('ai-agent-claude'); + expect(agentLogin.runId).toBe(RUN); + expect(agentLogin.running).toBe(true); + expect(agentLogin.url).toBe('https://claude.ai/oauth'); + expect(agentLogin.output).toEqual(['Opening browser to sign in…', AUTHORIZE_LINE]); + + // Lines after the snapshot keep arriving live, merged by `seq`. + registration.callback(line('ai-agent-claude', 'Paste code here if prompted >', 2)); + expect(agentLogin.output).toHaveLength(3); + + registration.callback(done('ai-agent-claude')); + await expect(settled).resolves.toBe('completed'); + }); + + it('asks nothing after a start answered without a gap, so a fast failure keeps its error', async () => { + const { agentLogin, startAgentLogin } = await load(); + + const settled = startAgentLogin('ai-agent-claude'); + const registration = only(); + registration.onEstablished?.(); + await flush(); + expect(agentLogin.runId).toBe(RUN); + + // The listener was live before the fix existed, so every line is on its + // way here and there is nothing to fetch. Asking anyway would race the + // fix's own end: the status mock answers "not running", and a sync would + // settle the record as `completed` — swallowing the `done` behind it and + // the error it carries. + expect(doctorLoginStatus).not.toHaveBeenCalled(); + registration.callback(done('ai-agent-claude', 'spawn failed: zsh: command not found')); + await expect(settled).rejects.toThrow('spawn failed: zsh: command not found'); + expect(agentLogin.error).toBe('spawn failed: zsh: command not found'); + expect(agentLogin.running).toBe(false); + }); + + it('keeps following a re-attached login when the catch-up status fails', async () => { + startDoctorLogin.mockResolvedValue(alreadyRunning('run-7')); + doctorLoginStatus.mockRejectedValue(new Error('IPC channel closed')); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { agentLogin, startAgentLogin, submitAgentLoginCode } = await load(); + + const settled = startAgentLogin('ai-agent-claude'); + const registration = only(); + registration.onEstablished?.(); + await flush(); + + // The backend has just confirmed the login is alive; a status hiccup is not + // its failure. The record keeps the run and the listener, and only the + // tail is missing until the next event or reconnect. + expect(doctorLoginStatus).toHaveBeenCalledWith('ai-agent-claude'); + expect(warn).toHaveBeenCalled(); + expect(agentLogin.running).toBe(true); + expect(agentLogin.runId).toBe('run-7'); + expect(agentLogin.error).toBeNull(); + expect(agentLogin.output).toEqual([]); + expect(registration.unlisten).not.toHaveBeenCalled(); + + // Still very much a login: lines show and a code goes to the run. + registration.callback(line('ai-agent-claude', AUTHORIZE_LINE, 1, 'run-7')); + expect(agentLogin.url).toBe('https://claude.ai/oauth'); + agentLogin.code = 'abc-123'; + await submitAgentLoginCode(); + expect(sendDoctorLoginCode).toHaveBeenCalledWith('ai-agent-claude', 'run-7', 'abc-123'); + + // The next reconnect fetches what the failed catch-up could not. + doctorLoginStatus.mockResolvedValue( + running(['Opening browser to sign in…', AUTHORIZE_LINE], 'run-7') + ); + registration.onEstablished?.(); + await flush(); + expect(agentLogin.output).toEqual(['Opening browser to sign in…', AUTHORIZE_LINE]); + + registration.callback(done('ai-agent-claude', null, false, 'run-7')); + await expect(settled).resolves.toBe('completed'); + warn.mockRestore(); + }); +}); diff --git a/apps/staged/src/lib/features/doctor/fixDialog.test.ts b/apps/staged/src/lib/features/doctor/fixDialog.test.ts new file mode 100644 index 000000000..6597d1238 --- /dev/null +++ b/apps/staged/src/lib/features/doctor/fixDialog.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { closingFixDialogCancelsLogin } from './fixDialog'; + +describe('closingFixDialogCancelsLogin', () => { + it('cancels a running login the dialog started', () => { + // Nothing else is watching it; left alone it would hold the check's login + // slot until doctor's fix timeout. + expect( + closingFixDialogCancelsLogin({ running: true, requestedHere: true, origin: 'started' }) + ).toBe(true); + }); + + it('cancels a login the dialog asked for that the backend has yet to answer', () => { + // This dialog's request; the store's cancel waits for the run to be named. + expect(closingFixDialogCancelsLogin({ running: true, requestedHere: true, origin: null })).toBe( + true + ); + }); + + it('detaches from a login the dialog asked for but the backend answered “already running”', () => { + // The probe on open found nothing, someone else started a login before Run + // was clicked, and the click re-attached to theirs. The dialog's own flag + // says "started here"; the record's origin says otherwise, and it wins. + expect( + closingFixDialogCancelsLogin({ running: true, requestedHere: true, origin: 'attached' }) + ).toBe(false); + }); + + it('detaches from a running login the dialog only attached to', () => { + // Started from the session pane, another client, or before a reload: its + // starter is still watching it, and a look through this dialog must not + // kill it on the way out — whatever the record says of how this client + // came to follow it. The pane's own start reads `started`, and this dialog + // did not make it. + for (const origin of ['started', 'attached', null] as const) { + expect(closingFixDialogCancelsLogin({ running: true, requestedHere: false, origin })).toBe( + false + ); + } + }); + + it('has nothing to cancel when no login is running', () => { + for (const requestedHere of [true, false]) { + for (const origin of ['started', 'attached', null] as const) { + expect(closingFixDialogCancelsLogin({ running: false, requestedHere, origin })).toBe(false); + } + } + }); +}); diff --git a/apps/staged/src/lib/features/doctor/fixDialog.ts b/apps/staged/src/lib/features/doctor/fixDialog.ts new file mode 100644 index 000000000..b7aa04b9c --- /dev/null +++ b/apps/staged/src/lib/features/doctor/fixDialog.ts @@ -0,0 +1,56 @@ +/** + * fixDialog.ts — the one decision the Doctor panel's fix dialog makes on its way + * out, kept pure so it can be tested without rendering the row: whether leaving + * the dialog also ends the login it shows. + */ +import type { AgentLoginOrigin } from './agentLogin.svelte'; + +/** What the dialog knows about the login the shared record shows for its check. */ +export interface FixDialogLogin { + /** The shared record shows a login running for this dialog's check. */ + running: boolean; + /** + * This dialog asked for that login — its Run was confirmed — as opposed to + * having attached, on open, to one already running: started from the session + * pane, from another client, or before this view reloaded. Only what the + * dialog knows on its own; whether the request in fact began a run is + * `origin`, and it takes both to call the login the dialog's. + */ + requestedHere: boolean; + /** + * The record's `origin`: how this client came to follow the run. `started` + * when the request began it, `attached` when the backend answered "already + * running" or a probe found it, null while a start's answer is still in flight. + */ + origin: AgentLoginOrigin | null; +} + +/** + * Whether closing the fix dialog — its Cancel, Escape, a click outside — should + * cancel the login it shows, rather than merely stop watching it. + * + * A login the dialog started has no other watcher. Left running, it would hold + * the check's login slot until doctor's fix timeout: the CLI ignores a closed + * stdin once it is waiting on its browser callback, so only a kill ends it. A + * login the dialog attached to is someone else's to watch — the session pane + * that started it is still showing its URL and code box — and opening this + * dialog for a look must not kill it on the way out. That includes a login the + * dialog *asked* for but the backend answered "already running": the probe on + * open found nothing, someone else started one before Run was clicked, and the + * click re-attached to their login. The dialog cannot tell that from its own + * request; the record's `origin` can, so it is read here rather than assumed. + * + * A request the backend has yet to answer is cancelled. It is this dialog's, + * and the store's cancel waits for the answer to name the run before asking. + * Should that answer turn out to be "already running", the kill lands on a login + * the user asked to start a moment ago and then left — the ambiguous shape: + * after a reload whose probe failed it is their own lost login, and ending it is + * right; otherwise it is someone else's. Resolved for cancelling, because the + * other reading leaves a slot the user has walked away from held until doctor's + * fix timeout, and the window is the backend's answer, not a human's. + * + * Nothing running means nothing to decide. + */ +export function closingFixDialogCancelsLogin(login: FixDialogLogin): boolean { + return login.running && login.requestedHere && login.origin !== 'attached'; +} diff --git a/apps/staged/src/lib/features/sessions/SessionChatPane.svelte b/apps/staged/src/lib/features/sessions/SessionChatPane.svelte index b7b37a2aa..742def8ec 100644 --- a/apps/staged/src/lib/features/sessions/SessionChatPane.svelte +++ b/apps/staged/src/lib/features/sessions/SessionChatPane.svelte @@ -80,6 +80,11 @@ type AcpConfigSelector, } from '../../api/commands'; import { listenToEvent, type UnlistenFn } from '../../transport'; + import { openSettings } from '../layout/navigation.svelte'; + import { doctorState, runChecks } from '../doctor/doctor.svelte'; + import { agentLogin, attachAgentLogin, startAgentLogin } from '../doctor/agentLogin.svelte'; + import AgentLoginPrompt from '../doctor/AgentLoginPrompt.svelte'; + import { canOfferLogin, doctorCheckForProvider, isAuthenticationError } from './authRecovery'; import AcpFixedConfigPicker from '../agents/AcpFixedConfigPicker.svelte'; import { agentState } from '../agents/agent.svelte'; import { @@ -239,6 +244,65 @@ * `noteTaskStopOutcome`. */ let taskStopNotices = $state>(new Map()); + /** Doctor check id for this session's agent — the login's identity. */ + let loginCheckId = $derived(session?.provider ? `ai-agent-${session.provider}` : null); + let loginCheck = $derived(doctorCheckForProvider(session?.provider, doctorState.report)); + let canLogin = $derived(canOfferLogin(loginCheck)); + let loginRunning = $derived(agentLogin.running && agentLogin.checkId === loginCheckId); + /** + * Sessions whose authentication failure has already asked for a report, so a + * scan that fails (leaving `report` null) isn't retried on every flush. + */ + let authReportRequestedFor: string | null = null; + /** + * `Log in` is the primary action on an authentication failure, but it depends + * on doctor's auth probe — and `doctorState.report` is otherwise filled in + * only by opening the Doctor settings panel. On a fresh launch that left + * every auth-failed session showing `Fix` alone until the user had visited + * that panel and come back, so run the checks the first time such a failure + * is displayed. + */ + $effect(() => { + const id = sessionId; + const failed = session?.status === 'error' || session?.status === 'cancelled'; + if (!active || !id || !failed || !isAuthenticationError(session?.errorMessage)) return; + if (doctorState.report || doctorState.loading || authReportRequestedFor === id) return; + authReportRequestedFor = id; + void runChecks(); + }); + /** + * Sessions whose authentication failure has already asked the backend about a + * running login, per open — see below. + */ + let loginAttachRequestedFor: string | null = null; + /** + * A login for this agent may already be running on the backend — started from + * the Doctor panel, from another client, or before this webview reloaded — with + * the shared record here knowing nothing of it. Ask once per open when the + * alert shows, so its URL and code box come back instead of a `Log in` the + * backend would answer "already running". Not gated on `canLogin`: that needs + * the doctor report, and the login exists whether or not it has arrived. + */ + $effect(() => { + const id = sessionId; + const checkId = loginCheckId; + if (!active) { + loginAttachRequestedFor = null; + return; + } + const failed = session?.status === 'error' || session?.status === 'cancelled'; + if (!id || !checkId || !failed || !isAuthenticationError(session?.errorMessage)) return; + if (agentLogin.running || loginAttachRequestedFor === id) return; + loginAttachRequestedFor = id; + void attachAgentLogin(checkId) + .then((outcome) => { + // A signed-in agent changes the check the "Log in" button depends on. + if (outcome === 'completed') void runChecks(); + }) + .catch(() => { + // The failure is on the shared login record, which the alert renders. + }); + }); let inputText = $state(''); let queuedMessages = $state([]); @@ -614,6 +678,9 @@ stopPolling(); unlistenStatus?.(); unlistenBackgroundHold?.(); + // A login in flight is deliberately not torn down here: the subprocess + // outlives this pane, and its shared record is what the Doctor panel — or + // this pane on its next open — needs to keep feeding it a code. }); // This pane can be mounted once and reused across opens (the `active` prop toggles @@ -776,6 +843,18 @@ if (taskStopNotices.size > 0) taskStopNotices = new Map(); } + async function startLogin() { + if (!loginCheckId || !canLogin || agentLogin.running) return; + try { + const outcome = await startAgentLogin(loginCheckId); + // A signed-in agent changes the check the "Log in" button depends on; a + // cancelled login changes nothing. + if (outcome === 'completed') void runChecks(); + } catch { + // The failure is on the shared login record, which the alert renders. + } + } + function isComposerFocused(): boolean { return document.activeElement === inputEl; } @@ -2308,10 +2387,30 @@ was killed from outside with a recorded reason (e.g. a Pikchr child session whose generate_pikchr call timed out) and reads as an error. --> {#if (session?.status === 'error' || session?.status === 'cancelled') && session.errorMessage} + {@const authError = isAuthenticationError(session.errorMessage)} {session.errorMessage} + {#if authError} + + +
+ {#if canLogin} + + {/if} + +
+
+ {/if}
+ {:else if session && session.status !== 'running' && session.status !== 'queued'} {#if isResumableReason(session.completionReason)} {@const isWarning = @@ -2941,6 +3040,13 @@ /* ----- Input wrapper + queue popover ----------------------------------- */ + .auth-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + } + .input-wrapper { flex-shrink: 0; } diff --git a/apps/staged/src/lib/features/sessions/authRecovery.test.ts b/apps/staged/src/lib/features/sessions/authRecovery.test.ts new file mode 100644 index 000000000..26de9fd5c --- /dev/null +++ b/apps/staged/src/lib/features/sessions/authRecovery.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest'; +import type { DoctorCheck } from '../../api/commands'; +import { canOfferLogin, doctorCheckForProvider, isAuthenticationError } from './authRecovery'; + +const CLAUDE_LOGIN = 'claude-agent-acp --cli auth login'; + +/** The Claude check as doctor reports a positively signed-out agent. */ +function check(overrides: Partial = {}): DoctorCheck { + return { + id: 'ai-agent-claude', + label: 'Claude Code', + status: 'warn', + message: 'Installed, not authenticated', + fixUrl: null, + fixCommand: CLAUDE_LOGIN, + fixType: 'auth', + path: '/usr/local/bin/claude-agent-acp', + bridgePath: null, + rawOutput: null, + authStatus: 'notAuthenticated', + loginCommand: CLAUDE_LOGIN, + installedVersion: null, + latestVersion: null, + updateAvailable: null, + installSource: null, + selfUpdating: null, + main: null, + bridge: null, + ...overrides, + }; +} + +/** + * The same check when the probe exited 0: passing, no fix attached, but the + * static login command still present. This is what doctor reports for an + * expired token — `auth status` never checks expiry — so it is the shape the + * session alert sees in the most common real-world route into this UI. + */ +function passingCheck(overrides: Partial = {}): DoctorCheck { + return check({ + status: 'pass', + message: 'Installed', + fixCommand: null, + fixType: null, + authStatus: 'authenticated', + ...overrides, + }); +} + +/** A resolved provider that has no login command at all. */ +function providerWithoutLogin(id: string, label: string): DoctorCheck { + return passingCheck({ + id, + label, + path: `/usr/local/bin/${id.replace('ai-agent-', '')}`, + authStatus: null, + loginCommand: null, + }); +} + +describe('authentication recovery helpers', () => { + it.each([ + 'ACP protocol failed: OAuth token has expired; authentication required', + 'Error: missing CODEX_API_KEY (or OPENAI_API_KEY)', + 'nested ACP error: Unauthorized (401)', + ])('recognizes authentication error: %s', (message) => { + expect(isAuthenticationError(message)).toBe(true); + }); + + it('does not turn unrelated failures into authentication actions', () => { + expect(isAuthenticationError('ACP protocol failed: connection refused')).toBe(false); + expect(isAuthenticationError('npm install failed with exit code 1')).toBe(false); + }); + + describe('canOfferLogin', () => { + it('offers login for a positively signed-out agent', () => { + expect(canOfferLogin(check())).toBe(true); + }); + + it('offers login when the probe says authenticated but the session failed to authenticate', () => { + // The expired-token case: the probe exits 0 on a credentials record the + // vendor will reject, so the passing check must still be able to log in. + expect(canOfferLogin(passingCheck())).toBe(true); + }); + + it('offers login when the provider has a login command but no status probe', () => { + expect(canOfferLogin(passingCheck({ authStatus: 'notApplicable' }))).toBe(true); + }); + + it('does not rely on the fix fields, which a passing check leaves empty', () => { + expect(canOfferLogin(passingCheck({ fixType: null, fixCommand: null }))).toBe(true); + expect(canOfferLogin(check({ fixType: null, fixCommand: null }))).toBe(true); + }); + + it('withholds login when the probe could not run the binary', () => { + // `unknown` means the binary was not on the login shell's PATH or the + // probe never ran; a login through the same binary would fail the same way. + expect(canOfferLogin(check({ authStatus: 'unknown' }))).toBe(false); + expect(canOfferLogin(passingCheck({ authStatus: 'unknown' }))).toBe(false); + }); + + it('withholds login without a doctor check for the provider', () => { + expect(canOfferLogin(null)).toBe(false); + expect(canOfferLogin(undefined)).toBe(false); + }); + + it('withholds login for providers without a login command', () => { + expect(canOfferLogin(providerWithoutLogin('ai-agent-pi', 'Pi'))).toBe(false); + expect(canOfferLogin(providerWithoutLogin('ai-agent-goose', 'Goose'))).toBe(false); + // A stale fix on a check whose provider lost its login command is not a + // login either: the static capability is the only thing that qualifies. + expect(canOfferLogin(check({ loginCommand: null }))).toBe(false); + }); + }); + + it('matches a session provider to the existing doctor report', () => { + const report = { checks: [check(), check({ id: 'ai-agent-codex', label: 'Codex' })] }; + expect(doctorCheckForProvider('codex', report)?.label).toBe('Codex'); + expect(doctorCheckForProvider('pi', report)).toBeNull(); + expect(doctorCheckForProvider(null, report)).toBeNull(); + }); +}); diff --git a/apps/staged/src/lib/features/sessions/authRecovery.ts b/apps/staged/src/lib/features/sessions/authRecovery.ts new file mode 100644 index 000000000..8ddd65c74 --- /dev/null +++ b/apps/staged/src/lib/features/sessions/authRecovery.ts @@ -0,0 +1,70 @@ +import type { DoctorCheck, DoctorReport } from '../../api/commands'; + +/** Authentication failures commonly arrive wrapped in one or more ACP errors. */ +export function isAuthenticationError(message: string | null | undefined): boolean { + if (!message) return false; + const text = message.toLowerCase(); + + return ( + /authenticat(?:e|ion|ed|ing)/.test(text) || + /auth[_ -]?required/.test(text) || + /unauthori[sz]ed/.test(text) || + /oauth/.test(text) || + /(?:api[_ ]?key|access token|refresh token|credential).*(?:missing|invalid|expired|required)/.test( + text + ) || + /(?:missing|invalid|expired|required).*(?:api[_ ]?key|access token|refresh token|credential)/.test( + text + ) || + /\b(?:codex_api_key|openai_api_key)\b/.test(text) + ); +} + +/** Find the doctor check for the provider recorded on a session. */ +export function doctorCheckForProvider( + provider: string | null | undefined, + report: DoctorReport | null | undefined +): DoctorCheck | null { + if (!provider || !report) return null; + return report.checks.find((check) => check.id === `ai-agent-${provider}`) ?? null; +} + +/** + * Whether a login can be offered for a session whose live error is an + * authentication failure. The caller has already classified that error with + * `isAuthenticationError`; this only asks whether a login is worth running. + * + * Two things gate it. The provider must have a login command — a static + * capability doctor reports whenever the binary resolved, so Pi and Goose never + * get one. And the probe must not have come back `unknown`: that means the + * binary was not on the login shell's `PATH` or the probe never ran, and a + * login through the same binary would fail the same way. + * + * `authenticated` is deliberately *not* an exclusion. Doctor's probe is the + * exit code of the provider's own status command, and for Claude that is 0 + * for an expired token with a dead refresh token, for an expired token with no + * refresh token, and for a well-formed but bogus token — it checks that a + * credentials record exists, never its expiry. A fresh authentication failure + * from this session's own agent process outranks a probe that verifiably does + * not look. Nothing about the user's sign-in state is stored or inferred here: + * the live error, the static capability, and the probe used only to exclude + * `unknown` are read and forgotten, and the vendor stays the sole authority. + * + * One `authenticated` shape is a known false positive: a login shell that + * exports `ANTHROPIC_API_KEY`. The Claude CLI reports itself logged in on that + * variable alone, whatever the OAuth state, and an OAuth login does nothing + * about the exported key — the agent's next session starts with the same + * environment and fails the same way, after a login that reported success. So + * `Log in` is offered there and cannot help. It is accepted rather than + * excluded because `authenticated` cannot be split: the probe's exit code is + * all doctor reports, and it is the same 0 for the expired token this gate + * exists for and for the exported key. The probe's own JSON does tell them + * apart (`apiKeySource: ANTHROPIC_API_KEY`, `authMethod: api_key` when no + * OAuth record exists at all), so a future fix would key on a doctor-side + * credential-source field lifted from that output — not on re-excluding + * `authenticated`, which would close the berd#99 route again. + */ +export function canOfferLogin(check: DoctorCheck | null | undefined): boolean { + if (!check?.loginCommand) return false; + return check.authStatus !== 'unknown'; +} diff --git a/crates/doctor/src/agents.rs b/crates/doctor/src/agents.rs index 4c3858ccd..e791e5163 100644 --- a/crates/doctor/src/agents.rs +++ b/crates/doctor/src/agents.rs @@ -351,6 +351,12 @@ pub fn check_single_ai_agent( ); if let Some(ref path_str) = resolved_path { + // The binary resolved, so the provider's login command is runnable + // whatever the auth probe below concludes. It is surfaced on every + // branch from here on, independent of `auth_status` — see + // `DoctorCheck::login_command` for why the probe's verdict can't gate it. + let login_command = info.auth_command.map(str::to_string); + if info.id == "ai-agent-goose" { let mut command = std::process::Command::new(path_str); command.arg("acp").arg("--help"); @@ -374,6 +380,7 @@ pub fn check_single_ai_agent( bridge_path: None, raw_output: Some(raw), auth_status: None, + login_command, installed_version: None, latest_version: None, update_available: None, @@ -404,6 +411,7 @@ pub fn check_single_ai_agent( bridge_path: None, raw_output: Some(raw), auth_status: None, + login_command, installed_version: None, latest_version: None, update_available: None, @@ -427,6 +435,7 @@ pub fn check_single_ai_agent( .path(resolved_path) .install_source(bridge_install_source.clone()) .main(version_readout(bridge_install_source.clone())) + .login_command(login_command) .raw_suffix(Some(&search)), ), Err(e) => DoctorCheck { @@ -443,6 +452,7 @@ pub fn check_single_ai_agent( "{header}\n$ goose acp --help\nerror: {e}\n{search}" )), auth_status: None, + login_command, installed_version: None, latest_version: None, update_available: None, @@ -571,6 +581,7 @@ pub fn check_single_ai_agent( bridge_path, raw_output: Some(raw), auth_status, + login_command, installed_version: None, latest_version: None, update_available: None, @@ -614,6 +625,9 @@ pub fn check_single_ai_agent( bridge_path: None, raw_output: Some(format!("{header}\n{search}\n{main_search}")), auth_status: None, + // The actionable problem here is the missing bridge; the agent + // can't run a session at all yet, so no login is offered. + login_command: None, installed_version: None, latest_version: None, update_available: None, @@ -649,6 +663,7 @@ pub fn check_single_ai_agent( bridge_path: None, raw_output: Some(format!("{header}\n{search}{extra_search}")), auth_status: None, + login_command: None, installed_version: None, latest_version: None, update_available: None, @@ -986,6 +1001,211 @@ mod tests { assert!(check.bridge.is_none()); } + const CLAUDE_LOGIN: &str = "claude-agent-acp --cli auth login"; + + /// Run the Claude check against a fake `claude-agent-acp` whose + /// `--cli auth status` exits with `status_exit`. The probe runs through a + /// login shell, so the shell gets a dotfile-free `HOME`/`ZDOTDIR` to keep + /// it fast and deterministic (the same reason the fix-runner tests do). + fn claude_check_with_auth_status_exit(name: &str, status_exit: i32) -> DoctorCheck { + let tmp = unique_tmp_dir(name); + let bin = tmp.join("bin"); + let bridge = bin.join("claude-agent-acp"); + write_executable( + &bridge, + &format!( + "#!/bin/sh\n\ + test \"$1\" = --cli || exit 44\n\ + test \"$2\" = auth || exit 45\n\ + test \"$3\" = status || exit 46\n\ + exit {status_exit}\n" + ), + ); + let bridge_resolved = ResolvedBinary { + path: Some(bridge), + search_output: String::new(), + install_source: Some(InstallSource::Npm), + }; + let env = DoctorEnv::new(vec![ + ( + "PATH".to_string(), + format!("{}:/usr/bin:/bin", bin.to_string_lossy()), + ), + ("HOME".to_string(), tmp.to_string_lossy().to_string()), + ("USER".to_string(), "doctor-test".to_string()), + ("ZDOTDIR".to_string(), tmp.to_string_lossy().to_string()), + ]); + + let check = check_single_ai_agent( + agent("ai-agent-claude"), + true, + std::slice::from_ref(&bridge_resolved), + None, + None, + Some(&env), + ); + let _ = std::fs::remove_dir_all(tmp); + check + } + + /// `login_command` is a capability of the resolved binary, not the probe's + /// verdict: a passing check carries it with no fix attached. This is the + /// expired-token shape — Claude's `auth status` exits 0 for a token the + /// vendor will reject — where a host that has just seen the agent fail to + /// authenticate needs to know a login exists although doctor sees nothing + /// to fix. + #[test] + fn authenticated_check_carries_login_command_without_a_fix() { + let check = claude_check_with_auth_status_exit("login-cmd-authenticated", 0); + assert_eq!(check.auth_status, Some(AuthStatus::Authenticated)); + assert_eq!(check.status, CheckStatus::Pass); + assert_eq!(check.login_command.as_deref(), Some(CLAUDE_LOGIN)); + assert_eq!(check.fix_type, None, "a passing check offers no fix"); + assert_eq!(check.fix_command, None); + } + + /// A positively signed-out agent carries the login command twice: as the + /// static capability and as the `Auth` fix, and the two agree. + #[test] + fn not_authenticated_check_carries_login_command_and_matching_auth_fix() { + let check = claude_check_with_auth_status_exit("login-cmd-not-authenticated", 1); + assert_eq!(check.auth_status, Some(AuthStatus::NotAuthenticated)); + assert_eq!(check.status, CheckStatus::Warn); + assert_eq!(check.login_command.as_deref(), Some(CLAUDE_LOGIN)); + assert_eq!(check.fix_type, Some(FixType::Auth)); + assert_eq!(check.fix_command, check.login_command); + } + + /// `Unknown` (here: the probe's exit 127) still carries the command. The + /// decision to withhold a login on `unknown` is the host's, made against + /// `auth_status`; doctor reports the capability uniformly rather than + /// encoding that policy, and still attaches no fix. + #[test] + fn unknown_auth_status_carries_login_command_without_a_fix() { + let check = claude_check_with_auth_status_exit("login-cmd-unknown", 127); + assert_eq!(check.auth_status, Some(AuthStatus::Unknown)); + assert_eq!(check.status, CheckStatus::Warn); + assert_eq!(check.login_command.as_deref(), Some(CLAUDE_LOGIN)); + assert_eq!(check.fix_type, None); + assert_eq!(check.fix_command, None); + } + + /// A provider with a login command but no status probe reports + /// `NotApplicable` and still carries the command. Copilot runs without + /// shelling out. + #[test] + fn not_applicable_auth_status_carries_login_command() { + let single = resolved(Some("/n/bin/copilot"), Some(InstallSource::Npm)); + let check = check_single_ai_agent( + agent("ai-agent-copilot"), + true, + std::slice::from_ref(&single), + None, + None, + None, + ); + assert_eq!(check.auth_status, Some(AuthStatus::NotApplicable)); + assert_eq!(check.status, CheckStatus::Pass); + assert_eq!(check.login_command.as_deref(), Some("copilot login")); + assert_eq!(check.fix_type, None); + } + + /// Providers without a login command never gain one, whatever branch the + /// check takes — the Doctor panel's "no button for Pi or Goose" rests on + /// this. Pi resolves both binaries without shelling out; Goose's + /// `acp --help` probe against a path that doesn't exist takes the + /// spawn-failure arm. + #[test] + fn provider_without_auth_command_has_no_login_command() { + let bridge = resolved(Some("/n/bin/pi-acp"), Some(InstallSource::Npm)); + let main = resolved(Some("/c/bin/pi"), Some(InstallSource::Cargo)); + let pi = check_single_ai_agent( + agent("ai-agent-pi"), + true, + std::slice::from_ref(&bridge), + Some(&main), + None, + None, + ); + assert_eq!(pi.status, CheckStatus::Pass); + assert_eq!(pi.auth_status, None); + assert_eq!(pi.login_command, None); + + let tmp = unique_tmp_dir("login-cmd-goose"); + let missing_goose = tmp.join("goose"); + let goose_resolved = resolved( + Some(&missing_goose.to_string_lossy()), + Some(InstallSource::Brew), + ); + let goose = check_single_ai_agent( + agent("ai-agent-goose"), + true, + std::slice::from_ref(&goose_resolved), + None, + None, + None, + ); + let _ = std::fs::remove_dir_all(tmp); + assert_eq!(goose.status, CheckStatus::Fail, "probe could not spawn"); + assert_eq!(goose.login_command, None); + } + + /// No resolved binary, no login command: neither an uninstalled agent nor + /// one whose bridge is missing (the actionable problem there is the + /// bridge) offers a login it could not run. + #[test] + fn unresolved_agent_has_no_login_command() { + let missing = resolved(None, None); + let uninstalled = check_single_ai_agent( + agent("ai-agent-claude"), + false, + std::slice::from_ref(&missing), + None, + None, + None, + ); + assert_eq!(uninstalled.fix_type, Some(FixType::Command)); + assert_eq!(uninstalled.login_command, None); + + let main = resolved(Some("/h/.local/bin/amp"), Some(InstallSource::Unknown)); + let bridge_missing = check_single_ai_agent( + agent("ai-agent-amp"), + true, + std::slice::from_ref(&missing), + Some(&main), + None, + None, + ); + assert_eq!(bridge_missing.fix_type, Some(FixType::Bridge)); + assert_eq!(bridge_missing.login_command, None); + } + + /// The field is additive on the wire: it serializes under its camelCase + /// name, and a payload from a doctor without it reads back as `None`. + #[test] + fn login_command_is_additive_on_the_wire() { + let single = resolved(Some("/n/bin/copilot"), Some(InstallSource::Npm)); + let check = check_single_ai_agent( + agent("ai-agent-copilot"), + true, + std::slice::from_ref(&single), + None, + None, + None, + ); + let json = serde_json::to_value(&check).expect("serialize check"); + assert_eq!(json["loginCommand"], serde_json::json!("copilot login")); + + let mut without = json.clone(); + without + .as_object_mut() + .expect("check is an object") + .remove("loginCommand"); + let parsed: DoctorCheck = serde_json::from_value(without).expect("older payload parses"); + assert_eq!(parsed.login_command, None); + assert_eq!(parsed.id, check.id); + } + #[test] fn auth_fix_lookup_returns_agent_auth_command() { assert_eq!( diff --git a/crates/doctor/src/checks.rs b/crates/doctor/src/checks.rs index 96b80225f..6be5651e0 100644 --- a/crates/doctor/src/checks.rs +++ b/crates/doctor/src/checks.rs @@ -30,6 +30,7 @@ pub fn check_git(resolved: &ResolvedBinary, env: Option<&DoctorEnv>) -> DoctorCh bridge_path: None, raw_output: Some(format!("{header}\nnot found via resolve_binary\n{search}")), auth_status: None, + login_command: None, installed_version: None, latest_version: None, update_available: None, @@ -65,6 +66,7 @@ pub fn check_git(resolved: &ResolvedBinary, env: Option<&DoctorEnv>) -> DoctorCh bridge_path: None, raw_output: Some(raw), auth_status: None, + login_command: None, installed_version: None, latest_version: None, update_available: None, @@ -92,6 +94,7 @@ pub fn check_git(resolved: &ResolvedBinary, env: Option<&DoctorEnv>) -> DoctorCh bridge_path: None, raw_output: Some(raw), auth_status: None, + login_command: None, installed_version: None, latest_version: None, update_available: None, @@ -119,6 +122,7 @@ pub fn check_git(resolved: &ResolvedBinary, env: Option<&DoctorEnv>) -> DoctorCh bridge_path: None, raw_output: Some(format!("{header}\n$ git --version\nerror: {e}\n{search}")), auth_status: None, + login_command: None, installed_version: None, latest_version: None, update_available: None, @@ -152,6 +156,7 @@ pub fn check_gh(resolved: &ResolvedBinary, env: Option<&DoctorEnv>) -> DoctorChe bridge_path: None, raw_output: Some(format!("{header}\nnot found via resolve_binary\n{search}")), auth_status: None, + login_command: None, installed_version: None, latest_version: None, update_available: None, @@ -188,6 +193,7 @@ pub fn check_gh(resolved: &ResolvedBinary, env: Option<&DoctorEnv>) -> DoctorChe bridge_path: None, raw_output: Some(raw), auth_status: None, + login_command: None, installed_version: None, latest_version: None, update_available: None, @@ -215,6 +221,7 @@ pub fn check_gh(resolved: &ResolvedBinary, env: Option<&DoctorEnv>) -> DoctorChe bridge_path: None, raw_output: Some(raw), auth_status: None, + login_command: None, installed_version: None, latest_version: None, update_available: None, @@ -242,6 +249,7 @@ pub fn check_gh(resolved: &ResolvedBinary, env: Option<&DoctorEnv>) -> DoctorChe bridge_path: None, raw_output: Some(format!("{header}\n$ gh --version\nerror: {e}\n{search}")), auth_status: None, + login_command: None, installed_version: None, latest_version: None, update_available: None, @@ -274,6 +282,7 @@ pub fn check_gh_auth(gh: &ResolvedBinary, env: Option<&DoctorEnv>) -> DoctorChec bridge_path: None, raw_output: Some(format!("{header}\ngh not found via resolve_binary")), auth_status: None, + login_command: None, installed_version: None, latest_version: None, update_available: None, @@ -307,6 +316,7 @@ pub fn check_gh_auth(gh: &ResolvedBinary, env: Option<&DoctorEnv>) -> DoctorChec bridge_path: None, raw_output: Some(raw), auth_status: None, + login_command: None, installed_version: None, latest_version: None, update_available: None, @@ -335,6 +345,7 @@ pub fn check_gh_auth(gh: &ResolvedBinary, env: Option<&DoctorEnv>) -> DoctorChec bridge_path: None, raw_output: Some(raw), auth_status: None, + login_command: None, installed_version: None, latest_version: None, update_available: None, @@ -360,6 +371,7 @@ pub fn check_gh_auth(gh: &ResolvedBinary, env: Option<&DoctorEnv>) -> DoctorChec bridge_path: None, raw_output: Some(format!("{header}\n$ gh auth status\nerror: {e}")), auth_status: None, + login_command: None, installed_version: None, latest_version: None, update_available: None, @@ -400,6 +412,7 @@ pub fn check_git_lfs( "{header}\ngit not found via resolve_binary\n{search}" )), auth_status: None, + login_command: None, installed_version: None, latest_version: None, update_available: None, @@ -438,6 +451,7 @@ pub fn check_git_lfs( bridge_path: None, raw_output: Some(raw), auth_status: None, + login_command: None, installed_version: None, latest_version: None, update_available: None, @@ -465,6 +479,7 @@ pub fn check_git_lfs( bridge_path: None, raw_output: Some(raw), auth_status: None, + login_command: None, installed_version: None, latest_version: None, update_available: None, @@ -497,6 +512,7 @@ pub fn check_git_lfs( bridge_path: None, raw_output: Some(format!("{header}\n$ git lfs version\nerror: {e}\n{search}")), auth_status: None, + login_command: None, installed_version: None, latest_version: None, update_available: None, diff --git a/crates/doctor/src/command.rs b/crates/doctor/src/command.rs index 4ccc3a2a8..0c9099b07 100644 --- a/crates/doctor/src/command.rs +++ b/crates/doctor/src/command.rs @@ -189,16 +189,33 @@ fn join_reader(handle: JoinHandle>) -> Vec { } fn clean_up_after_incomplete_wait(child: &mut Child) { - kill_child_process_group_or_child(child); + // The reach is irrelevant here: the probe runner always spawns with + // `process_group(0)`, so the group kill is the one that lands. + let _ = kill_child_process_group_or_child(child); let _ = child.wait(); } -fn kill_child_process_group_or_child(child: &mut Child) { +/// How far a kill actually reached. Returned rather than discarded so a caller +/// reporting the kill to a user can say what survived it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum KillReach { + /// The child's process group — the shell and everything that stayed in it. + ProcessGroup, + /// The direct child only; anything it spawned is still running. + ChildOnly, +} + +/// Best-effort kill: target the child's process group first so a shell's whole +/// command tree goes with it, falling back to the direct child when the group +/// lookup fails (the child wasn't spawned with `process_group(0)`, or it isn't +/// Unix). Callers must still reap afterwards. +pub(crate) fn kill_child_process_group_or_child(child: &mut Child) -> KillReach { if kill_child_process_group(child) { - return; + return KillReach::ProcessGroup; } let _ = child.kill(); + KillReach::ChildOnly } #[cfg(unix)] diff --git a/crates/doctor/src/lib.rs b/crates/doctor/src/lib.rs index abdd2325f..23d478483 100644 --- a/crates/doctor/src/lib.rs +++ b/crates/doctor/src/lib.rs @@ -18,8 +18,10 @@ pub use environment::DoctorEnv; pub use types::{AgentVersionInfo, CheckStatus, DoctorCheck, DoctorReport, FixType}; use std::collections::{HashMap, HashSet}; +use std::io::IsTerminal; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; use agents::{ bundled_version_probe_args, check_single_ai_agent, derive_update_command, lookup_fix_command, @@ -53,6 +55,7 @@ fn empty_check(id: &str, label: &str) -> DoctorCheck { bridge_path: None, raw_output: None, auth_status: None, + login_command: None, installed_version: None, latest_version: None, update_available: None, @@ -364,6 +367,7 @@ fn timeout_diagnostic_check(timeout: CommandTimeout, id: String) -> DoctorCheck timeout.raw_output() )), auth_status: None, + login_command: None, installed_version: None, latest_version: None, update_available: None, @@ -673,6 +677,507 @@ struct FreshnessTarget { version_args: Option<&'static [&'static str]>, } +/// Opt-in piped stdin for a fix subprocess. Create with [`FixStdin::pipe`]; +/// keep the [`FixStdinWriter`], put the `FixStdin` in +/// [`ExecuteFixOptions::stdin`]. +/// +/// Single-use: the first execution claims the pipe, and any later execution +/// handed the same `FixStdin` — or a clone of it, including one carried along by +/// a cloned [`ExecuteFixOptions`] — fails with an error instead of spawning. +/// Retrying a fix needs a fresh pipe. +#[derive(Debug, Clone)] +pub struct FixStdin { + shared: Arc, +} + +/// The pipe state plus the latch saying the fix is over. The latch lives outside +/// the mutex precisely so [`FixStdin::close`] never waits on it: a host thread +/// parked in a `write_all` holds the mutex for as long as the pipe stays full, +/// and the runner's return path — which is what [`FixTimeout`] promises is +/// bounded — cannot be queued behind that. +/// +/// The two are kept consistent by [`FixStdinGuard`]: whichever lock holder is +/// last to leave performs the `Closed` transition. +#[derive(Debug)] +struct FixStdinShared { + state: Mutex, + closed: std::sync::atomic::AtomicBool, +} + +/// The pipe's whole life cycle: `Buffered` until the fix spawns, `Live` while it +/// runs, then `Closed` — terminal, and reached when the fix ends, when the last +/// writer drops, or when a write finds the read end gone. Holding the child's +/// stdin handle here rather than in a thread of its own is what lets +/// [`FixStdinWriter::send_line`] write through and report the real outcome. +#[derive(Debug)] +enum FixStdinState { + /// Before the fix spawns: lines the host queued, replayed at spawn. + /// `claimed` marks the execution that reserved this pipe, so a second one + /// is rejected before it spawns. `eof` records that every writer dropped + /// pre-spawn, so the replay is followed immediately by closing the pipe. + /// `queued_bytes` is what the replay will write, held under + /// [`MAX_QUEUED_FIX_STDIN_BYTES`]. + Buffered { + lines: Vec, + queued_bytes: usize, + eof: bool, + claimed: bool, + }, + /// Fix running: writes go straight into the child's stdin. + Live(std::process::ChildStdin), + /// Fix finished, every writer gone, or a write hit a dead pipe. + Closed, +} + +/// Rejection for an execution handed a `FixStdin` another one already claimed. +const FIX_STDIN_REUSED: &str = "FixStdin already consumed by a previous fix execution; \ + create a fresh pipe with FixStdin::pipe() for each run"; + +/// Rejection for a line the pipe cannot deliver because it is closed. +const FIX_STDIN_CLOSED: &str = "Fix is no longer accepting input"; + +/// Rejection for a pre-spawn line that would push the queue past +/// [`MAX_QUEUED_FIX_STDIN_BYTES`]. +const FIX_STDIN_QUEUE_FULL: &str = "Fix input queue is full before the fix started; \ + send the rest once the fix is running"; + +/// Ceiling on bytes queued through [`FixStdinWriter::send_line`] before the fix +/// spawns. The replay in `FixStdin::attach` writes inline on the runner thread, +/// before the deadline is armed and with nothing able to interrupt it, so it has +/// to fit in a virgin pipe's capacity or the runner would park there — the one +/// place [`FixTimeout`]'s bound could not reach. 4 KiB is the one-page floor of a +/// pipe on any platform doctor runs on (macOS and Linux both measure 64 KiB in +/// practice), and orders of magnitude above the auth code this exists to carry. +pub const MAX_QUEUED_FIX_STDIN_BYTES: usize = 4096; + +/// Guard over the pipe state that applies the `closed` latch on release. Every +/// lock holder therefore closes the pipe on its way out if the fix ended while it +/// held the lock — including a host `send_line` that was mid-write, which is what +/// lets [`FixStdin::close`] get away with never blocking. +struct FixStdinGuard<'a> { + shared: &'a FixStdinShared, + guard: std::sync::MutexGuard<'a, FixStdinState>, +} + +impl std::ops::Deref for FixStdinGuard<'_> { + type Target = FixStdinState; + + fn deref(&self) -> &FixStdinState { + &self.guard + } +} + +impl std::ops::DerefMut for FixStdinGuard<'_> { + fn deref_mut(&mut self) -> &mut FixStdinState { + &mut self.guard + } +} + +impl Drop for FixStdinGuard<'_> { + fn drop(&mut self) { + if self + .shared + .closed + .load(std::sync::atomic::Ordering::Acquire) + { + *self.guard = FixStdinState::Closed; + } + } +} + +impl FixStdinShared { + /// Locking the pipe state recovers from poisoning instead of propagating it: + /// no invariant spans the lock (the state is a plain enum, and the only work + /// done under it is a `Vec` push or a pipe write), while treating a poisoned + /// lock as a failure would cost `send_line` its delivery guarantee and leak + /// the child's stdin handle for the lifetime of the writer. + fn lock(&self) -> FixStdinGuard<'_> { + FixStdinGuard { + shared: self, + guard: self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner), + } + } +} + +impl FixStdinState { + /// Queue or write `line` — with the trailing newline the caller doesn't + /// supply — according to the current state. A failed write latches `Closed` + /// so later sends fail without re-discovering the dead pipe. + fn send_line(&mut self, line: String) -> Result<(), String> { + match self { + FixStdinState::Buffered { + lines, + queued_bytes, + .. + } => { + // The newline `send_line` appends is part of what enters the + // pipe, so charge for it. + let cost = line.len() + 1; + if *queued_bytes + cost > MAX_QUEUED_FIX_STDIN_BYTES { + return Err(format!( + "{FIX_STDIN_QUEUE_FULL} (limit {MAX_QUEUED_FIX_STDIN_BYTES} bytes)" + )); + } + *queued_bytes += cost; + lines.push(line); + Ok(()) + } + FixStdinState::Live(pipe) => { + use std::io::Write; + match pipe + .write_all(format!("{line}\n").as_bytes()) + .and_then(|()| pipe.flush()) + { + Ok(()) => Ok(()), + Err(e) => { + *self = FixStdinState::Closed; + Err(format!("{FIX_STDIN_CLOSED}: {e}")) + } + } + } + FixStdinState::Closed => Err(FIX_STDIN_CLOSED.to_string()), + } + } +} + +impl FixStdin { + /// Create a connected pair: a cloneable writer for the caller to keep and + /// the `FixStdin` to place in [`ExecuteFixOptions::stdin`]. Lines sent + /// before the fix subprocess spawns are queued and replayed once it does; + /// dropping every writer clone closes the child's stdin (EOF). + /// + /// Dropping the writers is the only way to say "no more input", and a fix + /// that reads *to EOF* rather than a fixed number of lines will not exit + /// until that happens — a host that leaves its input UI open pins the fix + /// until [`ExecuteFixOptions::timeout`] fires. Nothing else is at stake in + /// dropping them: the child's stdin handle lives with the fix and is + /// reclaimed when it ends, held writer or not. + /// + /// "No more input" is all EOF says. It is not a way to *stop* a fix: one that + /// is not reading stdin — the login CLI, waiting on its browser callback — + /// ignores it and runs on. Ending a fix early is [`FixCancellation`]'s job. + pub fn pipe() -> (FixStdinWriter, FixStdin) { + let shared = Arc::new(FixStdinShared { + state: Mutex::new(FixStdinState::Buffered { + lines: Vec::new(), + queued_bytes: 0, + eof: false, + claimed: false, + }), + closed: std::sync::atomic::AtomicBool::new(false), + }); + ( + FixStdinWriter { + inner: Arc::new(FixStdinWriterInner { + shared: shared.clone(), + }), + }, + FixStdin { shared }, + ) + } + + /// Reserve this pipe for a child about to be spawned. First caller wins; + /// `Err` on every later call (a clone already fed an execution), which the + /// caller surfaces instead of spawning a fix whose stdin is already dead. + fn claim(&self) -> Result<(), String> { + match &mut *self.shared.lock() { + FixStdinState::Buffered { claimed, .. } if !*claimed => { + *claimed = true; + Ok(()) + } + _ => Err(FIX_STDIN_REUSED.to_string()), + } + } + + /// Hand the spawned child's stdin to the pipe, replay whatever the host + /// queued before the spawn, and go live. + /// + /// Only ever reached after a successful [`FixStdin::claim`], which is what + /// guarantees the state is still `Buffered`; any other state means another + /// execution owns the pipe, and dropping the handle — an immediate EOF for + /// this child — is the only safe reading of that. A replay write that fails + /// is not the fix's failure (a command is free to exit successfully without + /// reading its stdin), so it only latches `Closed`; the host hears about it + /// from its next `send_line`. + /// + /// The replay writes inline on the runner's thread, ahead of the fix's + /// deadline, so it must not be able to park: that is what + /// [`MAX_QUEUED_FIX_STDIN_BYTES`] buys — the whole queue fits in a virgin + /// pipe's capacity, so these writes cannot block on a child that never reads. + fn attach(&self, child_stdin: std::process::ChildStdin) { + let mut state = self.shared.lock(); + let FixStdinState::Buffered { lines, eof, .. } = &mut *state else { + return; + }; + let queued = std::mem::take(lines); + let eof = *eof; + *state = FixStdinState::Live(child_stdin); + for line in queued { + if state.send_line(line).is_err() { + break; + } + } + if eof { + // Every writer was dropped before the spawn, so the queued lines + // above are all the input there will ever be and closing now is the + // EOF the fix is waiting for. + *state = FixStdinState::Closed; + } + } + + /// The fix is over: close the pipe so every later send fails immediately. + /// A write hitting `EPIPE` cannot be the signal on its own — a backgrounded + /// grandchild that inherited the child's stdin keeps the read end open, and + /// writes into it go on succeeding long after the fix is gone. + /// + /// Never blocks, which is what keeps the runner's return path inside + /// [`FixTimeout`]'s bound: a host thread parked in a `write_all` into a full + /// pipe holds the state mutex for as long as the pipe stays full. The latch + /// goes up first, so the transition is guaranteed either way — here if the + /// lock is free, otherwise by the holder's [`FixStdinGuard`] on release. + fn close(&self) { + self.shared + .closed + .store(true, std::sync::atomic::Ordering::Release); + // Not best-effort correctness: this arm is what reclaims the child's + // stdin handle in the ordinary case, where nobody will take the lock + // again to run the guard's transition. + match self.shared.state.try_lock() { + Ok(mut state) => *state = FixStdinState::Closed, + Err(std::sync::TryLockError::Poisoned(e)) => *e.into_inner() = FixStdinState::Closed, + Err(std::sync::TryLockError::WouldBlock) => {} + } + } + + /// Close a pipe whose execution never started, leaving one that another + /// execution already owns alone. The claim is the ownership test: it succeeds + /// only on a pipe no run has reserved, so a stale clone whose first run is + /// still live is never EOF'd out from under it. + fn close_if_unclaimed(&self) { + if self.claim().is_ok() { + self.close(); + } + } +} + +/// Cloneable handle for feeding lines to a fix subprocess's stdin. Dropping +/// every clone closes the fix's stdin (EOF) — "no more input", not "stop"; a +/// fix that isn't reading stdin never notices. Stopping one is +/// [`FixCancellation`]'s job. +#[derive(Debug, Clone)] +pub struct FixStdinWriter { + inner: Arc, +} + +/// Shared by every [`FixStdinWriter`] clone so EOF is delivered exactly when +/// the last one drops, which is what keeps the writer `Clone`. +#[derive(Debug)] +struct FixStdinWriterInner { + shared: Arc, +} + +impl Drop for FixStdinWriterInner { + fn drop(&mut self) { + // Nothing to signal once the fix is over, and this is the one place that + // must not skip the lock when it is contended: dropping the last writer + // *is* the EOF, and a fix reading to EOF would hang without it. + if self + .shared + .closed + .load(std::sync::atomic::Ordering::Acquire) + { + return; + } + match &mut *self.shared.lock() { + // Pre-spawn the queued lines still have to reach the child first, so + // record the EOF for `attach` to deliver after the replay. + FixStdinState::Buffered { eof, .. } => *eof = true, + // Otherwise dropping the state's `ChildStdin` *is* the EOF. + state => *state = FixStdinState::Closed, + } + } +} + +impl FixStdinWriter { + /// Write one line to the fix's stdin; a trailing `\n` is appended and the + /// pipe is flushed. + /// + /// `Ok` means the bytes were handed to the child's stdin pipe — not that the + /// fix read them, since a fix can exit with bytes still buffered. `Err` + /// means the line was *not* delivered: the fix has finished, its stdin is + /// closed, or this pipe was never attached to a spawned fix. + /// + /// Lines sent before the fix spawns are queued and replayed at spawn, so + /// they return `Ok` before any pipe exists — the one `Ok` that is not a + /// delivery guarantee, and unavoidable for a host that wants to prime the + /// input before the fix starts. That queue is capped at + /// [`MAX_QUEUED_FIX_STDIN_BYTES`], so a bulk pre-spawn send fails rather than + /// wedging the runner's replay; send the rest once the fix is running. Once a + /// fix execution gives up without ever spawning (an unresolved command, a + /// spawn failure), the pipe is closed and every later send fails. + /// + /// Completion is signalled by the fix's own `Result`, never by `send_line`, + /// and a fix that has stopped wanting input is not thereby over: a host that + /// wants the run *ended* — an abandoned login, say — cancels it through + /// [`FixCancellation`], which is what makes the pipe close. + /// May block if the fix isn't reading and the pipe buffer fills, so a host + /// sending anything bulkier than a pasted code should call this off its + /// async runtime. Such a write also delays any other clone's `send_line` and + /// the last-writer EOF — though no longer the fix's own completion or `Err`, + /// which stay inside [`ExecuteFixOptions::timeout`]. + pub fn send_line(&self, line: impl Into) -> Result<(), String> { + // Ahead of the mutex, so a host is never queued behind another clone's + // parked write for a fix that has already finished. + if self + .inner + .shared + .closed + .load(std::sync::atomic::Ordering::Acquire) + { + return Err(FIX_STDIN_CLOSED.to_string()); + } + self.inner.shared.lock().send_line(line.into()) + } +} + +/// Opt-in cancellation for a fix subprocess. Create with +/// [`FixCancellation::token`]; keep the [`FixCancelHandle`], put the +/// `FixCancellation` in [`ExecuteFixOptions::cancellation`]. +/// +/// This is the only way a host can end a fix early. Closing the fix's stdin is +/// not one: a fix that is not reading stdin — `claude-agent-acp --cli auth +/// login` prints its URL and then waits on its browser callback, ignoring EOF — +/// runs on until [`ExecuteFixOptions::timeout`] fires. The runner owns the +/// child, so the kill has to come from inside it, and this is how a host asks. +/// +/// Unlike [`FixStdin`] a token is not single-use — nothing about it is spent by +/// a run — but a cancelled token stays cancelled, so a retry that reuses one is +/// refused before it spawns. Attach a fresh token to each run. +#[derive(Debug, Clone)] +pub struct FixCancellation { + shared: Arc, +} + +/// One latch shared by the handle and the token. An atomic rather than anything +/// the runner has to lock: `cancel` is called from a UI thread in response to a +/// click and must never wait on a runner mid-`write_all`, and the runner reads +/// it once per output line without contending with anyone. +#[derive(Debug)] +struct FixCancellationShared { + cancelled: std::sync::atomic::AtomicBool, +} + +impl FixCancellation { + /// Create a connected pair: a cloneable handle for the caller to keep and + /// the `FixCancellation` to place in [`ExecuteFixOptions::cancellation`]. + pub fn token() -> (FixCancelHandle, FixCancellation) { + let shared = Arc::new(FixCancellationShared { + cancelled: std::sync::atomic::AtomicBool::new(false), + }); + ( + FixCancelHandle { + shared: shared.clone(), + }, + FixCancellation { shared }, + ) + } + + fn is_cancelled(&self) -> bool { + self.shared + .cancelled + .load(std::sync::atomic::Ordering::Acquire) + } +} + +/// Cloneable, thread-safe handle for cancelling a fix subprocess from wherever +/// the host learns the user gave up — a Tauri command, a UI callback, another +/// task — while the run itself is awaited elsewhere. +#[derive(Debug, Clone)] +pub struct FixCancelHandle { + shared: Arc, +} + +impl FixCancelHandle { + /// Ask the runner to stop the fix. Infallible and idempotent: the request is + /// recorded whatever state the run is in, and repeating it changes nothing. + /// + /// What happens next depends on where the run is. Before the fix has spawned, + /// the runner refuses to spawn it and returns `Err`. While it runs, the runner + /// notices within [`FIX_CANCEL_POLL_INTERVAL`] (sooner if the fix is + /// printing), kills the fix — its whole process tree where doctor owns the + /// process group, the direct child where it does not, exactly as the timeout + /// does — delivers any output already read, emits one `doctor: fix + /// cancelled` notice through `on_line`, and returns `Err` naming the + /// cancellation. After the run has returned this is a no-op: the fix's own + /// `Result` stands. + /// + /// A cancel that lands in the instant between the fix exiting and the runner + /// observing that exit is reported as a cancellation, the same way the + /// timeout is; the window is the runner's own reap latency, not a human's. + pub fn cancel(&self) { + self.shared + .cancelled + .store(true, std::sync::atomic::Ordering::Release); + } + + /// Whether [`cancel`](Self::cancel) has been called on this handle or any + /// clone of it. A host holding the run's `Err` can tell a cancellation it + /// asked for from a failure without parsing the message: the runner's error + /// type is unchanged, so this is where that distinction lives. + pub fn is_cancelled(&self) -> bool { + self.shared + .cancelled + .load(std::sync::atomic::Ordering::Acquire) + } +} + +/// How long a cancellable fix's runner blocks between looks at its +/// [`FixCancellation`], so the bound on cancel latency while the fix is silent. +/// A cancellable run that is *not* cancelled pays one wake-up per interval — +/// nothing next to a login idling for minutes — and a run without a +/// `FixCancellation` never polls at all. +pub const FIX_CANCEL_POLL_INTERVAL: Duration = Duration::from_millis(100); + +/// Wall-clock bound on a single fix execution. +/// +/// Fixes are install/auth/update actions, so the bound has to clear a +/// cold-cache `npm install -g` behind a corporate proxy and a human doing SSO +/// in a browser — orders of magnitude above the probe timeouts in +/// [`crate::command`]. This is an enum rather than `Option` because +/// `None` reads as both "use the default" and "no timeout"; here every literal +/// has to say which it means, and `Unbounded` stays reachable for a caller +/// that genuinely wants the old forever-wait. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum FixTimeout { + /// [`DEFAULT_FIX_TIMEOUT`]. + #[default] + Standard, + /// A caller-chosen bound. + After(Duration), + /// No bound at all: the fix runs until it exits on its own. + Unbounded, +} + +impl FixTimeout { + /// The wall-clock bound, or `None` for [`FixTimeout::Unbounded`]. + fn duration(self) -> Option { + match self { + FixTimeout::Standard => Some(DEFAULT_FIX_TIMEOUT), + FixTimeout::After(duration) => Some(duration), + FixTimeout::Unbounded => None, + } + } +} + +/// Deadline applied by [`FixTimeout::Standard`]. Deliberately generous: it +/// exists to stop a wedged fix from pinning a blocking worker and a process +/// tree for the lifetime of the host, not to police slow-but-honest installs +/// or a leisurely browser login. +pub const DEFAULT_FIX_TIMEOUT: Duration = Duration::from_secs(600); + /// Options for executing a doctor fix command. #[derive(Debug, Clone, Default)] pub struct ExecuteFixOptions { @@ -682,6 +1187,25 @@ pub struct ExecuteFixOptions { pub npm_registry: Option, /// Optional caller-provided environment snapshot for the fix subprocess. pub env: Option, + /// Opt-in piped stdin for the fix subprocess (see [`FixStdin::pipe`]). + /// `None` keeps the child inheriting the host process's stdin, so + /// terminal hosts can still run interactive fixes directly. + /// + /// A `FixStdin` feeds exactly one execution, so a cached options struct + /// must have this field refreshed (or be rebuilt) before a fix is retried; + /// reusing it fails the run. + pub stdin: Option, + /// Wall-clock bound on the fix. Defaults to [`FixTimeout::Standard`]. + pub timeout: FixTimeout, + /// Opt-in cancellation for the fix subprocess (see + /// [`FixCancellation::token`]). `None` means nothing but the fix's own + /// exit or [`timeout`](Self::timeout) ends the run — closing a piped stdin + /// does not, since a fix that isn't reading it never notices. + /// + /// A cancelled token stays cancelled, so a cached options struct that was + /// cancelled must have this field refreshed before a fix is retried; + /// reusing it refuses the run before it spawns. + pub cancellation: Option, } impl ExecuteFixOptions { @@ -689,6 +1213,28 @@ impl ExecuteFixOptions { self.env = Some(DoctorEnv::new(vars)); self } + + /// Attach an opt-in stdin pipe (see [`FixStdin::pipe`]). The `FixStdin` + /// feeds exactly one execution: call this again with a fresh pipe for + /// every retry rather than reusing a built options struct. + pub fn with_stdin(mut self, stdin: FixStdin) -> Self { + self.stdin = Some(stdin); + self + } + + /// Override the wall-clock bound on the fix (see [`FixTimeout`]). + pub fn with_timeout(mut self, timeout: FixTimeout) -> Self { + self.timeout = timeout; + self + } + + /// Attach an opt-in cancellation token (see [`FixCancellation::token`]). + /// Attach a fresh one for every retry: a token cancelled during one run + /// refuses the next before it spawns. + pub fn with_cancellation(mut self, cancellation: FixCancellation) -> Self { + self.cancellation = Some(cancellation); + self + } } /// Run a fix command for a doctor check, identified by check ID and fix type. @@ -722,7 +1268,7 @@ pub async fn execute_fix_with_options( ExecuteFixOptions { command_override, npm_registry: npm_registry.map(str::to_string), - env: None, + ..Default::default() }, ) .await @@ -779,7 +1325,7 @@ where ExecuteFixOptions { command_override, npm_registry: npm_registry.map(str::to_string), - env: None, + ..Default::default() }, on_line, ) @@ -797,6 +1343,11 @@ pub async fn execute_fix_streaming_with_env_options( where F: FnMut(&str) + Send + 'static, { + // Armed ahead of the lookup so every exit that never reaches the runner closes + // the pipe. Once the runner claims, this is a no-op — the claim is spent, and + // the runner's own `FixStdinCloser` owns the close from there. + let _unlaunched_closer = opts.stdin.as_ref().map(UnlaunchedFixStdinCloser); + let command = match opts.command_override { Some(cmd) => cmd, None => lookup_fix_command(&check_id, &fix_type) @@ -813,21 +1364,49 @@ where // Fixes are intentionally not routed through the bounded probe runner: // these are user-triggered install/auth/update actions and can reasonably - // be interactive or long-running. - run_command_streaming(command, opts.env, on_line).await + // be interactive or long-running, so they get the far more generous + // `FixTimeout` bound instead of a probe timeout. + // Cloned rather than moved because `_unlaunched_closer` borrows it: an `Arc` + // bump, and the runner holds its own handle to the same shared state. + run_command_streaming( + command, + opts.env, + opts.stdin.clone(), + opts.timeout, + opts.cancellation, + on_line, + ) + .await } /// Async wrapper that runs `run_command_streaming_blocking` on the blocking pool. pub(crate) async fn run_command_streaming( command: String, env: Option, + stdin: Option, + timeout: FixTimeout, + cancellation: Option, on_line: F, ) -> Result<(), String> where F: FnMut(&str) + Send + 'static, { + // Decided here, before `stdin` moves into the closure, and from the host's own + // fd 0 rather than the blocking worker's — they are the same descriptor, but + // reading it on this side keeps the runner's behavior a parameter that tests + // can set. + let process_group = FixProcessGroup::for_fix(stdin.as_ref(), std::io::stdin().is_terminal()); + tokio::task::spawn_blocking(move || { - run_command_streaming_blocking(&command, env.as_ref(), on_line) + run_command_streaming_blocking( + &command, + env.as_ref(), + stdin, + timeout, + cancellation, + process_group, + on_line, + ) }) .await .unwrap_or_else(|e| Err(format!("Task failed: {e}"))) @@ -1011,31 +1590,220 @@ pub(crate) fn execute_command_with_path_prefix_with_env( } } +/// Closes the fix's stdin pipe when `run_command_streaming_blocking` leaves its +/// body — normal return, error return, timeout, spawn failure, or a panic in +/// `on_line`. Every path has to close it: a host that still holds a +/// [`FixStdinWriter`] would otherwise keep getting `Ok` from `send_line` for a +/// fix that is already over, and the child's stdin handle would live as long as +/// that writer. +struct FixStdinCloser<'a>(&'a FixStdin); + +impl Drop for FixStdinCloser<'_> { + fn drop(&mut self) { + self.0.close(); + } +} + +/// Closes the pipe when a fix never reaches `run_command_streaming_blocking`: an +/// unresolved command, a panic in the `on_line` preamble, a blocking task dropped +/// before it ran. Distinct from [`FixStdinCloser`], which closes unconditionally +/// because by then the claim is that run's own — this one must not touch a pipe +/// another execution has claimed, since `FixStdin` clones share one state and +/// EOF'ing a live login out from under the user would be worse than the bogus +/// `Ok` it is here to prevent. +struct UnlaunchedFixStdinCloser<'a>(&'a FixStdin); + +impl Drop for UnlaunchedFixStdinCloser<'_> { + fn drop(&mut self) { + self.0.close_if_unclaimed(); + } +} + +/// Whether doctor puts the fix's shell in its own process group, which is what +/// lets the timeout's `kill(-pid)` reach the fix's whole tree instead of just the +/// direct child. +/// +/// Staying in doctor's group is only worth its cost — descendants surviving the +/// deadline, and a direct-child kill whose reach depends on whether zsh +/// exec-optimized the payload away — when the child might read the host's +/// terminal. That needs an actual tty on fd 0, so the decision is about fd 0 and +/// nothing else. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FixProcessGroup { + /// The child leads its own group: a timeout kill takes its descendants too. + Own, + /// The child stays in doctor's group so it can read the host's terminal + /// without stopping on SIGTTIN. A timeout kill reaches the child only. + Inherited, +} + +impl FixProcessGroup { + /// `stdin_is_terminal` is `std::io::stdin().is_terminal()` in production; + /// tests pass it explicitly so the decision doesn't depend on how the test + /// binary was launched (cargo hands the terminal through, which would make a + /// tree-kill test exercise `Own` in CI and `Inherited` on a laptop). + fn for_fix(stdin: Option<&FixStdin>, stdin_is_terminal: bool) -> Self { + // Piped stdin: doctor owns fd 0. Inherited but non-tty stdin (a GUI host: + // /dev/null, a pipe, a closed fd): there is no terminal on fd 0 to raise + // SIGTTIN, and this runner always pipes stdout/stderr, so the child holds + // no tty descriptor at all and cannot be stopped for touching one. + // + // The residual case is a fix that opens `/dev/tty` itself while fd 0 is + // not a tty but the host does have a controlling terminal — Staged + // launched from a shell with stdin redirected. Under `Own` that fix stops + // on SIGTTIN, and is then killed at the deadline with an accurate notice: + // bounded rather than silent, and the price of the tree kill on the path + // every real fix takes. + if stdin.is_some() || !stdin_is_terminal { + Self::Own + } else { + Self::Inherited + } + } +} + +/// Why a run stopped short of the fix's own exit. One reason per run: whichever +/// the runner observes first wins, and the other is never reported, so a cancel +/// landing on a fix that is timing out (or the reverse) yields one notice and +/// one `Err`, not two of each. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FixStop { + /// [`FixTimeout`]'s deadline passed. + TimedOut, + /// The host's [`FixCancelHandle`] was used. + Cancelled, +} + +/// Prefix of the one notice line a cancelled fix emits through `on_line`; +/// callers grep for it the way they do `doctor: fix timed out after `. +const FIX_CANCELLED_NOTICE_PREFIX: &str = "doctor: fix cancelled"; + +/// How long the runner may block before looking around again: until the +/// deadline, or one cancellation poll interval, whichever is sooner. `None` is +/// "indefinitely" — the run has neither a deadline nor a token, and keeps the +/// plain blocking `recv()`/`wait()` it always had (`recv_timeout(Duration::MAX)` +/// overflows instantly, so unbounded cannot be spelled as a very long slice). +fn fix_wait_slice(deadline: Option, poll: Option) -> Option { + let remaining = deadline.map(|deadline| deadline.saturating_duration_since(Instant::now())); + match (remaining, poll) { + (None, None) => None, + (Some(remaining), None) => Some(remaining), + (None, Some(poll)) => Some(poll), + (Some(remaining), Some(poll)) => Some(remaining.min(poll)), + } +} + /// Spawn `command` through a login shell, stream stdout/stderr lines to -/// `on_line`, and return based on the process exit status. This path is -/// deliberately unbounded: fix commands are user-triggered install/auth/update -/// actions and may prompt or run package managers. Stderr lines are also -/// accumulated so a non-zero exit can surface a useful error message (matching -/// the non-streaming behavior of the previous `execute_command`). +/// `on_line`, and return based on the process exit status. Bounded by +/// `timeout`, which is generous rather than tight: fix commands are +/// user-triggered install/auth/update actions and may prompt or run package +/// managers. Stderr lines are also accumulated so a non-zero exit can surface a +/// useful error message (matching the non-streaming behavior of the previous +/// `execute_command`). +/// +/// With a `cancellation` token the runner also stops on the host's say-so: +/// before the spawn by refusing it, afterwards by killing the fix exactly as the +/// timeout would. It learns of the request by checking the token before every +/// blocking wait and bounding each wait by [`FIX_CANCEL_POLL_INTERVAL`], so a +/// silent fix is still interrupted within one interval — including under +/// [`FixTimeout::Unbounded`], whose waits are otherwise plain blocking calls +/// that nothing wakes. Without a token no wait is sliced and nothing here +/// changes. fn run_command_streaming_blocking( command: &str, env: Option<&DoctorEnv>, + stdin: Option, + timeout: FixTimeout, + cancellation: Option, + process_group: FixProcessGroup, mut on_line: F, ) -> Result<(), String> where F: FnMut(&str), { use std::io::{BufRead, BufReader}; + use std::sync::mpsc::RecvTimeoutError; + + use wait_timeout::ChildExt; + + fn consume(msg: StreamLine, on_line: &mut F, stderr_accum: &mut String) { + match msg { + StreamLine::Stdout(s) => { + on_line(&s); + } + StreamLine::Stderr(s) => { + on_line(&s); + if !stderr_accum.is_empty() { + stderr_accum.push('\n'); + } + stderr_accum.push_str(&s); + } + } + } + + // Claim the pipe before anything is launched: a `FixStdin` another execution + // already consumed can never deliver a line, so the child would block + // forever on a pipe nobody writes — the exact hang this option exists to + // fix. Always a caller bug, so surface it at the call site rather than + // spawning a doomed subprocess. + if let Some(fix_stdin) = &stdin { + fix_stdin.claim()?; + } + + let cancel_requested = || { + cancellation + .as_ref() + .is_some_and(FixCancellation::is_cancelled) + }; + // Only a cancellable run pays for polling; without a token every wait below + // keeps the shape it always had. + let poll = cancellation.as_ref().map(|_| FIX_CANCEL_POLL_INTERVAL); - let mut command = build_shell_command(command, &[], env); - command + let mut shell_command = build_shell_command(command, &[], env); + shell_command .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()); - command::configure_command(&mut command); - let mut child = command + // Opt-in only: without a `FixStdin` the child keeps inheriting the host + // process's stdin, so interactive fixes in terminal hosts are untouched. + if stdin.is_some() { + shell_command.stdin(std::process::Stdio::piped()); + } + // Own the whole tree so a timeout can kill more than the login shell: + // `kill(-pid)` only reaches an `npm install` under `zsh -lc` if the shell + // leads its own group. See [`FixProcessGroup`] for when doctor declines it. + #[cfg(unix)] + if process_group == FixProcessGroup::Own { + use std::os::unix::process::CommandExt; + shell_command.process_group(0); + } + command::configure_command(&mut shell_command); + + // Declared ahead of the spawn so a spawn failure closes the pipe too: the + // claim above is already spent, so the host must not keep getting `Ok` for a + // fix that never started. + let _stdin_closer = stdin.as_ref().map(FixStdinCloser); + + // Last look before the point of no return. Placed after the claim and the + // closer so a pre-spawn cancel leaves the pipe closed like any other exit, + // and before the spawn so there is no child to kill or reap: an `Err` after + // `spawn` would drop the `Child`, whose `Drop` neither kills nor reaps. + if cancel_requested() { + return Err(format!("Fix cancelled before it started: {command}")); + } + + let mut child = shell_command .spawn() .map_err(|e| format!("Failed to run command: {e}"))?; + // Armed at the spawn so the fix's wall clock measures the fix, not the setup + // below it — which is what `FixTimeout` claims. It bounds the recv loop and + // the reap; the replay in `attach` is kept unable to park by + // `MAX_QUEUED_FIX_STDIN_BYTES` rather than by this deadline, since nothing + // interrupts a `write_all` already in progress. + let limit = timeout.duration(); + let deadline = limit.map(|limit| Instant::now() + limit); + + let child_stdin = child.stdin.take(); let stdout = child.stdout.take().expect("stdout was piped"); let stderr = child.stderr.take().expect("stderr was piped"); @@ -1059,28 +1827,152 @@ where } }); + // Deliberately after the readers are running: the replay of pre-spawn lines + // writes inline on this thread, so a queue larger than the pipe buffer would + // deadlock against a child whose output nobody is draining yet. + // `MAX_QUEUED_FIX_STDIN_BYTES` is what actually rules that out — keeping the + // readers first means the ordering isn't the only thing standing between a + // raised cap and a wedged runner. + if let (Some(fix_stdin), Some(child_stdin)) = (&stdin, child_stdin) { + fix_stdin.attach(child_stdin); + } + let mut stderr_accum = String::new(); - for msg in rx.iter() { - match msg { - StreamLine::Stdout(s) => { - on_line(&s); + let mut stop: Option = None; + + loop { + // Checked on every pass, not only when a wait slice runs out: a fix that + // prints continuously never lets `recv_timeout` time out, and would + // otherwise be uncancellable for as long as it kept talking. + if cancel_requested() { + stop = Some(FixStop::Cancelled); + break; + } + let msg = match fix_wait_slice(deadline, poll) { + Some(wait) => match rx.recv_timeout(wait) { + Ok(msg) => msg, + Err(RecvTimeoutError::Timeout) => { + if deadline.is_some_and(|deadline| Instant::now() >= deadline) { + stop = Some(FixStop::TimedOut); + break; + } + // A cancellation poll tick: back to the top to look. + continue; + } + Err(RecvTimeoutError::Disconnected) => break, + }, + None => match rx.recv() { + Ok(msg) => msg, + Err(_) => break, + }, + }; + consume(msg, &mut on_line, &mut stderr_accum); + } + + let status = if stop.is_some() { + None + } else { + // Both pipes hit EOF, so the readers are already done and joining is + // immediate. The process can still outlive its pipes, though, so the + // reap is bounded by the same deadline — and, for a cancellable run, + // sliced the same way, since a `wait` is the other call nothing wakes. + let _ = stdout_thread.join(); + let _ = stderr_thread.join(); + loop { + if cancel_requested() { + stop = Some(FixStop::Cancelled); + break None; } - StreamLine::Stderr(s) => { - on_line(&s); - if !stderr_accum.is_empty() { - stderr_accum.push('\n'); + match fix_wait_slice(deadline, poll) { + Some(wait) => match child + .wait_timeout(wait) + .map_err(|e| format!("Failed to wait for command: {e}"))? + { + Some(status) => break Some(status), + None => { + if deadline.is_some_and(|deadline| Instant::now() >= deadline) { + stop = Some(FixStop::TimedOut); + break None; + } + // A cancellation poll tick. + } + }, + None => { + break Some( + child + .wait() + .map_err(|e| format!("Failed to wait for command: {e}"))?, + ) } - stderr_accum.push_str(&s); } } - } - - let _ = stdout_thread.join(); - let _ = stderr_thread.join(); + }; - let status = child - .wait() - .map_err(|e| format!("Failed to wait for command: {e}"))?; + let Some(status) = status else { + let stop = stop.expect("a missing exit status means the run was stopped"); + // Anything the readers already queued is real output the user should + // see before the notice explaining why it stopped. + while let Ok(msg) = rx.try_recv() { + consume(msg, &mut on_line, &mut stderr_accum); + } + // Kill before phrasing the notice so it reports what was actually + // signalled rather than what we hoped: on the `Inherited` path the child + // is not a group leader, so `kill(-pid)` would fail with `ESRCH` and the + // fallback reaches the direct child only — and whether that is the fix + // itself or a login shell that kept it as a grandchild depends on the + // user's dotfiles. Skip the pointless negative-pid `kill` there, since + // its failure is known by construction. + let reach = match process_group { + FixProcessGroup::Own => command::kill_child_process_group_or_child(&mut child), + FixProcessGroup::Inherited => { + let _ = child.kill(); + command::KillReach::ChildOnly + } + }; + let _ = child.wait(); + // Late lines the kill itself shook loose. + while let Ok(msg) = rx.try_recv() { + consume(msg, &mut on_line, &mut stderr_accum); + } + let survivors = match reach { + command::KillReach::ProcessGroup => "killed the fix and its child processes", + command::KillReach::ChildOnly => { + "killed the fix process; anything it started may still be running" + } + }; + let (notice, mut err) = match stop { + FixStop::TimedOut => { + let limit = limit.expect("a deadline only exists when the fix is bounded"); + ( + format!( + "doctor: fix timed out after {} — {survivors}", + format_duration(limit) + ), + format!( + "Fix timed out after {} without finishing: {command}", + format_duration(limit) + ), + ) + } + FixStop::Cancelled => ( + format!("{FIX_CANCELLED_NOTICE_PREFIX} — {survivors}"), + format!("Fix cancelled before finishing: {command}"), + ), + }; + on_line(¬ice); + // The reader threads are deliberately not joined: a descendant that + // escaped the process group can hold the inherited stdout open long + // after the fix is dead, and waiting on that is the hang this timeout + // exists to end. Dropping `rx` retires them at their next send. + // + // A host that only logs the error string shouldn't be told the tree is + // dead when it isn't. Appended so the existing "names the timeout and the + // command" shape of the message survives. + if reach == command::KillReach::ChildOnly { + err.push_str(" (anything the fix started may still be running)"); + } + return Err(err); + }; if status.success() { Ok(()) @@ -1100,7 +1992,7 @@ mod tests { use std::path::Path; use std::sync::{Arc, Mutex}; - use std::time::Duration; + use std::time::{Duration, Instant}; fn timeout(label: &str, command: &str) -> CommandTimeout { CommandTimeout::new(label, command, Duration::from_secs(15)) @@ -1198,6 +2090,9 @@ mod tests { let result = run_command_streaming( "echo doctor-streaming-marker-hello && echo doctor-streaming-marker-world".to_string(), None, + None, + FixTimeout::Standard, + None, move |line| { lines_clone.lock().unwrap().push(line.to_string()); }, @@ -1220,6 +2115,1315 @@ mod tests { ); } + /// A line sent through the `FixStdin` pipe must reach the child's stdin + /// and dropping the last writer must deliver EOF: `cat` echoes the line + /// and exits 0 only when its stdin closes. Sending before the child + /// spawns also exercises the pre-spawn buffering guarantee. + #[tokio::test] + async fn run_command_streaming_piped_stdin_round_trips_through_cat() { + let lines: Arc>> = Arc::new(Mutex::new(Vec::new())); + let lines_clone = lines.clone(); + let (writer, stdin) = FixStdin::pipe(); + + writer.send_line("doctor-stdin-marker-echo").unwrap(); + drop(writer); + + let result = run_command_streaming( + "cat".to_string(), + None, + Some(stdin), + FixTimeout::Standard, + None, + move |line| { + lines_clone.lock().unwrap().push(line.to_string()); + }, + ) + .await; + + assert!(result.is_ok(), "cat should exit 0 on EOF; got {result:?}"); + let captured = lines.lock().unwrap().clone(); + assert!( + captured.iter().any(|l| l == "doctor-stdin-marker-echo"), + "cat should echo the line written to its piped stdin; captured: {captured:?}", + ); + } + + /// The paste-an-auth-code shape: the command prompts by blocking on a line + /// read, and the caller feeds the answer through the writer while the fix is + /// running. Sending from inside `on_line` — on the fix's own thread, in + /// response to the prompt the fix printed — pins the send to a moment when + /// the pipe is provably live, so the `Ok` asserted here is the delivery + /// guarantee and not the pre-spawn queueing one. + #[tokio::test] + async fn run_command_streaming_piped_stdin_feeds_prompt_style_read() { + let lines: Arc>> = Arc::new(Mutex::new(Vec::new())); + let lines_clone = lines.clone(); + let live_send: Arc>>> = Arc::new(Mutex::new(None)); + let live_send_clone = live_send.clone(); + let (writer, stdin) = FixStdin::pipe(); + + let result = run_command_streaming( + "echo doctor-stdin-prompt; read -r line && echo \"got-$line\"".to_string(), + None, + Some(stdin), + FixTimeout::Standard, + None, + move |line| { + lines_clone.lock().unwrap().push(line.to_string()); + if line == "doctor-stdin-prompt" { + *live_send_clone.lock().unwrap() = + Some(writer.send_line("doctor-stdin-auth-code")); + } + }, + ) + .await; + + assert!(result.is_ok(), "read/echo should exit 0; got {result:?}"); + let captured = lines.lock().unwrap().clone(); + let sent = live_send + .lock() + .unwrap() + .take() + .expect("the fix's prompt line should have reached on_line"); + assert!( + sent.is_ok(), + "a send while the fix is live should report delivery; got {sent:?}", + ); + assert!( + captured.iter().any(|l| l == "got-doctor-stdin-auth-code"), + "prompt-style read should see the sent line; captured: {captured:?}", + ); + } + + /// A writer held across the fix's completion must not hang the run, and the + /// *first* send after it must fail: the runner closes the pipe as it returns, + /// so `Ok` never means "queued for a fix that is already over". That is the + /// berd#99 shape — the login subprocess dies, the user pastes the auth code + /// a beat later — and a host keying off `Ok` would otherwise wait forever + /// with nothing in the log to explain it. + #[tokio::test] + async fn run_command_streaming_piped_stdin_rejects_sends_once_the_fix_finishes() { + let (writer, stdin) = FixStdin::pipe(); + + let result = run_command_streaming( + "echo doctor-stdin-done".to_string(), + None, + Some(stdin), + FixTimeout::Standard, + None, + |_| {}, + ) + .await; + + assert!(result.is_ok(), "echo fix should complete; got {result:?}"); + let err = writer + .send_line("late-line") + .expect_err("the first send after the fix finished should fail"); + assert!( + err.contains("no longer accepting input"), + "error should say the input is closed; got {err:?}", + ); + } + + /// `EPIPE` alone can't carry "the fix is over": a backgrounded grandchild + /// inherits the child's stdin and keeps the read end open, so a write into a + /// finished fix's pipe still succeeds. Only the runner's explicit close on + /// the way out makes this send fail. The grandchild's stdout is redirected so + /// it doesn't also hold the reader threads open — this test is about stdin. + #[cfg(unix)] + #[tokio::test] + async fn run_command_streaming_piped_stdin_rejects_sends_when_a_grandchild_holds_the_pipe() { + let (writer, stdin) = FixStdin::pipe(); + + let result = run_command_streaming( + "sleep 2 >/dev/null 2>&1 & echo doctor-stdin-done".to_string(), + None, + Some(stdin), + FixTimeout::Standard, + None, + |_| {}, + ) + .await; + + assert!(result.is_ok(), "echo fix should complete; got {result:?}"); + assert!( + writer.send_line("late-line").is_err(), + "a grandchild holding the read end must not make a dead fix look writable", + ); + } + + /// A line whose cost divides the cap exactly, so filling the queue with these + /// leaves precisely nothing for the next byte. + fn queue_filling_chunk() -> String { + "x".repeat(MAX_QUEUED_FIX_STDIN_BYTES / 16 - 1) + } + + /// A login shell with no user dotfiles. Faster (~0.2s of startup instead of + /// ~1.6s, and the same on any machine), and — what matters for the tests that + /// background a long-lived descendant — free of dotfiles that leak a + /// descriptor. A leaked duplicate of the inherited stderr keeps the reader + /// threads alive for as long as that descendant lives, which would turn "the + /// fix finished" into "the fix's last descendant exited". Measured locally: + /// under a real `$HOME`, a backgrounded `sleep` with both of its own output + /// streams redirected to `/dev/null` still held an extra pipe descriptor + /// inherited from the shell's startup. + fn dotfile_free_env(home: &Path) -> DoctorEnv { + DoctorEnv::new(vec![ + ("PATH".to_string(), "/usr/bin:/bin".to_string()), + ("HOME".to_string(), home.to_string_lossy().to_string()), + ("USER".to_string(), "doctor-test".to_string()), + ]) + } + + /// Queueing past the cap must fail rather than build a replay the runner + /// would park in. `Err` is the honest answer — not delivered, and not + /// silently held for a spawn that would then wedge the fix's own deadline. + #[test] + fn queued_fix_stdin_bytes_are_capped() { + let (writer, _stdin) = FixStdin::pipe(); + let chunk = queue_filling_chunk(); + let mut accepted = 0; + let err = loop { + match writer.send_line(chunk.clone()) { + Ok(()) => { + accepted += chunk.len() + 1; + assert!( + accepted <= MAX_QUEUED_FIX_STDIN_BYTES, + "queue took {accepted} bytes, past its {MAX_QUEUED_FIX_STDIN_BYTES}-byte cap", + ); + } + Err(e) => break e, + } + }; + assert_eq!( + accepted, MAX_QUEUED_FIX_STDIN_BYTES, + "the whole cap should be usable before a send is refused", + ); + assert!( + err.contains("queue is full"), + "error should name the full queue; got {err:?}", + ); + assert!( + writer.send_line("x").is_err(), + "not even a short line fits once the queue is full", + ); + + // An oversized single line is refused outright rather than truncated: + // half an auth code is worse than none. + let (writer, _stdin) = FixStdin::pipe(); + assert!( + writer + .send_line("x".repeat(MAX_QUEUED_FIX_STDIN_BYTES)) + .is_err(), + "one line over the cap must be refused, not sliced", + ); + } + + /// The cap exists to fit a *virgin* pipe's capacity, because the replay in + /// `attach` writes inline on the runner thread with no deadline armed and + /// nothing able to interrupt a `write_all` in progress. 4 KiB is the one-page + /// floor of a pipe on any platform doctor runs on (macOS and Linux both + /// measure 64 KiB in practice); raising it past that reintroduces a runner + /// that can park forever, so it must not pass silently. Checked at compile + /// time — the bound is on a constant, and a cap that can wedge the runner + /// should not build, let alone wait for someone to run this test. + #[test] + fn queued_fix_stdin_cap_fits_in_a_pipe() { + const { + assert!( + MAX_QUEUED_FIX_STDIN_BYTES <= 4096, + "the pre-spawn queue must fit in the smallest pipe doctor can get", + ); + assert!( + MAX_QUEUED_FIX_STDIN_BYTES >= 256, + "the cap must stay far above any credential a fix prompts for", + ); + } + } + + /// A queue filled to the cap must replay without parking the runner: the + /// backgrounded `sleep` inherits stdin and never reads it, so the read end + /// stays open and the replay cannot short-circuit on `EPIPE` — the writes + /// really do land in the pipe's buffer. Both its output streams are redirected + /// by name so the reader threads still see EOF: zsh's `MULTIOS` leaves the + /// inherited stderr open under `>/dev/null 2>&1`, which would hold the run + /// here for the `sleep`'s whole duration. + /// + /// The elapsed bound is the real assertion, and what keeps a regression from + /// wedging the suite: a replay that parks is eventually released by the + /// backgrounded `sleep` exiting and closing the read end, so raising the cap + /// past a pipe's capacity turns this into a 30s `Ok` — verified locally at + /// 400 KiB — rather than a failure the timing check would otherwise miss. + #[cfg(unix)] + #[tokio::test] + async fn run_command_streaming_replays_a_full_queue_without_parking() { + let (writer, stdin) = FixStdin::pipe(); + let chunk = queue_filling_chunk(); + while writer.send_line(chunk.clone()).is_ok() {} + drop(writer); + + let tmp = unique_tmp_dir("fix-stdin-full-queue-replay"); + let lines: Arc>> = Arc::new(Mutex::new(Vec::new())); + let lines_clone = lines.clone(); + let started = Instant::now(); + let result = run_command_streaming( + "sleep 30 >/dev/null 2>/dev/null & echo doctor-stdin-replay-done".to_string(), + Some(dotfile_free_env(&tmp)), + Some(stdin), + FixTimeout::After(Duration::from_secs(30)), + None, + move |line| lines_clone.lock().unwrap().push(line.to_string()), + ) + .await; + + let _ = std::fs::remove_dir_all(&tmp); + assert!(result.is_ok(), "the fix should complete; got {result:?}"); + assert!( + started.elapsed() < Duration::from_secs(10), + "the replay parked until the backgrounded sleep freed the pipe", + ); + let captured = lines.lock().unwrap().clone(); + assert!( + captured.iter().any(|l| l == "doctor-stdin-replay-done"), + "the fix should have run past the replay; captured: {captured:?}", + ); + } + + /// The hazard this bound exists for: a host thread parked in a `write_all` + /// into a full pipe holds the state mutex indefinitely, and the runner's + /// return path must not queue behind it. The `setsid` descendant inherits + /// stdin and escapes the process group, so the timeout's group kill does not + /// free the pipe — the parked write stays parked until that descendant exits, + /// well after the deadline. + /// + /// Driven on a plain thread with a bounded `recv_timeout` so a regression + /// *fails* rather than hanging the suite: the whole point is that the runner + /// returns at all. The receive window sits between the deadline and the + /// descendant's exit, so a runner that waits on the mutex misses it. + #[cfg(unix)] + #[test] + fn run_command_streaming_returns_while_a_host_send_is_parked() { + let (writer, stdin) = FixStdin::pipe(); + // A throwaway `HOME`: with real dotfiles a login shell takes over a + // second to start, long enough for the deadline to land before the fix + // printed anything to park on. + let tmp = unique_tmp_dir("fix-stdin-parked-send"); + let env = dotfile_free_env(&tmp); + + let (marker_tx, marker_rx) = std::sync::mpsc::channel::<()>(); + let parked_writer = writer.clone(); + // Far more than any pipe holds, so this parks mid-write holding the mutex. + std::thread::spawn(move || { + if marker_rx.recv_timeout(Duration::from_secs(10)).is_ok() { + let _ = parked_writer.send_line("x".repeat(1_000_000)); + } + }); + + let lines: Arc>> = Arc::new(Mutex::new(Vec::new())); + let lines_clone = lines.clone(); + let (result_tx, result_rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let result = run_command_streaming_blocking( + "perl -MPOSIX=setsid -e 'setsid(); sleep 5' >/dev/null 2>&1 & \ + echo doctor-stdin-parked; sleep 30", + Some(&env), + Some(stdin), + FixTimeout::After(Duration::from_secs(1)), + None, + FixProcessGroup::Own, + move |line| { + lines_clone.lock().unwrap().push(line.to_string()); + if line == "doctor-stdin-parked" { + let _ = marker_tx.send(()); + } + }, + ); + let _ = result_tx.send(result); + }); + + let result = result_rx.recv_timeout(Duration::from_secs(3)).expect( + "the runner must return on its deadline while a host send holds the state mutex", + ); + let err = result.expect_err("a fix past its deadline should fail"); + assert!( + err.contains("timed out"), + "error should name the timeout; got {err:?}", + ); + let captured = lines.lock().unwrap().clone(); + assert!( + captured.iter().any(|l| l == "doctor-stdin-parked"), + "the host send never had a live pipe to park on, so this proves \ + nothing; captured: {captured:?}", + ); + + // Still held by the parked write, so this can only be answered from the + // latch outside the mutex — the fast path that keeps a host from queueing + // behind another clone for a fix that is already over. + let (late_tx, late_rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = late_tx.send(writer.send_line("doctor-stdin-late")); + }); + let late = late_rx + .recv_timeout(Duration::from_secs(1)) + .expect("a send after the fix must not wait on a parked write"); + let late_err = late.expect_err("the fix is over, so the send should fail"); + assert!( + late_err.contains("no longer accepting input"), + "error should say the input is closed; got {late_err:?}", + ); + + // The parked thread is deliberately not joined: it unparks when the + // escaped descendant exits and the read end closes, and its guard drop is + // what performs the `Closed` transition the contended `close()` skipped. + let _ = std::fs::remove_dir_all(&tmp); + } + + /// Reusing a `FixStdin` (or a clone) for a second execution must fail + /// loudly rather than hand the child an immediately-EOF'd stdin — the + /// receiver lives with the first run, so a second could only hang. The + /// second run must also never spawn: nothing reaches `on_line`. + #[tokio::test] + async fn run_command_streaming_piped_stdin_errors_when_reused() { + let (writer, stdin) = FixStdin::pipe(); + let reused = stdin.clone(); + writer.send_line("doctor-stdin-reuse-first").unwrap(); + drop(writer); + + let first = run_command_streaming( + "cat".to_string(), + None, + Some(stdin), + FixTimeout::Standard, + None, + |_| {}, + ) + .await; + assert!(first.is_ok(), "first run should succeed; got {first:?}"); + + let lines: Arc>> = Arc::new(Mutex::new(Vec::new())); + let lines_clone = lines.clone(); + let second = run_command_streaming( + "echo doctor-stdin-reuse-second".to_string(), + None, + Some(reused), + FixTimeout::Standard, + None, + move |line| lines_clone.lock().unwrap().push(line.to_string()), + ) + .await; + + let err = second.expect_err("reusing a consumed FixStdin should fail"); + let captured = lines.lock().unwrap().clone(); + assert!( + err.contains("already consumed"), + "error should name the reuse; got {err:?}", + ); + assert!( + captured.is_empty(), + "second run must not spawn; captured: {captured:?}", + ); + } + + /// A fix that never resolves to a command returns before the runner ever sees + /// the pipe, so the entry point has to close it: otherwise a host still + /// holding its writer keeps getting `Ok` from `send_line` for a fix that will + /// never spawn. `UpdateMain` against a real check id is the honest reachable + /// path — `lookup_fix_command` returns `None` for both `Update*` variants, so + /// dispatching one without a `command_override` misses. + #[tokio::test] + async fn execute_fix_streaming_unknown_fix_closes_the_stdin_pipe() { + let (writer, stdin) = FixStdin::pipe(); + let lines: Arc>> = Arc::new(Mutex::new(Vec::new())); + let lines_clone = lines.clone(); + + let result = execute_fix_streaming_with_env_options( + "ai-agent-claude".to_string(), + FixType::UpdateMain, + ExecuteFixOptions { + stdin: Some(stdin), + ..Default::default() + }, + move |line| lines_clone.lock().unwrap().push(line.to_string()), + ) + .await; + + let err = result.expect_err("an UpdateMain with no command_override should fail"); + assert!( + err.contains("Unknown check") || err.contains("UpdateMain"), + "error should name the unresolved fix; got {err:?}", + ); + let captured = lines.lock().unwrap().clone(); + assert!( + captured.is_empty(), + "the fix must not run: no `$ command` preamble; captured: {captured:?}", + ); + let send_err = writer + .send_line("doctor-stdin-after-unknown-fix") + .expect_err("the first send after an unresolved fix should fail"); + assert!( + send_err.contains("no longer accepting input"), + "error should say the input is closed; got {send_err:?}", + ); + } + + /// The guard's non-vacuity test, and the reason it closes only an *unclaimed* + /// pipe: `FixStdin` clones share one state, so a bare `close()` in the + /// unresolved-command arm would EOF a login the user is mid-way through. A + /// second execution handed a clone must error without disturbing the live run. + /// + /// The live fix runs on a plain thread rather than through + /// `run_command_streaming`: the test has to block on `recv_timeout` to know + /// the child is up, and that would starve `#[tokio::test]`'s current-thread + /// runtime before a spawned task ever reached its `spawn_blocking`. + #[tokio::test] + async fn execute_fix_streaming_unknown_fix_leaves_a_live_run_alone() { + let (writer, stdin) = FixStdin::pipe(); + let stale_clone = stdin.clone(); + let (line_tx, line_rx) = std::sync::mpsc::channel::(); + + let live = std::thread::spawn(move || { + run_command_streaming_blocking( + "echo doctor-stdin-live; cat", + None, + Some(stdin), + FixTimeout::Standard, + None, + FixProcessGroup::Own, + move |line| { + let _ = line_tx.send(line.to_string()); + }, + ) + }); + + // Any output proves the child spawned, so the claim has landed. + let marker = line_rx + .recv_timeout(Duration::from_secs(10)) + .expect("the live fix should print its marker"); + assert_eq!(marker, "doctor-stdin-live"); + + let unresolved = execute_fix_streaming_with_env_options( + "ai-agent-claude".to_string(), + FixType::UpdateMain, + ExecuteFixOptions { + stdin: Some(stale_clone), + ..Default::default() + }, + |_| {}, + ) + .await; + assert!( + unresolved.is_err(), + "an UpdateMain with no command_override should fail; got {unresolved:?}", + ); + + writer + .send_line("doctor-stdin-still-live") + .expect("the live fix's pipe must survive the unresolved execution"); + let echoed = line_rx + .recv_timeout(Duration::from_secs(10)) + .expect("the live `cat` should echo the line"); + assert_eq!(echoed, "doctor-stdin-still-live"); + + drop(writer); + let result = live.join().expect("the live fix thread should not panic"); + assert!(result.is_ok(), "`cat` should exit 0 on EOF; got {result:?}"); + } + + /// The default bound must stay at fix scale, not probe scale. A fix is an + /// `npm install -g` behind a corporate proxy or a human doing SSO in a + /// browser; retuning this toward `DEFAULT_PROBE_TIMEOUT` would kill honest + /// work mid-flight. + #[test] + fn default_fix_timeout_stays_at_fix_scale() { + assert_eq!(DEFAULT_FIX_TIMEOUT, Duration::from_secs(600)); + assert_eq!(ExecuteFixOptions::default().timeout, FixTimeout::Standard); + assert_eq!(FixTimeout::Standard.duration(), Some(DEFAULT_FIX_TIMEOUT)); + assert_eq!(FixTimeout::Unbounded.duration(), None); + assert!( + DEFAULT_FIX_TIMEOUT >= DEFAULT_PROBE_TIMEOUT * 30, + "fix timeout must stay far above probe scale", + ); + } + + /// A fix that never finishes must return on its deadline instead of + /// pinning the blocking worker forever — the whole point of the bound. + #[tokio::test] + async fn run_command_streaming_returns_when_the_fix_outlives_its_timeout() { + let lines: Arc>> = Arc::new(Mutex::new(Vec::new())); + let lines_clone = lines.clone(); + let started = Instant::now(); + + let result = run_command_streaming( + "sleep 60".to_string(), + None, + None, + FixTimeout::After(Duration::from_millis(100)), + None, + move |line| lines_clone.lock().unwrap().push(line.to_string()), + ) + .await; + + let err = result.expect_err("a fix past its deadline should fail"); + assert!( + err.contains("timed out") && err.contains("sleep 60"), + "error should name the timeout and the command; got {err:?}", + ); + assert!( + started.elapsed() < Duration::from_secs(2), + "timeout path waited for the fix instead of its deadline", + ); + let captured = lines.lock().unwrap().clone(); + assert!( + captured + .iter() + .any(|l| l.starts_with("doctor: fix timed out")), + "callers should see a notice line explaining the stop; captured: {captured:?}", + ); + } + + /// The group decision is about fd 0 and nothing else. `None` with a non-tty + /// stdin — every fix Staged runs today — must get `Own`, or the timeout's tree + /// kill only ever works on the piped-stdin path nothing uses yet; a terminal + /// host keeps `Inherited` so its fix isn't stopped by SIGTTIN. + #[test] + fn fix_process_group_decision_matrix() { + let (_writer, stdin) = FixStdin::pipe(); + + assert_eq!( + FixProcessGroup::for_fix(Some(&stdin), true), + FixProcessGroup::Own, + "piped stdin means doctor owns fd 0 whatever the host's tty is", + ); + assert_eq!( + FixProcessGroup::for_fix(Some(&stdin), false), + FixProcessGroup::Own, + ); + assert_eq!( + FixProcessGroup::for_fix(None, false), + FixProcessGroup::Own, + "a GUI host's inherited non-tty stdin cannot raise SIGTTIN", + ); + assert_eq!( + FixProcessGroup::for_fix(None, true), + FixProcessGroup::Inherited, + "a terminal host's fix must keep reading the tty without stopping", + ); + } + + /// Let a fix time out with a backgrounded grandchild running, and report + /// whether that grandchild survived the kill. + /// + /// The grandchild records its own pid and then sleeps past every bound here, + /// so liveness by pid is the assertion — no waiting on a marker file the + /// survivor would write later. That matters because the payload does not start + /// the moment the fix does: this is a *login* shell, and a real `$HOME`'s + /// dotfiles take it over a second to start, long enough for a short deadline + /// to fire before the grandchild exists at all and "pass" no matter what the + /// kill reached. Hence both the throwaway `HOME` — no user dotfiles, so ~0.2s + /// of startup instead of ~1.6s, and the same on any machine — and reading the + /// pid file back, which turns that race into a loud failure. + /// + /// `sleep 60` is the shell's last command, so zsh exec-replaces itself with + /// it and the recorded `sleep 300` becomes the direct child's own child — out + /// of reach of `child.kill()`, in reach of `kill(-pgid)`. + #[cfg(unix)] + fn grandchild_survives_timeout_kill( + tag: &str, + stdin: Option, + process_group: FixProcessGroup, + ) -> bool { + let tmp = unique_tmp_dir(tag); + let pid_file = tmp.join("grandchild-pid"); + let env = dotfile_free_env(&tmp); + + let result = run_command_streaming_blocking( + &format!( + "sleep 300 & printf %s $! > {}; sleep 60", + pid_file.display() + ), + Some(&env), + stdin, + // ~15x the dotfile-free login-shell startup, so the grandchild is up + // well before the deadline lands even under a loaded test run. + FixTimeout::After(Duration::from_secs(3)), + None, + process_group, + |_| {}, + ); + assert!(result.is_err(), "timed-out fix should fail; got {result:?}"); + + let survived = recorded_grandchild_survives(&pid_file); + let _ = std::fs::remove_dir_all(&tmp); + survived + } + + /// Whether the process whose pid a payload wrote to `pid_file` is still + /// alive. The kill is asynchronous, so a doomed grandchild gets a moment to + /// go; one that is still there after that is put down so it doesn't outlive + /// the test. A missing or unparseable pid file is a loud failure rather than + /// a silent pass: it means the run was stopped before the payload had forked + /// anything, so whatever the kill reached proves nothing. + #[cfg(unix)] + fn recorded_grandchild_survives(pid_file: &Path) -> bool { + let recorded = std::fs::read_to_string(pid_file).unwrap_or_default(); + let pid: i32 = recorded.trim().parse().unwrap_or_else(|_| { + panic!( + "grandchild never recorded a pid ({recorded:?}): the run was stopped \ + before the login shell got that far, so this proves nothing" + ) + }); + let pid = nix::unistd::Pid::from_raw(pid); + + let deadline = Instant::now() + Duration::from_secs(2); + while Instant::now() < deadline { + if nix::sys::signal::kill(pid, None).is_err() { + return false; + } + std::thread::sleep(Duration::from_millis(50)); + } + let _ = nix::sys::signal::kill(pid, nix::sys::signal::Signal::SIGKILL); + true + } + + /// The tree kill has to work on the inherited-stdin path too — that is the one + /// every real fix takes. Driven through the blocking runner with an explicit + /// decision rather than `run_command_streaming`, because cargo passes the + /// terminal through to test binaries: going through the auto-detection would + /// exercise `Own` in CI and `Inherited` on a laptop, silently. + #[cfg(unix)] + #[test] + fn fix_timeout_kills_the_whole_process_tree_with_inherited_stdin() { + assert!( + !grandchild_survives_timeout_kill( + "fix-timeout-tree-no-stdin", + None, + FixProcessGroup::Own + ), + "backgrounded grandchild outlived the timeout kill", + ); + } + + /// Owning the group means the notice can promise the tree is gone. + #[cfg(unix)] + #[test] + fn fix_timeout_notice_reports_a_group_kill() { + let lines: Arc>> = Arc::new(Mutex::new(Vec::new())); + let lines_clone = lines.clone(); + + // The payload only has to outlive the deadline: what the kill reaches is + // fixed by the group decision, not by the process shape. + let result = run_command_streaming_blocking( + "sleep 5", + None, + None, + FixTimeout::After(Duration::from_millis(200)), + None, + FixProcessGroup::Own, + move |line| lines_clone.lock().unwrap().push(line.to_string()), + ); + + let err = result.expect_err("a fix past its deadline should fail"); + assert!( + !err.contains("may still be running"), + "a group kill must not hedge; got {err:?}", + ); + let notice = timeout_notice(&lines.lock().unwrap()); + assert!( + notice.contains("killed the fix and its child processes"), + "notice should report the group kill; got {notice:?}", + ); + } + + /// Staying in doctor's group means descendants survive the deadline, so both + /// the notice and the error have to say so — `doctor: fix timed out after … — + /// terminating` claimed a tree kill that never happened on this path. + #[cfg(unix)] + #[test] + fn fix_timeout_notice_admits_survivors_when_the_group_is_inherited() { + let lines: Arc>> = Arc::new(Mutex::new(Vec::new())); + let lines_clone = lines.clone(); + + let result = run_command_streaming_blocking( + "sleep 5", + None, + None, + FixTimeout::After(Duration::from_millis(200)), + None, + FixProcessGroup::Inherited, + move |line| lines_clone.lock().unwrap().push(line.to_string()), + ); + + let err = result.expect_err("a fix past its deadline should fail"); + assert!( + err.contains("timed out") && err.contains("sleep 5"), + "error should still name the timeout and the command; got {err:?}", + ); + assert!( + err.contains("may still be running"), + "error should admit the survivors; got {err:?}", + ); + let notice = timeout_notice(&lines.lock().unwrap()); + assert!( + notice.contains("may still be running"), + "notice should admit the survivors; got {notice:?}", + ); + } + + /// The one `doctor: fix timed out after …` line, which every timeout emits and + /// callers grep for. + fn timeout_notice(lines: &[String]) -> String { + lines + .iter() + .find(|l| l.starts_with("doctor: fix timed out after ")) + .unwrap_or_else(|| panic!("no timeout notice in {lines:?}")) + .clone() + } + + /// With piped stdin the shell leads its own process group, so the timeout + /// kill must take the whole tree — not just the login shell, leaving a + /// backgrounded installer running. + #[cfg(unix)] + #[test] + fn fix_timeout_kills_the_whole_process_tree_with_piped_stdin() { + let (_writer, stdin) = FixStdin::pipe(); + + assert!( + !grandchild_survives_timeout_kill( + "fix-timeout-tree", + Some(stdin), + FixProcessGroup::Own + ), + "backgrounded grandchild outlived the timeout kill", + ); + } + + /// A descendant that escaped the process group keeps the inherited + /// stdout/stderr open, so the reader threads never see EOF. The timeout + /// path must not join them — it must return on the deadline regardless + /// (the streaming twin of `command_runner_returns_when_escaped_descendant_ + /// keeps_pipes_open`). + #[cfg(unix)] + #[tokio::test] + async fn run_command_streaming_timeout_returns_when_escaped_descendant_keeps_pipes_open() { + let started = Instant::now(); + + let result = run_command_streaming( + "perl -MPOSIX=setsid -e 'setsid(); sleep 5' & wait".to_string(), + None, + None, + FixTimeout::After(Duration::from_millis(250)), + None, + |_| {}, + ) + .await; + + let err = result.expect_err("a fix past its deadline should fail"); + assert!( + err.contains("timed out"), + "error should name the timeout; got {err:?}", + ); + assert!( + started.elapsed() < Duration::from_secs(2), + "timeout path waited for the escaped descendant to close the pipes", + ); + } + + /// The one `doctor: fix cancelled …` line every cancelled fix emits and + /// callers grep for — exactly one, whatever else the run was doing when the + /// cancel landed. + fn cancel_notice(lines: &[String]) -> String { + let notices: Vec<&String> = lines + .iter() + .filter(|l| l.starts_with(FIX_CANCELLED_NOTICE_PREFIX)) + .collect(); + assert_eq!( + notices.len(), + 1, + "a cancelled fix emits exactly one notice; got {notices:?} in {lines:?}", + ); + assert!( + !lines.iter().any(|l| l.starts_with("doctor: fix timed out")), + "a cancelled fix must not also report a timeout; got {lines:?}", + ); + notices[0].clone() + } + + /// Drive the blocking runner on its own thread, forwarding every `on_line` + /// to the first receiver and the run's result to the second. The runner has + /// to live off the test thread whenever the test needs to *block* — on a + /// marker proving the fix is up, or on the result to time its return — since + /// the runner itself blocks for the fix's whole lifetime. + fn spawn_runner( + command: String, + env: Option, + stdin: Option, + timeout: FixTimeout, + cancellation: Option, + process_group: FixProcessGroup, + ) -> ( + std::sync::mpsc::Receiver, + std::sync::mpsc::Receiver>, + ) { + let (line_tx, line_rx) = std::sync::mpsc::channel::(); + let (result_tx, result_rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let result = run_command_streaming_blocking( + &command, + env.as_ref(), + stdin, + timeout, + cancellation, + process_group, + move |line| { + let _ = line_tx.send(line.to_string()); + }, + ); + let _ = result_tx.send(result); + }); + (line_rx, result_rx) + } + + /// Block until the runner forwards `marker`, returning every line up to and + /// including it. Panics if the fix never gets there. + fn wait_for_marker(lines: &std::sync::mpsc::Receiver, marker: &str) -> Vec { + let mut seen = Vec::new(); + loop { + let line = lines + .recv_timeout(Duration::from_secs(10)) + .unwrap_or_else(|_| panic!("the fix never printed {marker:?}; saw {seen:?}")); + seen.push(line); + if seen.last().is_some_and(|l| l == marker) { + return seen; + } + } + } + + /// The berd#99 abandonment shape: a piped-stdin login that will never exit on + /// its own (the CLI ignores EOF and waits on its browser callback), given up + /// on by the user. Dropping the writer can't end it; the cancel has to kill + /// it — the whole tree, since doctor owns the group on this path — and hand + /// back an `Err` naming the cancellation, not the 600s timeout, promptly. + /// + /// Same payload shape as the timeout tree-kill tests, with a marker after + /// the pid write so the cancel is issued only once the grandchild provably + /// exists; issued from inside `on_line`, on the runner's own thread, so the + /// flag is set before the runner's next look and the test is deterministic. + #[cfg(unix)] + #[test] + fn fix_cancel_kills_the_whole_process_tree_mid_run() { + let tmp = unique_tmp_dir("fix-cancel-tree"); + let pid_file = tmp.join("grandchild-pid"); + let env = dotfile_free_env(&tmp); + let (writer, stdin) = FixStdin::pipe(); + let (handle, cancellation) = FixCancellation::token(); + let lines: Arc>> = Arc::new(Mutex::new(Vec::new())); + let lines_clone = lines.clone(); + let cancel_from_fix = handle.clone(); + let started = Instant::now(); + + let result = run_command_streaming_blocking( + &format!( + "sleep 300 & printf %s $! > {}; echo doctor-cancel-armed; sleep 60", + pid_file.display() + ), + Some(&env), + Some(stdin), + FixTimeout::Standard, + Some(cancellation), + FixProcessGroup::Own, + move |line| { + lines_clone.lock().unwrap().push(line.to_string()); + if line == "doctor-cancel-armed" { + cancel_from_fix.cancel(); + } + }, + ); + + let survived = recorded_grandchild_survives(&pid_file); + let _ = std::fs::remove_dir_all(&tmp); + let err = result.expect_err("a cancelled fix should fail"); + assert!( + err.contains("cancelled") && err.contains("sleep 60"), + "error should name the cancellation and the command; got {err:?}", + ); + assert!( + !err.contains("timed out"), + "the cancel, not the 600s timeout, must be what ended the run; got {err:?}", + ); + assert!( + started.elapsed() < Duration::from_secs(5), + "cancel path waited on the fix instead of killing it", + ); + assert!(handle.is_cancelled()); + let captured = lines.lock().unwrap().clone(); + assert!( + captured.iter().any(|l| l == "doctor-cancel-armed"), + "the fix's own output must survive the cancel; captured: {captured:?}", + ); + let notice = cancel_notice(&captured); + assert!( + notice.contains("killed the fix and its child processes"), + "owning the group, the notice can promise the tree is gone; got {notice:?}", + ); + assert!( + !survived, + "backgrounded grandchild outlived the cancel kill" + ); + assert!( + writer.send_line("doctor-cancel-late").is_err(), + "the pipe must be closed on the cancel path like every other exit", + ); + } + + /// A cancel that lands before the runner spawns anything must refuse the + /// spawn: no child to kill or reap, nothing through `on_line` — the notice is + /// for a fix that ran — and the pipe closed like any other pre-spawn exit, + /// so the host's next `send_line` fails instead of queueing for a fix that + /// will never start. + #[tokio::test] + async fn fix_cancel_before_spawn_runs_nothing() { + let (writer, stdin) = FixStdin::pipe(); + let (handle, cancellation) = FixCancellation::token(); + let lines: Arc>> = Arc::new(Mutex::new(Vec::new())); + let lines_clone = lines.clone(); + + handle.cancel(); + let result = run_command_streaming( + "echo doctor-cancel-never".to_string(), + None, + Some(stdin), + FixTimeout::Standard, + Some(cancellation), + move |line| lines_clone.lock().unwrap().push(line.to_string()), + ) + .await; + + let err = result.expect_err("a fix cancelled before it started should fail"); + assert!( + err.contains("cancelled before it started") && err.contains("echo doctor-cancel-never"), + "error should name the pre-spawn cancellation and the command; got {err:?}", + ); + let captured = lines.lock().unwrap().clone(); + assert!( + captured.is_empty(), + "nothing must run and no notice is owed; captured: {captured:?}", + ); + let send_err = writer + .send_line("doctor-cancel-never-sent") + .expect_err("the first send after a refused spawn should fail"); + assert!( + send_err.contains("no longer accepting input"), + "error should say the input is closed; got {send_err:?}", + ); + } + + /// Once the run has returned, a cancel changes nothing: the fix's `Ok` + /// stands, no notice appears, and the handle still records the request — + /// `cancel` is infallible and idempotent whatever state the run is in. + /// Driven through the public entry point with the builder so the option's + /// whole path is covered, not just the runner. + #[tokio::test] + async fn fix_cancel_after_completion_is_a_no_op() { + let (handle, cancellation) = FixCancellation::token(); + let lines: Arc>> = Arc::new(Mutex::new(Vec::new())); + let lines_clone = lines.clone(); + assert!(!handle.is_cancelled(), "a fresh token is not cancelled"); + + let result = execute_fix_streaming_with_env_options( + "ai-agent-claude".to_string(), + FixType::Auth, + ExecuteFixOptions { + command_override: Some("echo doctor-cancel-finished".to_string()), + ..Default::default() + } + .with_cancellation(cancellation), + move |line| lines_clone.lock().unwrap().push(line.to_string()), + ) + .await; + assert!(result.is_ok(), "the fix should complete; got {result:?}"); + + handle.cancel(); + handle.cancel(); + assert!( + handle.is_cancelled(), + "the request is recorded even when late" + ); + let captured = lines.lock().unwrap().clone(); + assert!( + captured.iter().any(|l| l == "doctor-cancel-finished"), + "the fix should have run to completion; captured: {captured:?}", + ); + assert!( + !captured.iter().any(|l| l.starts_with("doctor: fix")), + "a late cancel owes no notice; captured: {captured:?}", + ); + } + + /// A cancel that arrives on the fix's last line — the runner sees the flag + /// before it sees the readers disconnect — must still yield exactly one + /// notice and one `Err`, with the line that triggered it delivered. This is + /// the race between a cancel and a normal exit, pinned at the point where + /// the runner's observation order makes the outcome deterministic. + #[test] + fn fix_cancel_on_the_final_line_reports_once() { + let (handle, cancellation) = FixCancellation::token(); + let lines: Arc>> = Arc::new(Mutex::new(Vec::new())); + let lines_clone = lines.clone(); + let cancel_from_fix = handle.clone(); + + let result = run_command_streaming_blocking( + "echo doctor-cancel-final", + None, + None, + FixTimeout::Standard, + Some(cancellation), + FixProcessGroup::Own, + move |line| { + lines_clone.lock().unwrap().push(line.to_string()); + if line == "doctor-cancel-final" { + cancel_from_fix.cancel(); + } + }, + ); + + let err = result.expect_err("the runner saw the cancel before the exit"); + assert!( + err.contains("cancelled"), + "error should name the cancellation; got {err:?}", + ); + let captured = lines.lock().unwrap().clone(); + assert!( + captured.iter().any(|l| l == "doctor-cancel-final"), + "the triggering line must not be lost; captured: {captured:?}", + ); + cancel_notice(&captured); + } + + /// `FixTimeout::Unbounded` has no deadline to slice its waits by, so before + /// this a silent fix parked the runner in a plain `recv()` nothing could + /// wake. The cancel arrives from another thread while the fix has been quiet + /// for longer than the poll interval, so it is the poll — not a line — that + /// lets the runner notice, and the return has to land within one interval + /// plus a kill. + #[test] + fn fix_cancel_wakes_an_unbounded_run_from_a_silent_wait() { + let tmp = unique_tmp_dir("fix-cancel-silent"); + let (handle, cancellation) = FixCancellation::token(); + let (lines, result) = spawn_runner( + "echo doctor-cancel-silent; sleep 60".to_string(), + Some(dotfile_free_env(&tmp)), + None, + FixTimeout::Unbounded, + Some(cancellation), + FixProcessGroup::Own, + ); + let mut captured = wait_for_marker(&lines, "doctor-cancel-silent"); + let _ = std::fs::remove_dir_all(&tmp); + + // Well past the poll interval: the runner is parked in a wait slice with + // nothing arriving, so only the poll can deliver the cancel. + std::thread::sleep(FIX_CANCEL_POLL_INTERVAL * 3); + let cancelled_at = Instant::now(); + handle.cancel(); + let result = result + .recv_timeout(Duration::from_secs(3)) + .expect("an unbounded run must still return on cancel"); + let latency = cancelled_at.elapsed(); + assert!( + latency < Duration::from_secs(1), + "cancel took {latency:?}: the runner missed its poll", + ); + + let err = result.expect_err("a cancelled fix should fail"); + assert!( + err.contains("cancelled") && err.contains("sleep 60"), + "error should name the cancellation and the command; got {err:?}", + ); + captured.extend(lines.try_iter()); + cancel_notice(&captured); + } + + /// The opposite of silence: a fix that prints without pause never lets a + /// wait slice run out, so a runner that only looked at the token on poll + /// ticks would be uncancellable for as long as the fix kept talking. The + /// check on every pass through the loop is what bounds this case, and the + /// bound is the same one interval plus a kill. + #[test] + fn fix_cancel_interrupts_a_fix_that_never_goes_quiet() { + let tmp = unique_tmp_dir("fix-cancel-chatter"); + let (handle, cancellation) = FixCancellation::token(); + let (lines, result) = spawn_runner( + "echo doctor-cancel-chatter-start; while :; do echo doctor-cancel-chatter; done" + .to_string(), + Some(dotfile_free_env(&tmp)), + None, + FixTimeout::Standard, + Some(cancellation), + FixProcessGroup::Own, + ); + wait_for_marker(&lines, "doctor-cancel-chatter-start"); + let _ = std::fs::remove_dir_all(&tmp); + + std::thread::sleep(FIX_CANCEL_POLL_INTERVAL * 3); + let cancelled_at = Instant::now(); + handle.cancel(); + let result = result + .recv_timeout(Duration::from_secs(3)) + .expect("a fix that never stops printing must still return on cancel"); + let latency = cancelled_at.elapsed(); + assert!( + latency < Duration::from_secs(1), + "cancel took {latency:?}: the runner only looks between lines", + ); + + let err = result.expect_err("a cancelled fix should fail"); + assert!( + err.contains("cancelled"), + "error should name the cancellation; got {err:?}", + ); + let captured: Vec = lines.try_iter().collect(); + assert!( + captured.iter().any(|l| l == "doctor-cancel-chatter"), + "the fix should have been mid-chatter when cancelled", + ); + cancel_notice(&captured); + } + + /// A fix can close both its pipes and keep running — the runner is then past + /// the recv loop and parked in the reap, a `wait` nothing else wakes. The + /// payload redirects its own stdout/stderr away from the pipes, records that + /// it has done so, and sleeps; the cancel is issued only once the readers + /// have provably seen EOF. Both the bounded (`wait_timeout`) and unbounded + /// (`wait`) reaps have to come back within a poll interval. + #[cfg(unix)] + fn cancel_during_the_reap_returns_promptly(tag: &str, timeout: FixTimeout) { + let tmp = unique_tmp_dir(tag); + let pipes_closed = tmp.join("pipes-closed"); + let env = dotfile_free_env(&tmp); + let (handle, cancellation) = FixCancellation::token(); + + let (lines, result) = spawn_runner( + format!( + "echo doctor-cancel-reap; exec >/dev/null 2>/dev/null; touch {}; sleep 60", + pipes_closed.display() + ), + Some(env), + None, + timeout, + Some(cancellation), + FixProcessGroup::Own, + ); + let mut captured = wait_for_marker(&lines, "doctor-cancel-reap"); + + let deadline = Instant::now() + Duration::from_secs(10); + while !pipes_closed.exists() { + assert!( + Instant::now() < deadline, + "the payload never got past redirecting its pipes", + ); + std::thread::sleep(Duration::from_millis(20)); + } + // The readers hit EOF the instant the shell redirected, and joining them + // is immediate, so by now the runner is in the reap; a few poll intervals + // more and it has been parked there for the whole of one. + std::thread::sleep(FIX_CANCEL_POLL_INTERVAL * 3); + let cancelled_at = Instant::now(); + handle.cancel(); + let result = result + .recv_timeout(Duration::from_secs(3)) + .expect("a run parked in its reap must still return on cancel"); + let latency = cancelled_at.elapsed(); + let _ = std::fs::remove_dir_all(&tmp); + assert!( + latency < Duration::from_secs(1), + "cancel took {latency:?}: the reap missed its poll", + ); + + let err = result.expect_err("a cancelled fix should fail"); + assert!( + err.contains("cancelled") && err.contains("sleep 60"), + "error should name the cancellation and the command; got {err:?}", + ); + captured.extend(lines.try_iter()); + cancel_notice(&captured); + } + + #[cfg(unix)] + #[test] + fn fix_cancel_returns_promptly_from_a_bounded_reap() { + cancel_during_the_reap_returns_promptly("fix-cancel-reap-bounded", FixTimeout::Standard); + } + + #[cfg(unix)] + #[test] + fn fix_cancel_returns_promptly_from_an_unbounded_reap() { + cancel_during_the_reap_returns_promptly("fix-cancel-reap-unbounded", FixTimeout::Unbounded); + } + + /// The cancel path shares the timeout's honesty about reach: in doctor's + /// group the kill stops at the direct child, and both the notice and the + /// `Err` have to say so rather than claim a tree kill that never happened. + #[cfg(unix)] + #[test] + fn fix_cancel_notice_admits_survivors_when_the_group_is_inherited() { + let (handle, cancellation) = FixCancellation::token(); + let lines: Arc>> = Arc::new(Mutex::new(Vec::new())); + let lines_clone = lines.clone(); + let cancel_from_fix = handle.clone(); + + let result = run_command_streaming_blocking( + "echo doctor-cancel-inherited; sleep 5", + None, + None, + FixTimeout::Standard, + Some(cancellation), + FixProcessGroup::Inherited, + move |line| { + lines_clone.lock().unwrap().push(line.to_string()); + if line == "doctor-cancel-inherited" { + cancel_from_fix.cancel(); + } + }, + ); + + let err = result.expect_err("a cancelled fix should fail"); + assert!( + err.contains("cancelled") && err.contains("sleep 5"), + "error should still name the cancellation and the command; got {err:?}", + ); + assert!( + err.contains("may still be running"), + "error should admit the survivors; got {err:?}", + ); + let notice = cancel_notice(&lines.lock().unwrap()); + assert!( + notice.contains("may still be running"), + "notice should admit the survivors; got {notice:?}", + ); + } + + /// The handle is meant to be parked in a host's static map and used from a + /// UI command on another thread, so it has to be shareable; the option has + /// to keep `ExecuteFixOptions`'s derives; `None` has to stay the default so + /// existing callers are untouched; and the poll interval that bounds cancel + /// latency must stay well under what a user reads as a stuck button. + #[test] + fn fix_cancellation_contract() { + fn shareable() {} + shareable::(); + shareable::(); + + assert!(ExecuteFixOptions::default().cancellation.is_none()); + assert!( + FIX_CANCEL_POLL_INTERVAL <= Duration::from_millis(250), + "cancel latency must stay imperceptible", + ); + assert!( + FIX_CANCEL_POLL_INTERVAL >= Duration::from_millis(10), + "a cancellable run must not spin", + ); + } + /// `execute_fix(|_| {})` and `execute_fix_streaming(.., |_| {})` must /// produce identical results for the same fix lookup — `execute_fix` is /// supposed to be a thin delegate. @@ -1620,8 +3824,8 @@ mod tests { FixType::UpdateMain, ExecuteFixOptions { command_override: Some(script_name.to_string()), - npm_registry: None, env: Some(env), + ..Default::default() }, move |line| { lines_clone.lock().unwrap().push(line.to_string()); @@ -1674,8 +3878,8 @@ mod tests { FixType::UpdateMain, ExecuteFixOptions { command_override: Some(command.to_string()), - npm_registry: None, env: Some(env), + ..Default::default() }, move |line| { lines_clone.lock().unwrap().push(line.to_string()); diff --git a/crates/doctor/src/timeout_check.rs b/crates/doctor/src/timeout_check.rs index e4bfa607d..9247254b5 100644 --- a/crates/doctor/src/timeout_check.rs +++ b/crates/doctor/src/timeout_check.rs @@ -14,6 +14,7 @@ pub(crate) struct TimeoutCheck<'a> { bridge_path: Option, install_source: Option, auth_status: Option, + login_command: Option, main: Option, bridge: Option, raw_suffix: Option<&'a str>, @@ -39,6 +40,7 @@ impl<'a> TimeoutCheck<'a> { bridge_path: None, install_source: None, auth_status: None, + login_command: None, main: None, bridge: None, raw_suffix: None, @@ -60,6 +62,13 @@ impl<'a> TimeoutCheck<'a> { self } + /// The provider's static login command, for a timed-out check whose binary + /// did resolve. See [`DoctorCheck::login_command`]. + pub(crate) fn login_command(mut self, login_command: Option) -> Self { + self.login_command = login_command; + self + } + pub(crate) fn raw_suffix(mut self, raw_suffix: Option<&'a str>) -> Self { self.raw_suffix = raw_suffix; self @@ -86,6 +95,7 @@ pub(crate) fn command_timeout_check(input: TimeoutCheck<'_>) -> DoctorCheck { bridge_path: input.bridge_path, raw_output: Some(raw), auth_status: input.auth_status, + login_command: input.login_command, installed_version: None, latest_version: None, update_available: None, diff --git a/crates/doctor/src/types.rs b/crates/doctor/src/types.rs index 0aa18fbee..ed79fd590 100644 --- a/crates/doctor/src/types.rs +++ b/crates/doctor/src/types.rs @@ -137,6 +137,19 @@ pub struct DoctorCheck { pub raw_output: Option, /// Authentication status, when the check probes credentials. pub auth_status: Option, + /// The provider's interactive login command, whenever its binary resolved — + /// regardless of `auth_status`. A static per-provider capability, not a + /// verdict: `auth_status` comes from a probe that can report `Authenticated` + /// for credentials the vendor will reject (Claude's `auth status` exits 0 on + /// an expired token and never checks expiry), so a host that has just seen + /// an authentication failure from the live agent needs to know a login + /// *exists* without doctor having to agree that one is *needed*. `fix_type` + /// / `fix_command` keep their meaning — set only when the probe positively + /// reported a signed-out agent — so a passing check still offers no fix. + /// `None` for providers without a login command and for non-agent checks. + /// Additive on the wire: absent in older payloads, read back as `None`. + #[serde(default)] + pub login_command: Option, /// Installed version string, if detected. pub installed_version: Option, /// Latest available version string, if known.