304 lines
9.9 KiB
Rust
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);
|
|
}
|
|
}
|