hyperhive/hive-ag3nt/src/login_session.rs

304 lines
9.9 KiB
Rust

//! `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<String>,
/// 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<String>,
}
/// A running `claude auth login` subprocess.
pub struct LoginSession {
child: Mutex<Child>,
/// 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<Option<ChildStdin>>,
state: Arc<Mutex<State>>,
}
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<Self> {
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/<agent> 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<String> {
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<String> {
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<String>) {
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<R: tokio::io::AsyncRead + Unpin>(
mut reader: BufReader<R>,
state: Arc<Mutex<State>>,
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<String> {
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<Option<Arc<LoginSession>>>) {
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);
}
}