phase 8 step 4: web-ui login endpoint (pipes, no pty)
This commit is contained in:
parent
78fae44ee5
commit
dff93b603d
4 changed files with 437 additions and 21 deletions
264
hive-ag3nt/src/login_session.rs
Normal file
264
hive-ag3nt/src/login_session.rs
Normal file
|
|
@ -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<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 /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 /login`. Failing to spawn returns an error
|
||||
/// before any state is registered.
|
||||
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 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<String> {
|
||||
self.state.lock().unwrap().url.clone()
|
||||
}
|
||||
|
||||
pub fn finished(&self) -> bool {
|
||||
self.state.lock().unwrap().finished
|
||||
}
|
||||
|
||||
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.
|
||||
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<String>) {
|
||||
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<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.
|
||||
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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue