Watch
0
0
Fork
You've already forked hyperhive
0

hive-agent: the agent has no login flow of its own, credentials come from the swarm

This commit is contained in:
damocles 2026-11-06 23:17:11 +01:00 • committed by mara
commit ac3b3cfcd3
9 changed files with 46 additions and 844 deletions

View file

@ -27,8 +27,8 @@ map of the module tree.
- **`turn.rs`** — the turn-loop policy layer: renders the system
prompt + MCP config, runs the turn through `hive-runtime`, classifies
the outcome, and feeds the event/turn-stats sinks.
- **`login.rs` / `login_session.rs`** — first-run and session-resume
auth flow for the `claude` CLI.
- **`login.rs`** — the Online or NeedsLogin state at boot, and the wait
for credentials to arrive (the swarm login stores them).
- **`mcp_config.rs`** — renders the per-turn `--mcp-config` /
`--allowedTools` blob from the agent's permissions.
- **`todos.rs` / `reminders.rs` / `todo_server.rs`** — the harness-local

View file

@ -108,12 +108,9 @@ pub enum LiveEvent {
/// harness hit a transport error.
TurnEnd { ok: bool, note: Option<String> },
/// Harness reachability flipped: `"online"` /
/// `"needs_login_idle"` / `"needs_login_in_progress"`. The web UI
/// drives the alive badge from this so the operator sees a login
/// land (or get revoked) without polling. Session detail
/// (`url`/`output`/`finished`) is still served by `/api/state`
/// during the short-lived in-progress window — the client
/// re-fetches only while that flow is active.
/// `"needs_login_idle"`. The web UI drives the alive badge from this
/// so the operator sees a login land (or get revoked) without
/// polling.
StatusChanged { status: String },
/// The active claude model changed (a swarm value applied or cleared).
/// The web UI updates the chip + the per-turn stats sink will key off this
@ -875,9 +872,9 @@ impl Bus {
});
}
/// Broadcast a status flip (online / `needs_login_*` / `rate_limited`).
/// Broadcast a status flip (online / `needs_login_idle` / `rate_limited`).
/// Called by the bin entry points + `turn::wait_for_login` + the
/// `post_login_*` handlers — every site that mutates the
/// `/api/logout` handler — every site that mutates the
/// `Arc<Mutex<LoginState>>` should also call this so the web UI
/// drops its periodic /api/state poll while a turn loop is running.
///
@ -900,9 +897,8 @@ impl Bus {
self.rate_limited.store(false, Ordering::Relaxed);
}
// Read the current persisted needs_login so we don't flip it on
// statuses that shouldn't touch it (e.g. `needs_login_in_progress`
// is a transient mid-flow status; only `needs_login_idle` and
// `online` should change the persistent flag).
// statuses that shouldn't touch it (e.g. `rate_limited`; only
// `needs_login_idle` and `online` should change the persistent flag).
//
// Known non-atomicity: this is a read-modify-write. Two concurrent
// `emit_status` calls could clobber each other's `needs_login`

View file

@ -97,7 +97,8 @@ pub enum LoginState {
/// `~/.claude/` has credentials; turn loop is running.
Online,
/// `~/.claude/` is empty; harness is up, web UI is bound, turn loop is NOT
/// running. Operator needs to complete login from the web UI.
/// running. The operator logs in from the swarm UI; the credentials then
/// arrive from the swarm secret store.
NeedsLogin,
}
@ -378,7 +379,7 @@ mod tests {
#[test]
fn has_fresh_credentials_rewrite_after_failure_resumes() {
// The operator's `/login/code` flow rewrites the stale file in
// A fresh login from the swarm rewrites the stale file in
// place; its mtime bumps strictly past the failure baseline.
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join(".credentials.json"), b"{}").unwrap();

View file

@ -1,313 +0,0 @@
//! `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 by `poll` 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 {
// EOF means this pipe closed, not that the child exited: only
// `poll` (via `try_wait`) may mark the session finished, or the
// next state render would drop a login that is still running.
Ok(0) => 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");
state.lock().unwrap().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::{State, extract_url, pump};
use std::sync::{Arc, Mutex};
use tokio::io::BufReader;
#[tokio::test]
async fn a_closed_output_pipe_does_not_finish_the_session() {
let state = Arc::new(Mutex::new(State::default()));
pump(BufReader::new(tokio::io::empty()), state.clone(), "stdout").await;
assert!(!state.lock().unwrap().finished);
}
#[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);
}
}

View file

