diff --git a/CLAUDE.md b/CLAUDE.md index 5a7e663..f180de7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -249,9 +249,12 @@ See PLAN.md → "Phase 8" for the full design. Summary: - **Login from the per-agent web UI.** Spawn `claude /login` with plain stdio pipes (no PTY initially), surface the OAuth URL from stdout on the page, accept the resulting code via a paste field, write it to the process - stdin. On success, harness transitions out of "needs login" and enters the - turn loop. If pipes turn out to be insufficient (claude refuses without a - TTY, raw-mode input, ANSI-only output) we redo the backend with a PTY. + stdin. Once `~/.claude/` populates, the existing needs-login polling loop + flips state to Online and starts the turn loop — no separate signaling + needed. The exact command is overridable via `HYPERHIVE_LOGIN_CMD` so we + can adjust without rebuilding. If pipes turn out to be insufficient + (claude refuses without a TTY, raw-mode input, ANSI-only output) we redo + the backend with a PTY (e.g. `portable-pty`). Implementation order: bind-mount/dir creation → approval-gated spawn + spinner → "needs login" partial run → PTY login endpoint. The login UI has diff --git a/hive-ag3nt/src/lib.rs b/hive-ag3nt/src/lib.rs index 4a62049..b601724 100644 --- a/hive-ag3nt/src/lib.rs +++ b/hive-ag3nt/src/lib.rs @@ -3,6 +3,7 @@ pub mod client; pub mod login; +pub mod login_session; pub mod web_ui; /// Default socket path inside the container — bind-mounted by `hive-c0re`. diff --git a/hive-ag3nt/src/login_session.rs b/hive-ag3nt/src/login_session.rs new file mode 100644 index 0000000..100b5b1 --- /dev/null +++ b/hive-ag3nt/src/login_session.rs @@ -0,0 +1,264 @@ +//! `claude /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] = &["/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 /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 /login`. Failing to spawn returns an error + /// before any state is registered. + 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 for the credentials dir; the bind-mount + // puts it at /root/.claude, which is already the default home + // for uid 0 inside the container. Nothing extra to set 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). + 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(); + } + + pub fn output(&self) -> String { + self.state.lock().unwrap().output.clone() + } + + pub fn url(&self) -> Option { + self.state.lock().unwrap().url.clone() + } + + pub fn finished(&self) -> bool { + self.state.lock().unwrap().finished + } + + 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. + 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. + 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 /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. +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); + } +} diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index 4eb02f9..e09adb2 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -7,9 +7,16 @@ use std::net::SocketAddr; use std::sync::{Arc, Mutex}; use anyhow::{Context, Result}; -use axum::{Router, extract::State, response::Html, routing::get}; +use axum::{ + Form, Router, + extract::State, + response::{Html, IntoResponse, Redirect, Response}, + routing::{get, post}, +}; +use serde::Deserialize; use crate::login::LoginState; +use crate::login_session::{LoginSession, drop_if_finished}; /// Live login state for the web UI. The harness updates this in place as it /// transitions between `NeedsLogin` and `Online`; the UI reads on each @@ -20,11 +27,21 @@ pub type LoginStateCell = Arc>; struct AppState { label: String, login: LoginStateCell, + session: Arc>>>, } pub async fn serve(label: String, port: u16, login: LoginStateCell) -> Result<()> { - let state = AppState { label, login }; - let app = Router::new().route("/", get(index)).with_state(state); + let state = AppState { + label, + login, + session: Arc::new(Mutex::new(None)), + }; + let app = Router::new() + .route("/", get(index)) + .route("/login/start", post(post_login_start)) + .route("/login/code", post(post_login_code)) + .route("/login/cancel", post(post_login_cancel)) + .with_state(state); let addr = SocketAddr::from(([0, 0, 0, 0], port)); let listener = tokio::net::TcpListener::bind(addr) .await @@ -35,25 +52,121 @@ pub async fn serve(label: String, port: u16, login: LoginStateCell) -> Result<() } async fn index(State(state): State) -> Html { + drop_if_finished(&state.session); let login = *state.login.lock().unwrap(); - let (status_label, status_class, body_extra) = match login { - LoginState::Online => ( - "▓█▓▒░ harness alive — turn loop running ▓█▓▒░", - "status-online", - "

phase 6a placeholder — turn-loop status / inbox / xterm.js coming in 6b+

