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
3 changed files with 46 additions and 844 deletions

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`,