hive-ag3nt + docs: extract web_ui prose (#716 batch 6)
docs/web-ui.md: - New `### Listener bind` subsection — uncapped TCP retry rationale (replaces stale "12 tries" claim that contradicted the code) + pointer to gateway.md for unix-socket transition - Expand `/api/logout` bullet with three-step teardown rationale (SIGINT race, selective cred-file wipe preserves --continue, wait_for_login resumption path) hive-ag3nt/src/web_ui.rs: - serve / bind_unix / bind_with_retry rustdocs trimmed to docs pointers (binding modes, .bound marker, retry budget all live in the prose now) - AgentLink / StateSnapshot.links / agent_links rustdocs trimmed to single-line summaries - post_logout / CRED_FILE_NAMES rustdocs reference docs/web-ui.md - 13 → 0 cookies
This commit is contained in:
parent
604146992a
commit
55716be8fc
2 changed files with 117 additions and 123 deletions
|
|
@ -217,10 +217,30 @@ previews are type-aware:
|
||||||
real content-type (text files stay UTF-8-lossy `text/plain`).
|
real content-type (text files stay UTF-8-lossy `text/plain`).
|
||||||
- **Everything else** — raw text in a `<pre>`.
|
- **Everything else** — raw text in a `<pre>`.
|
||||||
|
|
||||||
Both bind their listeners with `SO_REUSEADDR` via
|
### Listener bind
|
||||||
`tokio::net::TcpSocket` plus a retry loop on `AddrInUse` (12 tries,
|
|
||||||
exponential backoff capped at 2s) so an nspawn restart that races
|
Both bind their TCP listener with `SO_REUSEADDR` via
|
||||||
the previous process's socket release resolves itself.
|
`tokio::net::TcpSocket` plus a retry loop on `AddrInUse`
|
||||||
|
(exponential backoff capped at 2s, **no attempt cap**) so an nspawn
|
||||||
|
restart that races the previous process's socket release resolves
|
||||||
|
itself. The retry is uncapped on purpose: a capped budget once
|
||||||
|
left the harness silently UI-less for the rest of its lifetime
|
||||||
|
when a back-to-back restart held the port longer than the cap
|
||||||
|
allowed. Genuine port collisions are preflighted host-side
|
||||||
|
(`lifecycle::{spawn,rebuild}` refuses with a clear error,
|
||||||
|
surfaced on the dashboard as a banner), so at this layer a
|
||||||
|
persistent `AddrInUse` always reflects a recoverable stale
|
||||||
|
socket — retrying forever is the safe choice. The first 12
|
||||||
|
attempts log at WARN; after that the level drops to INFO so a
|
||||||
|
long-held stale socket doesn't flood the journal.
|
||||||
|
|
||||||
|
The per-agent UI optionally binds a `UnixListener` instead of
|
||||||
|
TCP when `HIVE_WEB_SOCKET` is set — the unix-socket transition
|
||||||
|
mechanics (per-agent `/run/hive-agent/<name>/` bind-mount,
|
||||||
|
`.bound` marker filtering, `agent-sockets.json` consumer on the
|
||||||
|
gateway side) live in [`docs/gateway.md::Per-agent unix-socket
|
||||||
|
upstream`](gateway.md). The env var is opt-in per agent so the
|
||||||
|
two modes coexist while sub-agents transition.
|
||||||
|
|
||||||
### Per-agent relative paths
|
### Per-agent relative paths
|
||||||
|
|
||||||
|
|
@ -1162,12 +1182,33 @@ shaped).
|
||||||
future turns. `Bus::set_model` emits `ModelChanged`.
|
future turns. `Bus::set_model` emits `ModelChanged`.
|
||||||
- `POST /api/new-session` — arm a one-shot for the next turn to
|
- `POST /api/new-session` — arm a one-shot for the next turn to
|
||||||
drop `--continue`. Emits a `LiveEvent::Note`.
|
drop `--continue`. Emits a `LiveEvent::Note`.
|
||||||
- `POST /api/logout` — SIGINT any in-flight turn, wipe OAuth
|
- `POST /api/logout` — three-step teardown that re-uses the
|
||||||
credential files (`.credentials.json` + `mcp-needs-auth-cache.json`
|
existing `wait_for_login` resumption path:
|
||||||
under `~/.claude/`), flip `LoginState::NeedsLogin`. Session
|
1. SIGINT any running claude (matches `/api/cancel`'s pattern —
|
||||||
history (`~/.claude/projects/`) is preserved. Returns 200 with a
|
idempotent no-op when nothing is running) so the credential
|
||||||
plain-text wipe summary. Emits `needs_login_idle` status via
|
wipe doesn't race a mid-API-call turn.
|
||||||
`wait_for_login` entry.
|
2. Delete only the OAuth credential files
|
||||||
|
(`.credentials.json` + `mcp-needs-auth-cache.json` under
|
||||||
|
`~/.claude/`). **Preserves** session history files
|
||||||
|
(`projects/<hash>/*.jsonl`), sessions, shell-snapshots,
|
||||||
|
plans, settings, telemetry, and the dir itself, so
|
||||||
|
`claude --continue` keeps working after a fresh login.
|
||||||
|
Wholesale `remove_dir_all` of `~/.claude/` was the previous
|
||||||
|
shape and broke session continuity; the narrowed allow-list
|
||||||
|
is the fix.
|
||||||
|
3. Flip `LoginState::NeedsLogin` + emit a `LiveEvent::Note`
|
||||||
|
describing exactly what was wiped, then emit
|
||||||
|
`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).
|
||||||
|
|
||||||
|
Always returns 200 with a body describing what happened —
|
||||||
|
per-file errors are folded into the response + the Note so the
|
||||||
|
operator sees them in the live panel rather than as an HTTP
|
||||||
|
error. Missing files (already logged out) are treated as
|
||||||
|
idempotent.
|
||||||
- `GET /events/history` — replay buffer for the terminal.
|
- `GET /events/history` — replay buffer for the terminal.
|
||||||
- `GET /screen` — VNC viewer page (minimal RFB-over-WebSocket
|
- `GET /screen` — VNC viewer page (minimal RFB-over-WebSocket
|
||||||
renderer — deliberately thin, just enough to display the
|
renderer — deliberately thin, just enough to display the
|
||||||
|
|
|
||||||
|
|
@ -76,21 +76,18 @@ impl AppState {
|
||||||
/// `post_compact`) the allowed-tools surface claude sees.
|
/// `post_compact`) the allowed-tools surface claude sees.
|
||||||
pub type Flavor = mcp::Flavor;
|
pub type Flavor = mcp::Flavor;
|
||||||
|
|
||||||
|
/// Bind the per-container web listener and serve the SPA.
|
||||||
|
///
|
||||||
|
/// `HIVE_WEB_SOCKET` opt-in selects unix-socket vs TCP binding; the
|
||||||
|
/// dual-mode transition + gateway-side consumer live in
|
||||||
|
/// [`docs/web-ui.md::Listener bind`](../../../docs/web-ui.md) and
|
||||||
|
/// [`docs/gateway.md::Per-agent unix-socket upstream`](../../../docs/gateway.md).
|
||||||
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// Returns an error if neither the TCP listener (default) nor the
|
/// Returns an error if neither the TCP listener (default) nor the
|
||||||
/// unix-socket bind (`HIVE_WEB_SOCKET`, if set) can be acquired, or
|
/// unix-socket bind (`HIVE_WEB_SOCKET`, if set) can be acquired, or
|
||||||
/// if `HIVE_STATIC_DIR` is missing.
|
/// if `HIVE_STATIC_DIR` is missing.
|
||||||
///
|
|
||||||
/// # Binding modes (#784 phase 1)
|
|
||||||
///
|
|
||||||
/// When `HIVE_WEB_SOCKET` is set + non-empty, bind a `UnixListener`
|
|
||||||
/// at that path so the gateway can `proxy_pass unix:…` instead of
|
|
||||||
/// reaching us over a TCP loopback that won't work post-#14 (private
|
|
||||||
/// netns). When the env var is unset, fall back to TCP bind on `port`
|
|
||||||
/// — the legacy path the gateway's `agent-ports.json` map drives. The
|
|
||||||
/// gateway can transition to socket upstreams independently of any
|
|
||||||
/// agent re-binding because the env var is opt-in per agent.
|
|
||||||
pub async fn serve(
|
pub async fn serve(
|
||||||
label: String,
|
label: String,
|
||||||
port: u16,
|
port: u16,
|
||||||
|
|
@ -150,11 +147,12 @@ pub async fn serve(
|
||||||
// hyperhive.frontend.mergedDist in nix).
|
// hyperhive.frontend.mergedDist in nix).
|
||||||
.fallback_service(ServeDir::new(&static_dir))
|
.fallback_service(ServeDir::new(&static_dir))
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
// `HIVE_WEB_SOCKET` opt-in (#784 phase 1): when set, bind a
|
// `HIVE_WEB_SOCKET` opt-in: when set + non-empty, bind a
|
||||||
// `UnixListener` at the given path. Empty string treated as
|
// `UnixListener` at the given path. Empty string treated as
|
||||||
// unset so a stray `HIVE_WEB_SOCKET=` doesn't trap us into an
|
// unset so a stray `HIVE_WEB_SOCKET=` doesn't trap us into an
|
||||||
// un-bindable empty path. Falls through to the TCP path below
|
// un-bindable empty path. Falls through to the TCP path below
|
||||||
// otherwise.
|
// otherwise. See docs/gateway.md::Per-agent unix-socket upstream
|
||||||
|
// for the gateway-side consumer.
|
||||||
if let Some(socket_path) = std::env::var_os("HIVE_WEB_SOCKET")
|
if let Some(socket_path) = std::env::var_os("HIVE_WEB_SOCKET")
|
||||||
&& !socket_path.is_empty()
|
&& !socket_path.is_empty()
|
||||||
{
|
{
|
||||||
|
|
@ -171,16 +169,17 @@ pub async fn serve(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bind a `UnixListener` at `path`. Best-effort unlinks any stale
|
/// Bind a `UnixListener` at `path` and drop a `.bound` marker next
|
||||||
/// socket left over from a previous (crashed) harness — clean exit
|
/// to it so c0re's gateway-map writer knows the socket is live.
|
||||||
/// removes it, but `bind(2)` refuses to overwrite an existing file.
|
/// Best-effort unlinks any stale socket left from a crashed previous
|
||||||
/// Also `mkdir -p` the parent so a freshly-created `/run/hive-agent/`
|
/// harness (clean exit removes it, but `bind(2)` refuses to overwrite
|
||||||
/// bind-mount target works on first boot.
|
/// an existing file) and `mkdir -p`s the parent for first-boot. Mode
|
||||||
|
/// `0o660` so a peer container bind-mounting the dir with a shared
|
||||||
|
/// group can `connect(2)`; the bind-mount source dir's ownership +
|
||||||
|
/// ACL is the real access gate.
|
||||||
///
|
///
|
||||||
/// Permissions: mode `0o660` so peers in the same unix group (the
|
/// Marker-gating + the gateway-side consumer: see
|
||||||
/// gateway container, when bind-mounting the socket dir with a
|
/// [`docs/gateway.md::Per-agent unix-socket upstream`](../../../docs/gateway.md).
|
||||||
/// shared group) can `connect(2)`. The bind-mount source dir's
|
|
||||||
/// ownership + ACL is the real access gate; this is defence-in-depth.
|
|
||||||
fn bind_unix(path: &Path) -> Result<tokio::net::UnixListener> {
|
fn bind_unix(path: &Path) -> Result<tokio::net::UnixListener> {
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
std::fs::create_dir_all(parent)
|
std::fs::create_dir_all(parent)
|
||||||
|
|
@ -195,15 +194,9 @@ fn bind_unix(path: &Path) -> Result<tokio::net::UnixListener> {
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o660))
|
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o660))
|
||||||
.with_context(|| format!("set perms on {}", path.display()))?;
|
.with_context(|| format!("set perms on {}", path.display()))?;
|
||||||
// Drop a `.bound` marker next to the socket so c0re's
|
// Best-effort .bound marker: failed write isn't fatal (the harness
|
||||||
// `agent_sockets::write` can filter the JSON map to only include
|
// still binds + serves), it just means the gateway side keeps the
|
||||||
// agents whose harness has actually opted in to (and bound) the
|
// TCP upstream for one more sync tick.
|
||||||
// unix socket. Without this gating, gateway would `proxy_pass`
|
|
||||||
// to a non-existent socket for any sub-agent that hasn't flipped
|
|
||||||
// `hyperhive.web.useUnixSocket = true` yet — atlas's concern on
|
|
||||||
// PR #813. Best-effort: a failed write isn't fatal (the harness
|
|
||||||
// still binds + serves on the socket), it just means the
|
|
||||||
// gateway side keeps using the TCP upstream for one more sync.
|
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
let marker = parent.join(".bound");
|
let marker = parent.join(".bound");
|
||||||
if let Err(e) = std::fs::write(&marker, b"") {
|
if let Err(e) = std::fs::write(&marker, b"") {
|
||||||
|
|
@ -222,23 +215,11 @@ fn bind_unix(path: &Path) -> Result<tokio::net::UnixListener> {
|
||||||
|
|
||||||
/// Bind a TCP listener with `SO_REUSEADDR` set, retrying on
|
/// Bind a TCP listener with `SO_REUSEADDR` set, retrying on
|
||||||
/// `AddrInUse` indefinitely with exponential backoff capped at 2s.
|
/// `AddrInUse` indefinitely with exponential backoff capped at 2s.
|
||||||
/// nspawn restarts can race the previous harness's socket release;
|
/// First 12 attempts log at WARN; subsequent attempts log at INFO so
|
||||||
/// `SO_REUSEADDR` lets us reclaim a port still in `TIME_WAIT` from a
|
/// a long-held stale socket doesn't flood the journal.
|
||||||
/// clean previous exit, and the retry covers the case where the
|
|
||||||
/// previous process is genuinely still alive (systemd restart-delay
|
|
||||||
/// overlap).
|
|
||||||
///
|
///
|
||||||
/// The retry has no attempt cap: capping was the proximate cause of
|
/// Uncapped retry + dashboard-banner-on-real-collision rationale:
|
||||||
/// issue #324 — two back-to-back restarts left the previous socket
|
/// see [`docs/web-ui.md::Listener bind`](../../../docs/web-ui.md).
|
||||||
/// holding the port for longer than the ~20s the old 12-attempt
|
|
||||||
/// budget allowed, and the harness silently lost its web UI for the
|
|
||||||
/// rest of the process lifetime. Genuine port collisions are
|
|
||||||
/// preflighted host-side (`lifecycle::{spawn,rebuild}`) and surfaced
|
|
||||||
/// on the dashboard as a banner, so at this layer a persistent
|
|
||||||
/// `AddrInUse` always reflects a recoverable stale socket — retrying
|
|
||||||
/// forever is the safe choice. The first attempts log at WARN; once
|
|
||||||
/// we cross attempt 12 the level drops to INFO so a long stale
|
|
||||||
/// socket doesn't flood the journal.
|
|
||||||
async fn bind_with_retry(addr: SocketAddr, label: &str) -> Result<tokio::net::TcpListener> {
|
async fn bind_with_retry(addr: SocketAddr, label: &str) -> Result<tokio::net::TcpListener> {
|
||||||
let mut delay_ms = 250u64;
|
let mut delay_ms = 250u64;
|
||||||
let mut attempts = 0u32;
|
let mut attempts = 0u32;
|
||||||
|
|
@ -293,12 +274,12 @@ fn try_bind(addr: SocketAddr) -> std::io::Result<tokio::net::TcpListener> {
|
||||||
/// Always returns an image, so consumers (dashboard, favicon) can hit
|
/// Always returns an image, so consumers (dashboard, favicon) can hit
|
||||||
/// `/icon` unconditionally without probing whether one is configured.
|
/// `/icon` unconditionally without probing whether one is configured.
|
||||||
async fn serve_icon() -> impl IntoResponse {
|
async fn serve_icon() -> impl IntoResponse {
|
||||||
// Per-agent icon overrides go through `/etc/hyperhive/icon.svg` (set
|
// Per-agent icon overrides go through `/etc/hyperhive/icon.svg`
|
||||||
// via the `hyperhive.icon` agent.nix option); the bundled default is
|
// (set via the `hyperhive.icon` agent.nix option); the bundled
|
||||||
// resolved at runtime from
|
// default is resolved at runtime from
|
||||||
// `$HIVE_ASSETS_DIR/branding/hyperhive.svg` (#555). If neither file
|
// `$HIVE_ASSETS_DIR/branding/hyperhive.svg`. If neither file can
|
||||||
// can be read we serve an empty body — keeps the response a valid
|
// be read we serve an empty body — keeps the response a valid SVG
|
||||||
// SVG content-type without a panic on a misconfigured container.
|
// content-type without a panic on a misconfigured container.
|
||||||
let body = std::fs::read_to_string("/etc/hyperhive/icon.svg").unwrap_or_else(|_| {
|
let body = std::fs::read_to_string("/etc/hyperhive/icon.svg").unwrap_or_else(|_| {
|
||||||
std::fs::read_to_string(hive_sh4re::assets::branding_svg()).unwrap_or_default()
|
std::fs::read_to_string(hive_sh4re::assets::branding_svg()).unwrap_or_default()
|
||||||
});
|
});
|
||||||
|
|
@ -413,7 +394,7 @@ struct StateSnapshot {
|
||||||
label: String,
|
label: String,
|
||||||
/// Hive-qualified long name (`${label}@${hyperhive.domain}`) when
|
/// Hive-qualified long name (`${label}@${hyperhive.domain}`) when
|
||||||
/// the host has been configured for a multi-hive swarm; falls back
|
/// the host has been configured for a multi-hive swarm; falls back
|
||||||
/// to the short label when the hive domain env var is unset (#589).
|
/// to the short label when the hive domain env var is unset.
|
||||||
/// The frontend uses this for the page title / agent self-introduction;
|
/// The frontend uses this for the page title / agent self-introduction;
|
||||||
/// when it equals `label`, the page renders the short form unchanged.
|
/// when it equals `label`, the page renders the short form unchanged.
|
||||||
qualified_label: String,
|
qualified_label: String,
|
||||||
|
|
@ -448,20 +429,12 @@ struct StateSnapshot {
|
||||||
/// Cumulative token usage across the most recent turn's inferences
|
/// Cumulative token usage across the most recent turn's inferences
|
||||||
/// (cost signal). `null` until the first turn finishes.
|
/// (cost signal). `null` until the first turn finishes.
|
||||||
cost_usage: Option<crate::events::TokenUsage>,
|
cost_usage: Option<crate::events::TokenUsage>,
|
||||||
/// Navigation links for this agent page (issue #262). Stats is
|
/// Navigation links for this agent page. The same list feeds the
|
||||||
/// always present; screen when the VNC compositor is enabled; the
|
/// dashboard card's icon-only nav strip via hive-c0re's
|
||||||
/// forge profile + the agent-configs mirror repo when the agent
|
/// `GET /api/agent/{name}/links` same-origin passthrough proxy
|
||||||
/// has a forge account; followed by any agent-declared
|
/// — single source of truth, no CORS. Per-agent page also reads
|
||||||
/// `hyperhive.dashboardLinks` extras (read from
|
/// this directly. See [`docs/web-ui.md::Container row`] for the
|
||||||
/// `{state_dir}/hyperhive-dashboard-links.json`). Each URL is
|
/// frontend resolver + which links appear in which conditions.
|
||||||
/// already absolute — built server-side from the request `Host`
|
|
||||||
/// header — so the frontend just renders.
|
|
||||||
///
|
|
||||||
/// This same list is the **source of truth** for the per-agent
|
|
||||||
/// page *and* the dashboard card's icon-only nav strip: hive-c0re
|
|
||||||
/// proxies it via `GET /api/agent/{name}/links` (same-origin from
|
|
||||||
/// the dashboard JS), avoiding CORS and centralising the link
|
|
||||||
/// definitions in the agent backend.
|
|
||||||
links: Vec<AgentLink>,
|
links: Vec<AgentLink>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -469,7 +442,7 @@ struct StateSnapshot {
|
||||||
/// shape feeds the dashboard's icon-only nav strip via the host's
|
/// shape feeds the dashboard's icon-only nav strip via the host's
|
||||||
/// `GET /api/agent/{name}/links` passthrough proxy, so the agent
|
/// `GET /api/agent/{name}/links` passthrough proxy, so the agent
|
||||||
/// backend is the single source of truth for what links an agent
|
/// backend is the single source of truth for what links an agent
|
||||||
/// exposes (issue #262).
|
/// exposes.
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct AgentLink {
|
struct AgentLink {
|
||||||
/// `kind = Container | Forge` → path; `kind = External` → full URL.
|
/// `kind = Container | Forge` → path; `kind = External` → full URL.
|
||||||
|
|
@ -607,26 +580,20 @@ async fn api_state(State(state): State<AppState>) -> axum::Json<StateSnapshot> {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the navigation link list for the agent page header
|
/// Build the navigation link list for the agent page header. URLs
|
||||||
/// (issue #262). Single source of truth: the dashboard's icon-only
|
/// are paths (relative) for `Container`/`Forge` targets and absolute
|
||||||
/// nav strip consumes the same list via the host's
|
/// for `External`; the frontend resolves each against its `kind`
|
||||||
/// `GET /api/agent/{name}/links` proxy. URLs are paths (relative)
|
/// against the right base so the backend never has to guess the
|
||||||
/// for Container/Forge targets and absolute for External; the
|
/// operator's browser host. See
|
||||||
/// frontend resolves each against its `kind` so the backend never
|
/// [`docs/web-ui.md::Container row`](../../../docs/web-ui.md) for
|
||||||
/// has to guess the operator's browser host.
|
/// the resolver + how `deployed:<sha>` ships alongside.
|
||||||
///
|
|
||||||
/// The agent harness doesn't know its own deployed sha (the meta
|
|
||||||
/// flake lock lives on the host), so the `config` link points at
|
|
||||||
/// the repo root; the dashboard renders a `deployed:<sha>` chip
|
|
||||||
/// alongside the strip so the operator still sees what's live.
|
|
||||||
fn agent_links(label: &str, gui_enabled: bool) -> Vec<AgentLink> {
|
fn agent_links(label: &str, gui_enabled: bool) -> Vec<AgentLink> {
|
||||||
let mut links = Vec::new();
|
let mut links = Vec::new();
|
||||||
|
|
||||||
// Note: the URLs are the actual HTML files served out of the
|
// URLs are the actual HTML files served out of the frontend dist
|
||||||
// frontend dist (`stats.html` / `screen.html`); after the #273
|
// (`stats.html` / `screen.html`); the harness serves them as
|
||||||
// backend/frontend split the harness serves these as static
|
// static files via ServeDir rather than via Rust routes, so the
|
||||||
// files via ServeDir rather than via Rust routes, so the URL
|
// URL has to be the on-disk filename.
|
||||||
// has to be the on-disk filename.
|
|
||||||
links.push(AgentLink {
|
links.push(AgentLink {
|
||||||
url: "/stats.html".to_owned(),
|
url: "/stats.html".to_owned(),
|
||||||
icon: "📊".to_owned(),
|
icon: "📊".to_owned(),
|
||||||
|
|
@ -969,36 +936,22 @@ async fn post_new_session(State(state): State<AppState>) -> Response {
|
||||||
(axum::http::StatusCode::OK, "ok").into_response()
|
(axum::http::StatusCode::OK, "ok").into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// OAuth credential filenames inside `paths::claude_dir()`. These are
|
/// OAuth credential filenames inside `paths::claude_dir()`. Wiping
|
||||||
/// the files `claude auth login` writes (the bearer token + an internal
|
/// only these (and not the rest of `~/.claude/`) preserves session
|
||||||
/// MCP auth cache); wiping them invalidates the session without
|
/// history so `claude --continue` keeps working after a fresh login.
|
||||||
/// touching the rest of `~/.claude/` — projects/ (jsonl session
|
/// Rationale + the previous wholesale-wipe shape we replaced live in
|
||||||
/// history), sessions/, shell-snapshots/, telemetry/, settings.json,
|
/// [`docs/web-ui.md::Per-agent endpoints`](../../../docs/web-ui.md)
|
||||||
/// etc. all survive so `--continue` keeps working after a re-login.
|
/// (the `/api/logout` bullet).
|
||||||
/// (#584 — fix for #582 which did a wholesale `remove_dir_all`.)
|
|
||||||
const CRED_FILE_NAMES: &[&str] = &[".credentials.json", "mcp-needs-auth-cache.json"];
|
const CRED_FILE_NAMES: &[&str] = &[".credentials.json", "mcp-needs-auth-cache.json"];
|
||||||
|
|
||||||
/// Operator-driven `/logout` (closes #576, narrowed scope per #584).
|
/// Operator-driven `/logout`: SIGINT claude, delete the credential
|
||||||
/// Three-step teardown:
|
/// files in `CRED_FILE_NAMES`, flip `LoginState::NeedsLogin`. The
|
||||||
///
|
/// turn loop's next iteration parks into `wait_for_login` which
|
||||||
/// 1. SIGINT any running claude process so we don't race a turn
|
/// resumes when a fresh credentials file appears via `/login/code`.
|
||||||
/// that's mid-API-call. Same pattern as `post_cancel_turn`;
|
/// Always returns 200 with a body describing what happened. See
|
||||||
/// idempotent (no-op when nothing is running).
|
/// [`docs/web-ui.md::Per-agent endpoints`](../../../docs/web-ui.md)
|
||||||
/// 2. Delete the OAuth credential files listed in `CRED_FILE_NAMES`
|
/// (the `/api/logout` bullet) for the three-step rationale +
|
||||||
/// inside `paths::claude_dir()`. **Preserves** session history
|
/// preservation invariants.
|
||||||
/// (`projects/<hash>/*.jsonl`), sessions, shell-snapshots, plans,
|
|
||||||
/// settings, telemetry, and the dir itself — `--continue` keeps
|
|
||||||
/// working with the same session after a fresh login.
|
|
||||||
/// 3. Flip `LoginState::NeedsLogin` + emit Note + status. The turn-
|
|
||||||
/// loop's next iteration parks into `wait_for_login`, which
|
|
||||||
/// snapshots the dir (now missing the cred files) and resumes
|
|
||||||
/// only when a fresh credentials file appears via the dashboard's
|
|
||||||
/// `/login/code` flow (#542 mtime resumption).
|
|
||||||
///
|
|
||||||
/// Always returns 200 with a body describing what happened — errors
|
|
||||||
/// per file are folded into the response + the Note so the operator
|
|
||||||
/// sees them in the live panel rather than as an HTTP error. Missing
|
|
||||||
/// files (already logged out) are treated as idempotent.
|
|
||||||
async fn post_logout(State(state): State<AppState>) -> Response {
|
async fn post_logout(State(state): State<AppState>) -> Response {
|
||||||
// Step 1: SIGINT claude (best-effort, matches `post_cancel_turn`).
|
// Step 1: SIGINT claude (best-effort, matches `post_cancel_turn`).
|
||||||
let _ = tokio::process::Command::new("pkill")
|
let _ = tokio::process::Command::new("pkill")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue