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

View file

@ -247,13 +247,12 @@ use semantic status codes: **400** for missing/malformed input
failures. The matching
mutations fire `LiveEvent` variants on the per-agent bus, so the
client doesn't refetch `/api/state` on submit — the SSE stream
delivers the new state faster anyway. Only the login flow still
polls (session output streams in updates that aren't event-
shaped).
delivers the new state faster anyway.
The agent has no login flow of its own. An agent without credentials
reports `needs_login_idle`, the operator logs in from the swarm UI, and the
credentials arrive from the swarm secret store.
- `POST /login/{start,code,cancel}` — claude OAuth login flow.
Start/cancel emit `LiveEvent::StatusChanged` to flip the
badge to/from `needs_login_in_progress`.
- `POST /api/cancel` — SIGINT the in-flight claude turn and the
commands it started. Emits a `LiveEvent::Note`.
- `POST /api/compact` — run `/compact` on the persistent session
@ -282,8 +281,8 @@ shaped).
`needs_login_idle`. The turn-loop's next iteration parks
into `wait_for_login`, which snapshots the credential dir
(now missing the wiped files) and resumes when a fresh
credentials file appears via the dashboard's `/login/code`
flow (the same mtime-resumption path manual re-login uses).
credentials file appears (a login from the swarm UI stores one,
and the agent installs it).
Always returns 200 with a body describing what happened — it
folds per-file errors into the response + the Note so the
@ -291,8 +290,7 @@ shaped).
error. Missing files (already logged out) count as
idempotent.
- `GET /api/state` — cold-load snapshot (`StateSnapshot`) consumed by
the frontend (`useAgentState.ts`) on page load and, on a poll, while
`status === 'needs_login_in_progress'`. Includes `turn_state`,
the frontend (`useAgentState.ts`) on page load. Includes `turn_state`,
`context_window_tokens`, `qualified_label`,
`model`, `model_source`, `effort`, `effort_source`, `links`, and
other fields described inline throughout this document. All
@ -364,7 +362,7 @@ shaped).
Bus events (new vocabulary on `/events/stream`):
- `status_changed { status }` — `online` / `rate_limited` /
`needs_login_idle` / `needs_login_in_progress`. Drives the
`needs_login_idle`. Drives the
alive-badge. The harness sets `rate_limited` when it detects a
429 response and clears it when the retry sleep expires.
- `model_changed { model }` — drives the model chip. The harness emits it

View file

@ -295,7 +295,7 @@ at without rebuilding the dist. The cases that matter:
The gateway upstream config strips the prefix before forwarding to
the per-agent server, so the agent's Rust routes (`api/state`,
`events/stream`, `screen/ws`, `login/start`, …) keep their absolute
`events/stream`, `screen/ws`, …) keep their absolute
paths server-side. Only the browser-facing URLs key off the
mount prefix.

View file

@ -1,9 +1,7 @@
// <Root> — root of the Preact rewrite. Wires `Header` + `StatusChips`
// to the real `/api/state` snapshot via `useAgentState`. Covers
// everything `app.js` did, including the `needs_login_idle`/
// `needs_login_in_progress` recovery flow (`LoginFlow`) — an agent with
// no credentials has no other web-UI path back online, so this isn't
// optional polish.
// everything `app.js` did. An agent without credentials shows the
// `needs_login_idle` badge; it logs in from the swarm UI, not here.
import { useEffect, useRef, useState } from "preact/hooks";
import type { BadgeTone } from "@hive/shared/badge.js";
import { SettingsMenu } from "@hive/shared/settings-menu.js";
@ -20,7 +18,6 @@ import { HeaderPill } from "./components/HeaderPill.js";
import { SidePanel } from "./components/SidePanel.js";
import { InboxPanel } from "./components/InboxPanel.js";
import { TodosPanel } from "./components/TodosPanel.js";
import { LoginFlow } from "./components/LoginFlow.js";
import { useAgentState } from "./hooks/useAgentState.js";
import { useTodos } from "./hooks/useTodos.js";
import { fmtAge, fmtTokens } from "./lib/format.js";
@ -49,7 +46,6 @@ const ALIVE_LABELS: Record<
online: { glyph: "●", text: "alive", tone: "positive" },
rate_limited: { glyph: "⊘", text: "rate limited", tone: "warning" },
needs_login_idle: { glyph: "◌", text: "needs login", tone: "warning" },
needs_login_in_progress: { glyph: "◌", text: "logging in", tone: "warning" },
};
const STATE_LABELS: Record<
string,
@ -336,14 +332,6 @@ export function Root() {
</Header>
)}
<main className="agent-main">
{state.status === "needs_login_idle" ||
state.status === "needs_login_in_progress" ? (
<LoginFlow
status={state.status}
session={state.session}
onRefresh={refresh}
/>
) : null}
<LiveStream ref={liveStreamRef} />
</main>
{panel}