@ -20,7 +20,6 @@ mod events;
mod harness_state;
mod identity;
mod login;
mod login_session;
mod mcp_config;
mod otel_turn_metrics;
mod paths;
@ -1141,7 +1140,7 @@ async fn handle_turn_error_recovery<S: Surface>(
if matches!(outcome, Err(turn::TurnError::AuthFailed)) {
bus.emit_status("needs_login_idle");
bus.emit(LiveEvent::Note {
text: "API 401 — waiting for re-login via web UI".into(),
text: "API 401 — waiting for a re-login (log in from the swarm UI)".into(),
});
tracing::warn!("auth-failed; parking until re-login");
S::requeue_inflight(socket).await;

View file

@ -187,7 +187,7 @@ pub enum TurnError {
/// The Anthropic API rejected the request with 401 (OAuth session
/// expired or revoked). The serve loop should flip the container
/// into `needs_login_idle` and stop driving turns until the
/// operator re-auths via the per-agent web UI.
/// operator logs in again from the swarm UI.
AuthFailed,
/// `--resume <title>` missed AND the lib's create self-heal also failed to
/// resolve the session — "shouldn't happen" (a resume-miss is normally

View file

@ -1,83 +1,20 @@
//! Login / logout flow handlers (`/login/*`, `/api/logout`).
use std::sync::Arc;
//! Logout handler (`/api/logout`). The agent has no login flow of its own:
//! credentials come from the swarm's secret store (`claude_credentials`).
use axum::{
Form,
extract::State,
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::Deserialize;
use crate::login::LoginState;
use crate::login_session::{LoginSession, drop_if_finished};
use super::{AppState, error_response};
pub(super) async fn post_login_start(State(state): State<AppState>) -> Response {
drop_if_finished(&state.session);
{
let guard = state.session.lock().unwrap();
if guard.is_some() {
return (axum::http::StatusCode::OK, "ok").into_response();
}
}
match LoginSession::start() {
Ok(session) => {
*state.session.lock().unwrap() = Some(Arc::new(session));
// Flip status from needs_login_idle → needs_login_in_progress
// so the web UI's badge + polling kick in (polling is still
// the right tool for the streaming session output during
// the login flow itself; events drop the poll for
// *everything else*).
state.bus.emit_status("needs_login_in_progress");
(axum::http::StatusCode::OK, "ok").into_response()
}
Err(e) => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("login start failed: {e:#}"),
),
}
}
#[derive(Deserialize)]
pub(super) struct CodeForm {
code: String,
}
pub(super) async fn post_login_code(
State(state): State<AppState>,
Form(form): Form<CodeForm>,
) -> Response {
let session = state.session.lock().unwrap().clone();
let Some(session) = session else {
return error_response(StatusCode::CONFLICT, "no login session running");
};
if let Err(e) = session.submit_code(&form.code).await {
return error_response(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("submit code failed: {e:#}"),
);
}
(axum::http::StatusCode::OK, "ok").into_response()
}
pub(super) async fn post_login_cancel(State(state): State<AppState>) -> Response {
let session = state.session.lock().unwrap().take();
if let Some(session) = session {
session.close_stdin().await;
session.kill();
}
// Back to needs_login_idle (LoginState unchanged, session gone).
state.bus.emit_status("needs_login_idle");
(axum::http::StatusCode::OK, "ok").into_response()
}
use super::AppState;
/// Operator-driven `/logout`: SIGINT claude, delete the credential
/// files (via [`crate::login::clear_session`]), flip `LoginState::NeedsLogin`.
/// The turn loop's next iteration parks into `wait_for_login` which
/// resumes when a fresh credentials file appears via `/login/code`.
/// resumes when a fresh credentials file appears (the swarm login stores
/// one and `claude_credentials` installs it).
/// Always returns 200 with a body describing what happened. See
/// `docs/2-handbook/web-ui/agent.md::Per-agent endpoints`
/// (the `/api/logout` bullet) for the three-step rationale +

View file

@ -1,7 +1,7 @@
//! Per-container HTTP UI. SPA shape: `GET /` returns a static shell;
//! `GET /static/*` serves CSS + JS; `GET /api/state` returns the page
//! state as JSON; the JS app renders. Live events stream on
//! `/events/stream`. Action POSTs (`/login/*`) return either a
//! `/events/stream`. Action POSTs return either a
//! 303 Redirect (for browsers that submit the form normally) or just
//! 200 OK — the JS app re-fetches `/api/state` afterwards.
//!
@ -35,7 +35,6 @@ use tower_http::services::ServeDir;
use crate::events::Bus;
use crate::login::LoginState;
use crate::login_session::LoginSession;
/// Deadline for broker-backed fetches on web-UI request paths. The
/// page's critical fields (status, turn state, usage) are all
@ -54,7 +53,6 @@ pub type LoginStateCell = Arc<Mutex<LoginState>>;
struct AppState {
label: String,
login: LoginStateCell,
session: Arc<Mutex<Option<Arc<LoginSession>>>>,
bus: Bus,
socket: PathBuf,
/// VNC port from the `HIVE_GUI_VNC_PORT` env var at startup.
@ -76,9 +74,6 @@ fn routes() -> Router<AppState> {
.route("/api/dashboard-state", get(state::api_dashboard_state))
.route("/events/stream", get(stream::events_stream))
.route("/events/history", get(stream::events_history))
.route("/login/start", post(auth::post_login_start))
.route("/login/code", post(auth::post_login_code))
.route("/login/cancel", post(auth::post_login_cancel))
.route("/api/cancel", post(actions::post_cancel_turn))
.route("/api/compact", post(actions::post_compact))
.route("/api/new-session", post(actions::post_new_session))
@ -127,7 +122,6 @@ pub async fn serve(
let state = AppState {
label,
login,
session: Arc::new(Mutex::new(None)),
bus,
socket,
gui_vnc_port,
@ -541,7 +535,6 @@ mod tests {
let state = AppState {
label: "test".to_owned(),
login: Arc::new(Mutex::new(LoginState::Online)),
session: Arc::new(Mutex::new(None)),
bus: Bus::new(),
socket: crate::paths::harness_dir().join("mcp.sock"),
gui_vnc_port: None,

View file

@ -5,7 +5,6 @@ use serde::Serialize;
use swarm_wire::agent_inference::Source;
use crate::login::LoginState;
use crate::login_session::drop_if_finished;
use super::AppState;
@ -13,22 +12,11 @@ pub(super) async fn api_state(State(state): State<AppState>) -> axum::Json<State
// Capture seq *before* any reads so the dedupe contract is
// "events with seq > snapshot.seq are post-snapshot, never missed."
let seq = state.bus.current_seq();
drop_if_finished(&state.session);
let login = *state.login.lock().unwrap();
let session_snapshot = state.session.lock().unwrap().clone();
let (status, session_view) = match (login, session_snapshot) {
(LoginState::Online, _) if state.bus.is_rate_limited() => ("rate_limited", None),
(LoginState::Online, _) => ("online", None),
(LoginState::NeedsLogin, None) => ("needs_login_idle", None),
(LoginState::NeedsLogin, Some(s)) => (
"needs_login_in_progress",
Some(SessionView {
url: s.url(),
output: s.output(),
finished: s.finished(),
exit_note: s.exit_note(),
}),
),
let status = match login {
LoginState::Online if state.bus.is_rate_limited() => "rate_limited",
LoginState::Online => "online",
LoginState::NeedsLogin => "needs_login_idle",
};
let dashboard_port = std::env::var("HIVE_DASHBOARD_PORT")
.ok()
@ -54,7 +42,6 @@ pub(super) async fn api_state(State(state): State<AppState>) -> axum::Json<State
qualified_label: crate::identity::qualify(&state.label),
dashboard_port,
status,
session: session_view,
inbox,
turn_state,
turn_state_since,
@ -124,10 +111,8 @@ pub(super) struct StateSnapshot {
/// when it equals `label`, the page renders the short form unchanged.
qualified_label: String,
dashboard_port: u16,
/// `"online"` | `"rate_limited"` | `"needs_login_idle"` | `"needs_login_in_progress"`.
/// `"online"` | `"rate_limited"` | `"needs_login_idle"`.
status: &'static str,
/// Present when `status == "needs_login_in_progress"`.
session: Option<SessionView>,
/// Last N messages addressed to this agent, newest-first. Pulled
/// from the broker via the per-agent socket on each render.
/// Empty on transport failure.
@ -202,16 +187,6 @@ pub(super) struct StateSnapshot {
paused: bool,
}
#[derive(Serialize)]
struct SessionView {
/// First `https://…` claude emitted on stdout, if any.
url: Option<String>,
/// Accumulated stdout + stderr.
output: String,
finished: bool,
exit_note: Option<String>,
}
/// One navigation link in the agent page header row. The same JSON
/// shape appears in both `StateSnapshot.links` (`GET /api/state`,
/// per-agent page) and `DashboardState.links` (`GET /api/dashboard-state`,