//! `claude auth login` driver. Spawns the login command under plain stdio pipes, //! accumulates stdout+stderr in a shared buffer (so the web UI can show //! whatever URL/prompt claude emits), and writes paste-back codes from the //! UI into the child's stdin. //! //! No PTY — we're betting `claude` produces a parseable URL on stdout and //! accepts a code on stdin even when not on a terminal. If it refuses or //! garbles, we'll redo this module backed by `portable-pty` (see PLAN.md //! Phase 8). use std::process::Stdio; use std::sync::{Arc, Mutex}; use anyhow::{Context, Result}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::process::{Child, ChildStdin, Command}; const DEFAULT_CMD: &str = "claude"; const DEFAULT_ARGS: &[&str] = &["auth", "login"]; #[derive(Default)] struct State { /// Concatenated stdout+stderr as it streams from the child. output: String, /// First URL-looking substring we saw in the output. Surface this on the /// web UI as the link the operator should open. url: Option, /// Set when the child has exited. The web UI uses this to know whether /// the operator can still paste a code. finished: bool, /// Exit status note (e.g. "exited with code 0", "killed by signal 15"), /// shown next to a "finished" badge once the child returns. exit_note: Option, } /// A running `claude auth login` subprocess. pub struct LoginSession { child: Mutex, /// Tokio mutex because we hold the guard across the `write_all().await` /// in `submit_code`. The other locks are blocking-only and stay on /// `std::sync::Mutex`. stdin: tokio::sync::Mutex>, state: Arc>, } impl LoginSession { /// Spawn the login command. The exact binary/args are configurable via /// `HYPERHIVE_LOGIN_CMD` (single string, shell-split into argv); by /// default we run `claude auth login`. Failing to spawn returns an error /// before any state is registered. /// /// # Errors /// /// Returns an error if spawning the login command fails, or if the child's /// stdio handles cannot be acquired. pub fn start() -> Result { let (cmd, args) = resolve_command(); tracing::info!(%cmd, ?args, "spawning login session"); let mut child = Command::new(&cmd) .args(&args) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) // `claude` reads $HOME/.claude for the credentials dir. The // harness service env sets HOME to /home/ and the // bind-mount lands the OAuth dir at the same path, so the // child inherits the right HOME without any further wiring // here. .kill_on_drop(true) .spawn() .with_context(|| format!("spawn `{cmd}`"))?; let stdin = child.stdin.take().context("child stdin")?; let stdout = child.stdout.take().context("child stdout")?; let stderr = child.stderr.take().context("child stderr")?; let state = Arc::new(Mutex::new(State::default())); tokio::spawn(pump(BufReader::new(stdout), state.clone(), "stdout")); tokio::spawn(pump(BufReader::new(stderr), state.clone(), "stderr")); Ok(Self { child: Mutex::new(child), stdin: tokio::sync::Mutex::new(Some(stdin)), state, }) } /// Write `code` (plus a newline) to the child's stdin. Returns an error /// if the stdin has already been closed (e.g. after the child exited or /// after a prior submission consumed it). /// /// # Errors /// /// Returns an error if the login stdin is already closed, or if writing /// to or flushing the stdin pipe fails. pub async fn submit_code(&self, code: &str) -> Result<()> { let mut guard = self.stdin.lock().await; let stdin = guard.as_mut().context("login stdin already closed")?; let line = format!("{}\n", code.trim()); stdin .write_all(line.as_bytes()) .await .context("write code to claude stdin")?; stdin.flush().await.context("flush claude stdin")?; Ok(()) } /// Close stdin so claude sees EOF (useful if it's waiting for more input /// after the code submit). pub async fn close_stdin(&self) { let _ = self.stdin.lock().await.take(); } /// # Panics /// /// Panics if the internal lock is poisoned. #[must_use] pub fn output(&self) -> String { self.state.lock().unwrap().output.clone() } /// # Panics /// /// Panics if the internal lock is poisoned. #[must_use] pub fn url(&self) -> Option { self.state.lock().unwrap().url.clone() } /// # Panics /// /// Panics if the internal lock is poisoned. #[must_use] pub fn finished(&self) -> bool { self.state.lock().unwrap().finished } /// # Panics /// /// Panics if the internal lock is poisoned. #[must_use] pub fn exit_note(&self) -> Option { self.state.lock().unwrap().exit_note.clone() } /// Best-effort: poll the child once and update `finished`/`exit_note`. /// Called by the web UI on each render so the state stays fresh without /// running a dedicated reaper task. /// /// # Panics /// /// Panics if an internal lock is poisoned. pub fn poll(&self) { let mut child = self.child.lock().unwrap(); match child.try_wait() { Ok(Some(status)) => { let mut s = self.state.lock().unwrap(); s.finished = true; s.exit_note = Some(format!("{status}")); } Ok(None) => {} Err(e) => { let mut s = self.state.lock().unwrap(); s.finished = true; s.exit_note = Some(format!("try_wait error: {e}")); } } } /// Kill the child if it's still running. Idempotent. /// /// # Panics /// /// Panics if the internal lock is poisoned. pub fn kill(&self) { if let Err(e) = self.child.lock().unwrap().start_kill() { tracing::warn!(error = ?e, "kill login child"); } } } fn resolve_command() -> (String, Vec) { if let Ok(raw) = std::env::var("HYPERHIVE_LOGIN_CMD") { // Whitespace-only split — no quote handling. Fine for "claude auth login" // style overrides; if we need anything with embedded spaces we'll // switch to shell-words. let mut parts = raw.split_whitespace().map(str::to_owned); if let Some(cmd) = parts.next() { return (cmd, parts.collect()); } } ( DEFAULT_CMD.into(), DEFAULT_ARGS.iter().map(|s| (*s).to_owned()).collect(), ) } async fn pump( mut reader: BufReader, state: Arc>, tag: &'static str, ) { let mut buf = String::new(); loop { buf.clear(); // read_line breaks on \n; for claude's TUI output that flushes by // line this is fine. If it ever blasts a single un-newlined blob, // we'll miss it until EOF (acceptable for the URL surface — claude // prints the URL on its own line). match reader.read_line(&mut buf).await { Ok(0) => { state.lock().unwrap().finished = true; break; } Ok(_) => { let mut s = state.lock().unwrap(); if s.url.is_none() && let Some(url) = extract_url(&buf) { tracing::info!(%url, %tag, "login URL detected"); s.url = Some(url); } s.output.push_str(&buf); } Err(e) => { tracing::warn!(error = ?e, %tag, "login pump read error"); let mut s = state.lock().unwrap(); s.finished = true; s.exit_note = Some(format!("pump {tag} error: {e}")); break; } } } } /// Return the first `https://…` substring on the line, terminating at any /// ASCII whitespace. Good enough for capturing claude's OAuth link without a /// regex dependency. fn extract_url(line: &str) -> Option { let start = line.find("https://")?; let tail = &line[start..]; let end = tail .find(|c: char| c.is_ascii_whitespace()) .unwrap_or(tail.len()); let url = tail[..end].trim_end_matches(['.', ',', ')', ']']); if url.len() > "https://".len() { Some(url.to_owned()) } else { None } } /// Helper used by the web UI to gate "is there a session running right now" /// without holding both this module's mutex and the `AppState`'s at once. /// /// # Panics /// /// Panics if the internal lock is poisoned. pub fn drop_if_finished(slot: &Mutex>>) { let mut guard = slot.lock().unwrap(); if let Some(s) = guard.as_ref() { s.poll(); if s.finished() { *guard = None; } } } impl Drop for LoginSession { fn drop(&mut self) { // kill_on_drop on the Command also ensures the child dies, but we // belt-and-brace it in case the runtime detaches. let _ = self.child.lock().unwrap().start_kill(); } } #[cfg(test)] mod tests { use super::extract_url; #[test] fn picks_first_https() { let line = " Go to https://claude.ai/oauth/abc?xyz=1 in your browser.\n"; assert_eq!( extract_url(line).as_deref(), Some("https://claude.ai/oauth/abc?xyz=1"), ); } #[test] fn trailing_punctuation_stripped() { let line = "Open https://example.com/abc).\n"; assert_eq!( extract_url(line).as_deref(), Some("https://example.com/abc"), ); } #[test] fn no_url() { assert_eq!(extract_url("nothing here\n"), None); } }