phase 8 step 4: web-ui login endpoint (pipes, no pty)

This commit is contained in:
müde 2026-05-15 13:07:16 +02:00
parent 78fae44ee5
commit dff93b603d
4 changed files with 437 additions and 21 deletions

View file

@ -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<Mutex<LoginState>>;
struct AppState {
label: String,
login: LoginStateCell,
session: Arc<Mutex<Option<Arc<LoginSession>>>>,
}
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<AppState>) -> Html<String> {
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",
"<p class=\"meta\">phase 6a placeholder — turn-loop status / inbox / xterm.js coming in 6b+</p>",
),
LoginState::NeedsLogin => (
"▓█▓▒░ NEEDS L0G1N ▓█▓▒░",
"status-needs-login",
"<p>No Claude session in <code>~/.claude/</code>. The harness is up and reachable on this UI, but the turn loop is paused until you log in.</p>\n<p class=\"meta\">Phase 8 step 4 will wire a login form here that drives <code>claude /login</code> over plain stdio pipes. Until then: <code>nixos-container root-login</code> the container and run <code>claude</code> interactively, then restart the harness.</p>",
),
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!(
"<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta http-equiv=\"refresh\" content=\"5\">\n<title>{label} // hyperhive</title>\n{STYLE}\n</head>\n<body>\n<pre class=\"banner\">░▒▓█▓▒░ {label} ░▒▓█▓▒░ hyperhive ag3nt ░▒▓█▓▒░</pre>\n<h2>◆ {label} ◆</h2>\n<div class=\"divider\">══════════════════════════════════════════════════════════════</div>\n<p class=\"{status_class}\">{status_label}</p>\n{body_extra}\n</body>\n</html>\n",
"<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta http-equiv=\"refresh\" content=\"3\">\n<title>{label} // hyperhive</title>\n{STYLE}\n</head>\n<body>\n<pre class=\"banner\">░▒▓█▓▒░ {label} ░▒▓█▓▒░ hyperhive ag3nt ░▒▓█▓▒░</pre>\n<h2>◆ {label} ◆</h2>\n<div class=\"divider\">══════════════════════════════════════════════════════════════</div>\n{body}\n</body>\n</html>\n",
label = state.label,
))
}
fn render_online() -> String {
"<p class=\"status-online\">▓█▓▒░ harness alive — turn loop running ▓█▓▒░</p>\n<p class=\"meta\">phase 6a placeholder — turn-loop status / inbox / xterm.js coming in 6b+</p>".into()
}
fn render_needs_login_idle() -> String {
"<p class=\"status-needs-login\">▓█▓▒░ NEEDS L0G1N ▓█▓▒░</p>\n<p>No Claude session in <code>~/.claude/</code>. The harness is up but the turn loop is paused until you log in.</p>\n<form method=\"POST\" action=\"/login/start\">\n <button type=\"submit\" class=\"btn btn-login\">◆ ST4RT L0G1N</button>\n</form>\n<p class=\"meta\">Spawns <code>claude /login</code> over plain stdio pipes. The OAuth URL will appear here when claude emits it; paste the resulting code back into the form below.</p>".into()
}
fn render_login_in_progress(session: &Arc<LoginSession>) -> String {
let url_block = match session.url() {
Some(url) => format!(
"<p>▶ <a href=\"{url}\" target=\"_blank\" rel=\"noreferrer\">{url}</a></p>\n<p class=\"meta\">open this URL in a browser, complete the OAuth flow, paste the resulting code below.</p>",
url = html_escape(&url),
),
None => "<p class=\"meta\">waiting for claude to emit an OAuth URL on stdout… (output below)</p>".into(),
};
let exit_badge = if session.finished() {
let note = session.exit_note().unwrap_or_else(|| "exited".into());
format!(
"<p class=\"status-needs-login\">claude process exited: {note}. Start over if needed.</p>",
note = html_escape(&note),
)
} else {
String::new()
};
let output = session.output();
let code_form = if session.finished() {
String::new()
} else {
"<form method=\"POST\" action=\"/login/code\" class=\"loginform\">\n <input name=\"code\" placeholder=\"paste OAuth code here\" required autocomplete=\"off\">\n <button type=\"submit\" class=\"btn btn-login\">◆ S3ND C0DE</button>\n</form>".into()
};
let cancel_form = "<form method=\"POST\" action=\"/login/cancel\" style=\"margin-top: 0.4em;\">\n <button type=\"submit\" class=\"btn btn-cancel\">cancel + kill</button>\n</form>".to_owned();
format!(
"<p class=\"status-needs-login\">▓█▓▒░ L0G1N 1N PR0GRESS ▓█▓▒░</p>\n{url_block}\n{code_form}\n{cancel_form}\n{exit_badge}\n<h3>output</h3>\n<pre class=\"diff\">{output}</pre>",
output = html_escape(&output),
)
}
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 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<AppState>,
Form(form): Form<CodeForm>,
) -> 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<AppState>) -> 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!(
"<!doctype html>\n<html><head>{STYLE}</head><body><h2>error</h2><pre class=\"diff\">{msg}</pre><p><a href=\"/\">← back</a></p></body></html>",
msg = html_escape(message),
)),
)
.into_response()
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
const STYLE: &str = r#"
<style>
:root {
@ -62,6 +175,8 @@ const STYLE: &str = r#"
--muted: #6c5c8c;
--purple: #cc66ff;
--purple-dim: #4a1a6a;
--amber: #ffb84d;
--green: #66ff99;
}
body {
background: var(--bg);
@ -80,7 +195,7 @@ const STYLE: &str = r#"
text-shadow: 0 0 6px rgba(204, 102, 255, 0.5);
overflow-x: auto;
}
h2 {
h2, h3 {
color: var(--purple);
text-transform: uppercase;
letter-spacing: 0.15em;
@ -93,8 +208,41 @@ const STYLE: &str = r#"
margin-bottom: 0.5em;
}
.meta { color: var(--muted); font-size: 0.85em; }
.status-online { color: #66ff99; text-shadow: 0 0 6px rgba(102, 255, 153, 0.5); }
.status-needs-login { color: #ffb84d; text-shadow: 0 0 6px rgba(255, 184, 77, 0.6); }
.status-online { color: var(--green); text-shadow: 0 0 6px rgba(102, 255, 153, 0.5); }
.status-needs-login { color: var(--amber); text-shadow: 0 0 6px rgba(255, 184, 77, 0.6); }
code { background: rgba(204, 102, 255, 0.1); padding: 0.05em 0.3em; border-radius: 2px; }
a { color: #66e0ff; }
.btn {
font-family: inherit;
font-size: 1em;
background: var(--bg);
border: 1px solid var(--purple);
color: var(--purple);
padding: 0.25em 0.8em;
cursor: pointer;
letter-spacing: 0.1em;
}
.btn:hover { background: rgba(204, 102, 255, 0.1); }
.btn-login { color: var(--amber); border-color: var(--amber); }
.btn-cancel { color: #ff6b6b; border-color: #ff6b6b; font-size: 0.85em; padding: 0.15em 0.6em; }
.loginform { display: flex; gap: 0.6em; margin-top: 0.5em; }
.loginform input {
font-family: inherit; font-size: 1em;
background: rgba(255, 255, 255, 0.04);
color: var(--fg);
border: 1px solid var(--purple-dim);
padding: 0.4em 0.6em;
flex: 1;
}
.loginform input:focus { outline: 1px solid var(--purple); }
pre.diff {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--purple-dim);
padding: 0.6em 0.8em;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
max-height: 30em;
}
</style>
"#;