agent page: assemble forge URL backend-side

Per review: build the full forge profile URL in the harness instead
of the client. /api/state now returns forge_url: Option<String>
(assembled from the request Host header — resolves against whatever
host the operator reached the page on), replacing the forge_present
bool. The JS just links forge_url when present — no client-side URL
construction.
This commit is contained in:
iris 2026-05-21 20:23:26 +02:00 committed by Mara
parent 6ab667901d
commit fc3490086b
3 changed files with 33 additions and 15 deletions

View file

@ -14,7 +14,7 @@ use anyhow::{Context, Result};
use axum::{
Form, Router,
extract::State,
http::StatusCode,
http::{HeaderMap, StatusCode},
response::{
IntoResponse, Response,
sse::{Event, KeepAlive, Sse},
@ -383,11 +383,11 @@ struct StateSnapshot {
/// (i.e. `/etc/hyperhive/gui.json` was present at harness startup).
/// When true, the UI may render a `🖥 screen` link to `/screen`.
gui_enabled: bool,
/// Whether this agent has a Forgejo account wired — its
/// `forge-token` file exists in the state dir. When true the UI
/// links to the agent's forge profile at `<host>:3000/<label>`
/// (the per-agent forge user is named after the agent).
forge_present: bool,
/// Full URL of this agent's Forgejo profile, or `null` when the
/// agent has no forge account (`forge-token` absent). Assembled
/// server-side from the request `Host` header so the UI just
/// links it — no client-side URL construction.
forge_url: Option<String>,
}
#[derive(Serialize)]
@ -445,7 +445,7 @@ async fn api_loose_ends(State(state): State<AppState>) -> Response {
axum::Json(serde_json::json!({ "loose_ends": loose_ends })).into_response()
}
async fn api_state(State(state): State<AppState>) -> axum::Json<StateSnapshot> {
async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> axum::Json<StateSnapshot> {
// Capture seq *before* any reads so the dedupe contract is
// "events with seq > snapshot.seq are post-snapshot, never missed."
let seq = state.bus.current_seq();
@ -490,10 +490,28 @@ async fn api_state(State(state): State<AppState>) -> axum::Json<StateSnapshot> {
ctx_usage,
cost_usage,
gui_enabled: state.gui_vnc_port.is_some(),
forge_present: crate::paths::state_dir().join("forge-token").is_file(),
forge_url: forge_profile_url(&headers, &state.label),
})
}
/// This agent's Forgejo profile URL — `Some` only when the agent has a
/// forge account (its `forge-token` file exists). The host is taken
/// from the request `Host` header so the link resolves against
/// whatever host the operator reached the page on; the forge always
/// listens on `:3000`, and the per-agent forge user is named after
/// the agent (`label`).
fn forge_profile_url(headers: &HeaderMap, label: &str) -> Option<String> {
if !crate::paths::state_dir().join("forge-token").is_file() {
return None;
}
let host = headers
.get("host")
.and_then(|h| h.to_str().ok())
.unwrap_or("localhost");
let hostname = host.split(':').next().unwrap_or(host);
Some(format!("http://{hostname}:3000/{label}"))
}
/// Best-effort: pull the last 30 messages addressed to us via the
/// per-agent / manager socket. Empty list on any transport / decode
/// failure — the inbox section is decorative, not authoritative.