", - ), - LoginState::NeedsLogin => ( - "▓█▓▒░ NEEDS L0G1N ▓█▓▒░", - "status-needs-login", - "

No Claude session in ~/.claude/. The harness is up and reachable on this UI, but the turn loop is paused until you log in.

\n

Phase 8 step 4 will wire a login form here that drives claude /login over plain stdio pipes. Until then: nixos-container root-login the container and run claude interactively, then restart the harness.

", - ), + let session_snapshot = state.session.lock().unwrap().clone(); + let body = match (login, session_snapshot) { + (LoginState::Online, _) => render_online(), + (LoginState::NeedsLogin, None) => render_needs_login_idle(), + (LoginState::NeedsLogin, Some(session)) => render_login_in_progress(&session), }; Html(format!( - "\n\n\n\n\n{label} // hyperhive\n{STYLE}\n\n\n
░▒▓█▓▒░  {label}  ░▒▓█▓▒░  hyperhive ag3nt  ░▒▓█▓▒░
\n

◆ {label} ◆

\n
══════════════════════════════════════════════════════════════
\n

{status_label}

\n{body_extra}\n\n\n", + "\n\n\n\n\n{label} // hyperhive\n{STYLE}\n\n\n
░▒▓█▓▒░  {label}  ░▒▓█▓▒░  hyperhive ag3nt  ░▒▓█▓▒░
\n

◆ {label} ◆

\n
══════════════════════════════════════════════════════════════
\n{body}\n\n\n", label = state.label, )) } +fn render_online() -> String { + "

▓█▓▒░ harness alive — turn loop running ▓█▓▒░

\n

phase 6a placeholder — turn-loop status / inbox / xterm.js coming in 6b+

".into() +} + +fn render_needs_login_idle() -> String { + "

▓█▓▒░ NEEDS L0G1N ▓█▓▒░

\n

No Claude session in ~/.claude/. The harness is up but the turn loop is paused until you log in.

\n
\n \n
\n

Spawns claude /login over plain stdio pipes. The OAuth URL will appear here when claude emits it; paste the resulting code back into the form below.

".into() +} + +fn render_login_in_progress(session: &Arc) -> String { + let url_block = match session.url() { + Some(url) => format!( + "

{url}

\n

open this URL in a browser, complete the OAuth flow, paste the resulting code below.

", + url = html_escape(&url), + ), + None => "

waiting for claude to emit an OAuth URL on stdout… (output below)

".into(), + }; + let exit_badge = if session.finished() { + let note = session.exit_note().unwrap_or_else(|| "exited".into()); + format!( + "

claude process exited: {note}. Start over if needed.

", + note = html_escape(¬e), + ) + } else { + String::new() + }; + let output = session.output(); + let code_form = if session.finished() { + String::new() + } else { + "
\n \n \n
".into() + }; + let cancel_form = "
\n \n
".to_owned(); + format!( + "

▓█▓▒░ L0G1N 1N PR0GRESS ▓█▓▒░

\n{url_block}\n{code_form}\n{cancel_form}\n{exit_badge}\n

output

\n
{output}
", + output = html_escape(&output), + ) +} + +async fn post_login_start(State(state): State) -> Response { + drop_if_finished(&state.session); + { + let guard = state.session.lock().unwrap(); + if guard.is_some() { + return Redirect::to("/").into_response(); + } + } + match LoginSession::start() { + Ok(session) => { + *state.session.lock().unwrap() = Some(Arc::new(session)); + Redirect::to("/").into_response() + } + Err(e) => error_response(&format!("login start failed: {e:#}")), + } +} + +#[derive(Deserialize)] +struct CodeForm { + code: String, +} + +async fn post_login_code( + State(state): State, + Form(form): Form, +) -> Response { + let session = state.session.lock().unwrap().clone(); + let Some(session) = session else { + return error_response("no login session running"); + }; + if let Err(e) = session.submit_code(&form.code).await { + return error_response(&format!("submit code failed: {e:#}")); + } + Redirect::to("/").into_response() +} + +async fn post_login_cancel(State(state): State) -> Response { + let session = state.session.lock().unwrap().take(); + if let Some(session) = session { + session.close_stdin().await; + session.kill(); + } + Redirect::to("/").into_response() +} + +fn error_response(message: &str) -> Response { + ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + Html(format!( + "\n{STYLE}

error

{msg}

← back

", + msg = html_escape(message), + )), + ) + .into_response() +} + +fn html_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + const STYLE: &str = r#" "#;