View file

@ -233,35 +233,6 @@ h3 {
overflow: hidden;
}
/* Login flow overlay: only rendered when status != online. Sits
centred over the (likely-empty) terminal area; doesn't take chrome
space in the normal online flow. `--agent-header-real-h` (set by
the new Preact page's `Header.tsx` via ResizeObserver, falling back
to the static `--agent-header-h` guess before the first
measurement / on the old app.js page, which never sets it) is the
header's ACTUAL rendered height, not an assumed one — see
Header.tsx's file comment for why this matters (a static guess is
wrong the moment the header wraps an extra line on some viewport). */
.agent-status-overlay {
position: absolute;
top: calc(var(--agent-header-real-h, var(--agent-header-h)) + 1.5em);
left: 50%;
transform: translateX(-50%);
max-width: 44em;
width: calc(100% - 3em);
z-index: 10;
}
.agent-status-overlay:empty {
display: none;
}
.agent-status-overlay > * {
background: var(--bg-elev);
border: 1px solid var(--purple-dim);
border-radius: 6px;
padding: 1em 1.2em;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4);
}
.meta {
color: var(--muted);
font-size: 0.85em;
@ -308,16 +279,6 @@ a:hover {
text-shadow: 0 0 10px currentColor;
box-shadow: 0 0 10px -2px currentColor;
}
.btn-login {
color: var(--amber);
border-color: var(--amber);
}
.btn-cancel {
color: var(--red);
border-color: var(--red);
font-size: 0.85em;
padding: 0.15em 0.6em;
}
/* Orphaned rules — left here as a tombstone so a search for the
class name finds them. Live consumers gone:
- `.btn-rebuild` was a per-agent header chip, then briefly an
@ -343,57 +304,6 @@ a:hover {
.sendform input:focus {
outline: 1px solid var(--purple);
}
.loginform {
display: flex;
gap: 0.6em;
margin-top: 0.5em;
align-items: stretch;
}
.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);
}
/* Show / hide toggle for the masked OAuth-code input. Quiet by
default (muted border + transparent bg), lights amber on hover
/ when pressed (`aria-pressed="true"`) so the operator sees at
a glance whether the code is currently visible. */
.loginform-reveal {
font-family: inherit;
font-size: 1em;
background: transparent;
color: var(--muted);
border: 1px solid var(--purple-dim);
border-radius: 3px;
padding: 0 0.6em;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition:
color 0.15s ease,
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.loginform-reveal:hover,
.loginform-reveal:focus-visible {
color: var(--amber);
border-color: var(--amber);
outline: none;
}
.loginform-reveal[aria-pressed="true"] {
color: var(--amber);
border-color: var(--amber);
box-shadow: 0 0 8px -2px var(--amber);
}
pre.diff {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--purple-dim);

View file

@ -1,36 +0,0 @@
/* .login-card — one elevated-surface card for the whole login-recovery
overlay, replacing the old markup's per-`<p>`/`<form>` boxing
(`agent.css`'s `.agent-status-overlay > *`, still needed there
verbatim since app.js's still-live markup depends on it until the
index.html cutover — this file coexists with it rather than editing
it). Same visual language as `Dropdown`'s `.ui-dropdown`
(`@hive/shared/dropdown/Dropdown.css`) — background/border/radius/
shadow values match exactly, so this reads as the same "popup" family
as every other floating surface in the app, just laid out as a static
block instead of an anchored, position:absolute menu (that part of
`Dropdown` is specific to hanging under a trigger and doesn't apply
here — `.agent-status-overlay` already centres this card itself). */
.login-card {
display: flex;
flex-direction: column;
gap: 0.6em;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: 0.5em;
box-shadow: 0 0.4em 1em rgba(0, 0, 0, 0.35);
padding: 1em 1.2em;
}
.login-card > * {
margin: 0;
}
/* claude's real OAuth URL is one long unbroken token (a query string
with no whitespace) — without this it doesn't wrap at the card's
bounded width (.agent-status-overlay caps at 44em) and instead
overflows straight past the card's right edge (mara reported this
with a screenshot). `overflow-wrap: anywhere` lets the
browser break mid-token as a last resort, same as any other
long-URL-in-a-narrow-column case — `word-break: break-all` is the
older alternative but breaks more eagerly than needed here. */
.login-url {
overflow-wrap: anywhere;
}

View file

@ -1,186 +0,0 @@
// <LoginFlow> — the `needs_login_idle` / `needs_login_in_progress`
// overlay. Ports app.js's `renderNeedsLoginIdle`/`renderLoginInProgress`
// as real functional UI, not just a status label: an agent whose
// credentials got rotated (or hit `/logout`) has NO other way back
// online, so this is load-bearing, not cosmetic. Reuses agent.css's
// `.status-needs-login`/`.btn-login`/`.loginform`/`.loginform-reveal`
// rules verbatim (same class names as the old markup).
//
// mara (screenshot review): "reuse the popup component for the login,
// the floating separate panels should be one card." The old markup's
// `.agent-status-overlay > *` rule boxed every direct-child `<p>`/
// `<form>` separately (a fine translation of `el().append()`-per-line,
// but not what a hand-authored component should do) — this renders one
// `.login-card` instead, styled with the same "elevated surface" look
// `Dropdown`'s `.ui-dropdown` already established (`@hive/shared/
// dropdown/Dropdown.css`), not `Dropdown` itself (its `options.map`
// render is menu-item-shaped, not a generic content container — not a
// fit for a login form + output pane).
import { useState } from "preact/hooks";
import type { SessionView } from "../types.js";
import {
postLoginStart,
postLoginCode,
postLoginCancel,
} from "../lib/loginAction.js";
import "./LoginFlow.css";
function LoginIdle({ onStarted }: { onStarted: () => void }) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
function submit(e: Event) {
e.preventDefault();
setBusy(true);
setError(null);
postLoginStart().then((r) => {
setBusy(false);
if (r.ok) onStarted();
else setError(r.detail ?? "failed");
});
}
return (
<div class="login-card">
<p class="status-needs-login">◌ NEEDS L0G1N</p>
<p>
No Claude session in <code>~/.claude/</code>. The harness is up but the
turn loop is paused until you log in.
</p>
<form onSubmit={submit}>
<button type="submit" class="btn btn-login" disabled={busy}>
◆ ST4RT L0G1N
</button>
</form>
{error ? <p class="meta">error: {error}</p> : null}
<p class="meta">
Spawns <code>claude auth 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>
</div>
);
}
function LoginInProgress({
session,
onDone,
}: {
session: SessionView | null;
onDone: () => void;
}) {
const [code, setCode] = useState("");
const [reveal, setReveal] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const finished = session?.finished ?? false;
function submitCode(e: Event) {
e.preventDefault();
if (!code) return;
setBusy(true);
setError(null);
// Success: the next `/api/state` poll flips `status` back to
// `online` (or leaves it `needs_login_in_progress` on a bad code,
// with the process's own stderr showing up in `output` below) —
// nothing local to update here either way.
postLoginCode(code).then((r) => {
setBusy(false);
if (!r.ok) setError(r.detail ?? "failed");
});
}
function cancel(e: Event) {
e.preventDefault();
setBusy(true);
postLoginCancel().then(() => {
setBusy(false);
onDone();
});
}
return (
<div class="login-card">
<p class="status-needs-login">◌ L0G1N 1N PR0GRESS</p>
{session?.url ? (
<>
<p class="login-url">
▶{" "}
<a href={session.url} target="_blank" rel="noreferrer">
{session.url}
</a>
</p>
<p class="meta">
open this URL in a browser, complete the OAuth flow, paste the
resulting code below.
</p>
</>
) : (
<p class="meta">
waiting for claude to emit an OAuth URL on stdout… (output below)
</p>
)}
{!finished ? (
<form class="loginform" onSubmit={submitCode}>
<input
name="code"
type={reveal ? "text" : "password"}
placeholder="paste OAuth code here (hidden)"
required
autocomplete="one-time-code"
spellcheck={false}
value={code}
onInput={(e) => setCode((e.target as HTMLInputElement).value)}
/>
<button
type="button"
class="loginform-reveal"
title="show / hide pasted code"
aria-label="show / hide pasted OAuth code"
aria-pressed={reveal}
onClick={() => setReveal((r) => !r)}
>
👁
</button>
<button type="submit" class="btn btn-login" disabled={busy}>
◆ S3ND C0DE
</button>
</form>
) : null}
<form style={{ marginTop: "0.4em" }} onSubmit={cancel}>
<button type="submit" class="btn btn-cancel" disabled={busy}>
cancel + kill
</button>
</form>
{finished ? (
<p class="status-needs-login">
claude process exited: {session?.exit_note || "exited"}. Start over if
needed.
</p>
) : null}
{error ? <p class="meta">error: {error}</p> : null}
<h3>output</h3>
<pre class="diff">{session?.output || ""}</pre>
</div>
);
}
export function LoginFlow({
status,
session,
onRefresh,
}: {
status: "needs_login_idle" | "needs_login_in_progress";
session: SessionView | null;
onRefresh: () => void;
}) {
return (
<div class="agent-status-overlay">
{status === "needs_login_idle" ? (
<LoginIdle onStarted={onRefresh} />
) : (
<LoginInProgress session={session} onDone={onRefresh} />
)}
</div>
);
}

View file

@ -1,7 +1,6 @@
/* <MetaNav> popover — matches `@hive/shared`'s `Dropdown` (`.ui-dropdown`)
values exactly (background/border/radius/shadow, item padding/hover),
same "reuse the values, not the component" call `.login-card`
(LoginFlow.css) already made, for the same reason: this popover's
a "reuse the values, not the component" call: this popover's
items are real `<a>` tags, not `Dropdown`'s command-dispatch
`<button>`s. Right-anchored — MetaNav sits in the header's right-hand
pills cluster, so a left anchor would push it off-screen. */

View file

@ -19,8 +19,7 @@
// always `<button>`s for command dispatch, which would lose real link
// semantics like ctrl/middle-click and "copy link address"), but
// matching its `.ui-dropdown` visual values exactly (see MetaNav.css) —
// the same "reuse the values, not the component" call `LoginFlow`'s
// `.login-card` makes for the same reason.
// a "reuse the values, not the component" call.
import { useEffect, useRef, useState } from "preact/hooks";
import { Badge } from "@hive/shared/badge.js";
import { LinkIcon } from "@hive/shared/icons.js";

View file

@ -8,12 +8,12 @@
<link rel="stylesheet" href="static/colors.css" />
<link rel="stylesheet" href="static/theme.css" />
<link rel="stylesheet" href="static/agent.css" />
<!-- main.css (bundled component CSS: Badge/Dropdown/LoginFlow/MetaNav/
<!-- main.css (bundled component CSS: Badge/Dropdown/MetaNav/
StatusChips/SidePanel/...) MUST load after agent.css —
`.login-card`/`.meta-nav-popover`/etc. share class-selector
`.meta-nav-popover`/etc. share class-selector
specificity with agent.css's legacy rules, so load order is what
decides which one wins the cascade (see LoginFlow.css/MetaNav.css
file comments). -->
decides which one wins the cascade (see the MetaNav.css
file comment). -->
<link rel="stylesheet" href="static/main.css" />
</head>
<body class="agent-shell">

View file

@ -1,43 +0,0 @@
// POST `/login/start` / `/login/code` / `/login/cancel` — same-origin
// (this agent's own backend, `hive-agent/src/web_ui/auth.rs`), so a
// plain `fetch` is safe (unlike `pauseAction.ts`'s dashboard-origin
// actions). Same `{ ok, detail }` shape and `redirect: 'manual'` /
// broad-2xx-4xx-treated-as-ok contract as `termActions.ts` — none of
// these three routes actually redirect today, but the same tolerant
// check costs nothing and keeps every same-origin POST in this package
// behaving identically.
async function post(
path: string,
body?: URLSearchParams,
): Promise<{ ok: boolean; detail?: string }> {
try {
const resp = await fetch(path, {
method: "POST",
headers: body
? { "Content-Type": "application/x-www-form-urlencoded" }
: undefined,
body,
redirect: "manual",
});
const ok =
resp.ok ||
resp.type === "opaqueredirect" ||
(resp.status >= 200 && resp.status < 400);
if (ok) return { ok: true };
const detail = await resp.text().catch(() => "");
return {
ok: false,
detail: `http ${resp.status}${detail ? " — " + detail : ""}`,
};
} catch (err) {
return {
ok: false,
detail: err instanceof Error ? err.message : String(err),
};
}
}
export const postLoginStart = () => post("login/start");
export const postLoginCode = (code: string) =>
post("login/code", new URLSearchParams({ code }));
export const postLoginCancel = () => post("login/cancel");

View file

@ -12,12 +12,7 @@ export interface AgentState {
label: string;
qualified_label: string;
dashboard_port: number;
status:
| "online"
| "rate_limited"
| "needs_login_idle"
| "needs_login_in_progress";
session: SessionView | null;
status: "online" | "rate_limited" | "needs_login_idle";
turn_state: string;
turn_state_since: number;
model: string;
@ -48,16 +43,6 @@ export interface AgentLink {
kind: "container" | "forge" | "external";
}
// Mirrors `hive_agent::web_ui::state::SessionView` — populated only
// while `status === 'needs_login_in_progress'` (the in-flight
// `claude auth login` session's streamed output).
export interface SessionView {
url: string | null;
output: string;
finished: boolean;
exit_note: string | null;
}
export interface TokenUsage {
input_tokens?: number;
output_tokens?: number;

View file

@ -1,8 +1,8 @@
/* <SettingsMenu> popover — matches `@hive/shared`'s `Dropdown`
(`../dropdown/Dropdown.css`, `.ui-dropdown`) values exactly
(background/border/radius/shadow), same "reuse the values, not the
component" call `MetaNav`/`.login-card` already made for their own
popovers: this one holds `<select>` rows, not `Dropdown`'s
component" call `MetaNav` already made for its own
popover: this one holds `<select>` rows, not `Dropdown`'s
command-dispatch `<button>`s. Right-anchored — this trigger lives at
the end of a header's icon-trigger cluster in every page that mounts
it, so a left anchor would push it off-screen. */

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,

Some files were not shown because too many files have changed in this diff Show more