hive-agent: the agent has no login flow of its own, credentials come from the swarm
This commit is contained in:
parent
52d26dd067
commit
ac3b3cfcd3
21 changed files with 46 additions and 844 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 +
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue