hive-c0re: scrub host-integration cookies (#715 batch 8)

This commit is contained in:
damocles 2026-05-31 17:48:54 +02:00 committed by mara
commit dd07ca946e
3 changed files with 57 additions and 60 deletions

View file

@ -37,7 +37,7 @@ pub struct Coordinator {
pub broker: Arc<Broker>, pub broker: Arc<Broker>,
pub approvals: Arc<Approvals>, pub approvals: Arc<Approvals>,
pub questions: Arc<OperatorQuestions>, pub questions: Arc<OperatorQuestions>,
/// Scheduled-prompts queue (#444). One sqlite connection, /// Scheduled-prompts queue. One sqlite connection,
/// internal mutex; the worker drains due rows and the manager /// internal mutex; the worker drains due rows and the manager
/// handlers insert / cancel through the same handle. /// handlers insert / cancel through the same handle.
pub scheduled_prompts: Arc<crate::scheduled_prompts::ScheduledPrompts>, pub scheduled_prompts: Arc<crate::scheduled_prompts::ScheduledPrompts>,
@ -74,7 +74,7 @@ pub struct Coordinator {
/// drop clears the transient → poll fires next tick and sees the /// drop clears the transient → poll fires next tick and sees the
/// container missing-from-running with no active transient → /// container missing-from-running with no active transient →
/// spurious "container stopped without an operator action" /// spurious "container stopped without an operator action"
/// message (closes #425). /// message.
/// ///
/// `clear_transient` stamps the cleared kind here with an /// `clear_transient` stamps the cleared kind here with an
/// `Instant`; `recent_transient_within(grace)` returns the set of /// `Instant`; `recent_transient_within(grace)` returns the set of
@ -94,8 +94,8 @@ pub struct Coordinator {
/// Count of dashboard-triggered `meta-update` runs currently in /// Count of dashboard-triggered `meta-update` runs currently in
/// flight. `post_meta_update` returns 200 immediately and does the /// flight. `post_meta_update` returns 200 immediately and does the
/// multi-minute `nix flake update` + agent-rebuild ripple in a /// multi-minute `nix flake update` + agent-rebuild ripple in a
/// background task, so without this the META INPUTS panel showed no /// background task, so without this the META INPUTS panel showed
/// sign anything was happening (issue #259). Held via /// no sign anything was happening. Held via
/// `MetaUpdateGuard`; a count > 0 surfaces on `/api/state` as /// `MetaUpdateGuard`; a count > 0 surfaces on `/api/state` as
/// `meta_update_running` and via the `MetaUpdateRunning` event. /// `meta_update_running` and via the `MetaUpdateRunning` event.
meta_updates_active: AtomicU64, meta_updates_active: AtomicU64,
@ -314,7 +314,7 @@ impl Coordinator {
/// concurrent run emits `MetaUpdateRunning { running: true }`; the /// concurrent run emits `MetaUpdateRunning { running: true }`; the
/// last one to finish emits `running: false`. The dashboard's META /// last one to finish emits `running: false`. The dashboard's META
/// INPUTS panel reads the flag to show a disabled "updating…" /// INPUTS panel reads the flag to show a disabled "updating…"
/// state while the lock bump + rebuild ripple runs (issue #259). /// state while the lock bump + rebuild ripple runs.
pub fn meta_update_guard(self: &Arc<Self>) -> MetaUpdateGuard { pub fn meta_update_guard(self: &Arc<Self>) -> MetaUpdateGuard {
if self.meta_updates_active.fetch_add(1, Ordering::SeqCst) == 0 { if self.meta_updates_active.fetch_add(1, Ordering::SeqCst) == 0 {
self.emit_dashboard_event(DashboardEvent::MetaUpdateRunning { self.emit_dashboard_event(DashboardEvent::MetaUpdateRunning {
@ -510,7 +510,7 @@ impl Coordinator {
} }
/// Apply a topology reparent + fan the resulting notifications /// Apply a topology reparent + fan the resulting notifications
/// out to the three affected agents (#743). On success, drops a /// out to the three affected agents. On success, drops a
/// one-line system message into the inbox of: /// one-line system message into the inbox of:
/// ///
/// 1. The **old parent** (if any) — `"{child} moved out of your /// 1. The **old parent** (if any) — `"{child} moved out of your
@ -523,7 +523,8 @@ impl Coordinator {
/// ///
/// `_or_root` resolves to the literal string `"<root>"` when the /// `_or_root` resolves to the literal string `"<root>"` when the
/// slot is `None`, keeping the wording consistent with the /// slot is `None`, keeping the wording consistent with the
/// `<parent>` sentinel's "root → operator" routing (#692). The /// `<parent>` sentinel's "root → operator" routing (see
/// `docs/conventions.md::Recipient sentinels`). The
/// notifications fire as ordinary broker messages with /// notifications fire as ordinary broker messages with
/// `from = hive_sh4re::SYSTEM_SENDER` so the dashboard renders /// `from = hive_sh4re::SYSTEM_SENDER` so the dashboard renders
/// them under the existing system-source styling. /// them under the existing system-source styling.
@ -667,9 +668,9 @@ impl Coordinator {
// Stamp the tombstone so the crash watcher can still see // Stamp the tombstone so the crash watcher can still see
// "operator kicked this off recently" on its next 10s poll // "operator kicked this off recently" on its next 10s poll
// — without this, the clear-then-poll race produced a // — without this, the clear-then-poll race produced a
// spurious ContainerCrash on every operator stop/restart // spurious ContainerCrash on every operator stop/restart.
// (#425). Old entries get reaped lazily on read so the map // Old entries get reaped lazily on read so the map doesn't
// doesn't grow unbounded. // grow unbounded.
self.recent_transient self.recent_transient
.lock() .lock()
.unwrap() .unwrap()

View file

@ -34,21 +34,18 @@ const TOKEN_NAME_PREFIX: &str = "hyperhive";
/// creation, future webhook setup, etc.). Root-only. /// creation, future webhook setup, etc.). Root-only.
const CORE_TOKEN_PATH: &str = "/var/lib/hyperhive/forge-core-token"; const CORE_TOKEN_PATH: &str = "/var/lib/hyperhive/forge-core-token";
/// Marker that records whether `ensure_core_avatar` has successfully /// Marker that records whether `ensure_core_avatar` has successfully
/// uploaded the hyperhive logo as `core`'s avatar (issue #320). One-shot: /// uploaded the hyperhive logo as `core`'s avatar. One-shot: the
/// the upload runs once, the marker is written, subsequent startups skip /// upload runs once, the marker is written, subsequent startups skip
/// the call. Delete to force re-upload. /// the call. Delete to force re-upload.
const CORE_AVATAR_MARKER: &str = "/var/lib/hyperhive/forge-core-avatar-set"; const CORE_AVATAR_MARKER: &str = "/var/lib/hyperhive/forge-core-avatar-set";
/// Sibling marker for the `agent-configs` org avatar (#424). Same one- /// Sibling marker for the `agent-configs` org avatar. Same one-shot
/// shot semantics — delete to force the upload to re-run. /// semantics — delete to force the upload to re-run.
const CONFIG_ORG_AVATAR_MARKER: &str = "/var/lib/hyperhive/forge-agent-configs-avatar-set"; const CONFIG_ORG_AVATAR_MARKER: &str = "/var/lib/hyperhive/forge-agent-configs-avatar-set";
// Avatar PNGs are loaded at runtime from // Avatar PNGs are loaded at runtime from
// `$HIVE_ASSETS_DIR/branding/{hyperhive,agent-configs}.png` via the // `$HIVE_ASSETS_DIR/branding/{hyperhive,agent-configs}.png` via the
// helpers in `hive_sh4re::assets` (#555 — was `include_bytes!` of an // helpers in `hive_sh4re::assets`. The `agent-configs.png` is
// in-source path and an OUT_DIR-rendered sibling, both of which // rendered from its SVG during the `hyperhive-assets` derivation's
// invalidated the crane src cache on any branding edit). The // build.
// `agent-configs.png` is rendered from its SVG during the
// `hyperhive-assets` derivation's build (was `hive-c0re/build.rs`
// + `rsvg-convert` on PATH; both gone now).
/// Forgejo org grouping every agent's applied config repo. Core is a /// Forgejo org grouping every agent's applied config repo. Core is a
/// site admin and reads + writes every repo here; agents are NOT /// site admin and reads + writes every repo here; agents are NOT
/// members and the repos are private, so no agent — not even the one /// members and the repos are private, so no agent — not even the one
@ -84,7 +81,7 @@ const TOKEN_SCOPES: &str = "read:user,write:user,read:notification,write:notific
/// membership alone isn't enough — the token's own scope gate runs /// membership alone isn't enough — the token's own scope gate runs
/// before the user-permission check, so `403 Forbidden` comes back /// before the user-permission check, so `403 Forbidden` comes back
/// for any `/admin/users/*` call from a non-admin-scoped token /// for any `/admin/users/*` call from a non-admin-scoped token
/// even if the bearer is an admin user (#646). /// even if the bearer is an admin user.
const CORE_TOKEN_SCOPES: &str = "read:admin,write:admin,read:user,write:user,read:notification,write:notification,write:repository,write:issue,write:organization,write:misc"; const CORE_TOKEN_SCOPES: &str = "read:admin,write:admin,read:user,write:user,read:notification,write:notification,write:repository,write:issue,write:organization,write:misc";
/// Token file inside the agent's bind-mounted state dir (visible as /// Token file inside the agent's bind-mounted state dir (visible as
@ -199,8 +196,7 @@ async fn forge_http(
/// account password: `None` uses `--random-password` (the existing /// account password: `None` uses `--random-password` (the existing
/// agent provisioning shape — the password is never read, agents auth /// agent provisioning shape — the password is never read, agents auth
/// by token); `Some(pw)` uses `--password <pw>` so the operator path /// by token); `Some(pw)` uses `--password <pw>` so the operator path
/// in `hivectl` can set a real password for matrix-style web-UI login /// in `hivectl` can set a real password for matrix-style web-UI login.
/// (#663).
async fn ensure_user_exists(name: &str, admin: bool, password: Option<&str>) -> Result<()> { async fn ensure_user_exists(name: &str, admin: bool, password: Option<&str>) -> Result<()> {
let email = agent_email(name); let email = agent_email(name);
let mut args = vec!["user", "create", "--username", name, "--email", &email]; let mut args = vec!["user", "create", "--username", name, "--email", &email];
@ -257,11 +253,11 @@ async fn change_user_password(name: &str, password: &str) -> Result<()> {
/// ///
/// Uses the admin REST API (`PATCH /api/v1/admin/users/{name}`) rather /// Uses the admin REST API (`PATCH /api/v1/admin/users/{name}`) rather
/// than `forgejo admin user edit` because the CLI dropped the `edit` /// than `forgejo admin user edit` because the CLI dropped the `edit`
/// subcommand somewhere between forgejo 8 and current — see #574 for /// subcommand somewhere between forgejo 8 and current (the bare CLI
/// the "flag provided but not defined: -username" error this fix /// surfaced as "flag provided but not defined: -username"). Body sets
/// replaces. Body sets `source_id = 0` (local auth, the default for /// `source_id = 0` (local auth, the default for users hive-c0re
/// users hive-c0re creates) which forgejo's PATCH validator requires /// creates) which forgejo's PATCH validator requires even when the
/// even when the only thing changing is the email. /// only thing changing is the email.
async fn ensure_user_email(name: &str) { async fn ensure_user_email(name: &str) {
let Some(token) = core_token() else { let Some(token) = core_token() else {
tracing::debug!(%name, "forge: skipping ensure_user_email — no core token yet"); tracing::debug!(%name, "forge: skipping ensure_user_email — no core token yet");
@ -276,13 +272,13 @@ async fn ensure_user_email(name: &str) {
} }
Ok(status) if status == reqwest::StatusCode::FORBIDDEN => { Ok(status) if status == reqwest::StatusCode::FORBIDDEN => {
// Almost certainly an existing-deployment migration case: // Almost certainly an existing-deployment migration case:
// pre-#646 the core token was minted without admin scope, so // an older core token may have been minted without admin
// /admin/users/* now returns 403 even though `core` is a // scope, so /admin/users/* now returns 403 even though
// site admin. Tell the operator how to fix it inline (#646). // `core` is a site admin. Tell the operator how to fix it.
tracing::warn!( tracing::warn!(
%name, %email, %status, %name, %email, %status,
"forge: PATCH user email forbidden — core token likely missing admin scope. \ "forge: PATCH user email forbidden — core token likely missing admin scope. \
Delete {CORE_TOKEN_PATH} and restart hive-c0re to re-mint with the new scopes (#646)." Delete {CORE_TOKEN_PATH} and restart hive-c0re to re-mint with the new scopes."
); );
} }
Ok(status) => { Ok(status) => {
@ -357,15 +353,15 @@ pub async fn ensure_user_for(name: &str) -> Result<()> {
/// disk — the caller is responsible for storing it. Used by `hivectl /// disk — the caller is responsible for storing it. Used by `hivectl
/// forge create-user` for human (non-agent) accounts so we don't create /// forge create-user` for human (non-agent) accounts so we don't create
/// stray `/var/lib/hyperhive/agents/<name>/` directories for users that /// stray `/var/lib/hyperhive/agents/<name>/` directories for users that
/// aren't agents (#662). /// aren't agents.
/// ///
/// `password` picks the account password. `None` keeps the existing /// `password` picks the account password. `None` keeps the existing
/// random-throwaway shape (caller doesn't need web UI access — token /// random-throwaway shape (caller doesn't need web UI access — token
/// alone is enough). `Some(pw)` sets `pw` as the password, including /// alone is enough). `Some(pw)` sets `pw` as the password, including
/// running `forgejo admin user change-password` if the account already /// running `forgejo admin user change-password` if the account already
/// exists, so the operator can log into the forge web UI afterwards /// exists, so the operator can log into the forge web UI afterwards.
/// (#663). Idempotent: re-running with the same `Some(pw)` lands on /// Idempotent: re-running with the same `Some(pw)` lands on the same
/// the same final state. /// final state.
pub async fn provision_user_token(name: &str, password: Option<&str>) -> Result<String> { pub async fn provision_user_token(name: &str, password: Option<&str>) -> Result<String> {
if !is_present().await { if !is_present().await {
anyhow::bail!( anyhow::bail!(
@ -385,9 +381,9 @@ pub async fn provision_user_token(name: &str, password: Option<&str>) -> Result<
} }
/// Set `core`'s Forgejo avatar to the hyperhive logo once, then /// Set `core`'s Forgejo avatar to the hyperhive logo once, then
/// remember it so subsequent startups don't re-upload (issue #320). /// remember it so subsequent startups don't re-upload. Best-effort
/// Best-effort — any non-2xx is logged at the caller; the project /// — any non-2xx is logged at the caller; the project runs fine
/// runs fine with the default hash identicon. /// with the default hash identicon.
async fn ensure_core_avatar(token: &str) -> Result<()> { async fn ensure_core_avatar(token: &str) -> Result<()> {
let marker = std::path::Path::new(CORE_AVATAR_MARKER); let marker = std::path::Path::new(CORE_AVATAR_MARKER);
if marker.exists() { if marker.exists() {
@ -415,7 +411,7 @@ async fn ensure_core_avatar(token: &str) -> Result<()> {
} }
/// Set the `agent-configs` org's Forgejo avatar to the /// Set the `agent-configs` org's Forgejo avatar to the
/// configs-stack glyph once (#424). Sibling to `ensure_core_avatar`: /// configs-stack glyph once. Sibling to `ensure_core_avatar`:
/// one-shot, marker-guarded, best-effort. Forgejo's per-org avatar /// one-shot, marker-guarded, best-effort. Forgejo's per-org avatar
/// endpoint is `POST /api/v1/orgs/{org}/avatar` with a base64-PNG /// endpoint is `POST /api/v1/orgs/{org}/avatar` with a base64-PNG
/// JSON body — same shape as the admin user endpoint above. /// JSON body — same shape as the admin user endpoint above.

View file

@ -194,8 +194,8 @@ pub struct QueueEntry {
/// the wire that way too. /// the wire that way too.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub approval_id: Option<i64>, pub approval_id: Option<i64>,
/// Current sub-step inside the running entry (#437, option A from /// Current sub-step inside the running entry. Worker mutates this
/// the issue). Worker mutates this as the kind-specific pipeline /// as the kind-specific pipeline
/// advances through phases (e.g. `"plant tags"` → /// advances through phases (e.g. `"plant tags"` →
/// `"nixos-container update"` → `"finalize deploy"`). `None` while /// `"nixos-container update"` → `"finalize deploy"`). `None` while
/// `Queued` and after terminal — only meaningful with /// `Queued` and after terminal — only meaningful with
@ -255,9 +255,9 @@ impl RebuildQueue {
/// `(kind, agent)` swallows the new request. /// `(kind, agent)` swallows the new request.
/// - `MetaUpdate`: dedup ALSO requires the `inputs` field to match — /// - `MetaUpdate`: dedup ALSO requires the `inputs` field to match —
/// two meta-updates with different input lists are distinct work /// two meta-updates with different input lists are distinct work
/// and must queue separately (closes #365: previously the second /// and must queue separately, otherwise the second meta-update
/// meta-update collapsed into the first whenever it was still /// would silently collapse into the first whenever it was still
/// `Queued`, losing the second's input set). /// `Queued`, losing the second's input set.
/// ///
/// Running and terminal entries never dedup — operators are free /// Running and terminal entries never dedup — operators are free
/// to re-queue a rebuild that's currently running (something /// to re-queue a rebuild that's currently running (something
@ -277,7 +277,7 @@ impl RebuildQueue {
/// `MetaUpdate` enqueues to tell the worker which meta-flake /// `MetaUpdate` enqueues to tell the worker which meta-flake
/// inputs to bump. For `MetaUpdate` the `inputs` value is part of /// inputs to bump. For `MetaUpdate` the `inputs` value is part of
/// the dedup key (two meta-updates with different inputs are /// the dedup key (two meta-updates with different inputs are
/// distinct operations, see #365). /// distinct operations).
pub fn enqueue_with_inputs( pub fn enqueue_with_inputs(
&self, &self,
kind: QueueKind, kind: QueueKind,
@ -293,8 +293,8 @@ impl RebuildQueue {
/// Full-shape enqueue — every `QueueEntry` field that's settable /// Full-shape enqueue — every `QueueEntry` field that's settable
/// at submit time. Existing `enqueue` / `enqueue_with_inputs` /// at submit time. Existing `enqueue` / `enqueue_with_inputs`
/// delegate to this with `approval_id: None`; the approval-driven /// delegate to this with `approval_id: None`; the approval-driven
/// POST handlers (#436) call it directly with the source row's id /// POST handlers call it directly with the source row's id so the
/// so the worker can re-fetch the kind-specific payload. /// worker can re-fetch the kind-specific payload.
// 8/7 args: the queue entry has 6 independent submit-time fields plus // 8/7 args: the queue entry has 6 independent submit-time fields plus
// the inputs/approval_id pair specific to MetaUpdate and approval // the inputs/approval_id pair specific to MetaUpdate and approval
// entries. A builder struct would obscure the call sites; the // entries. A builder struct would obscure the call sites; the
@ -314,9 +314,9 @@ impl RebuildQueue {
let mut inner = self.inner.lock().expect("rebuild_queue mutex poisoned"); let mut inner = self.inner.lock().expect("rebuild_queue mutex poisoned");
// Dedup against a pending entry with the same (kind, agent) — // Dedup against a pending entry with the same (kind, agent) —
// and, for MetaUpdate, the same `inputs` list (see method // and, for MetaUpdate, the same `inputs` list (see method
// docstring + #365 for why). Approval-driven entries also // docstring for why). Approval-driven entries also require the
// require the approval_id to match so two distinct approvals // approval_id to match so two distinct approvals for the same
// for the same agent never collapse into one queue slot. // agent never collapse into one queue slot.
for entry in &mut inner.entries { for entry in &mut inner.entries {
if entry.state == QueueState::Queued if entry.state == QueueState::Queued
&& entry.kind == kind && entry.kind == kind
@ -392,7 +392,7 @@ impl RebuildQueue {
Self::trim_history(&mut inner); Self::trim_history(&mut inner);
} }
/// Set the current sub-step label on a `Running` entry (#437). /// Set the current sub-step label on a `Running` entry.
/// Returns `true` when the row was found AND the label changed /// Returns `true` when the row was found AND the label changed
/// (caller should emit a `RebuildQueueChanged` snapshot only on /// (caller should emit a `RebuildQueueChanged` snapshot only on
/// `true` to avoid noisy duplicate frames). No-op for entries not /// `true` to avoid noisy duplicate frames). No-op for entries not
@ -583,9 +583,9 @@ async fn dispatch(
// approval (HostRequest::RequestSpawn → submit_kind → // approval (HostRequest::RequestSpawn → submit_kind →
// approve → enqueue with approval_id). The manager-side // approve → enqueue with approval_id). The manager-side
// `RequestSpawn` surface that used to bypass approvals // `RequestSpawn` surface that used to bypass approvals
// was removed in #442; if a future direct-spawn admin // was removed; if a future direct-spawn admin path needs
// path needs to skip the approval ride it should wire // to skip the approval ride it should wire its own action
// its own action call rather than route through here. // call rather than route through here.
anyhow::bail!( anyhow::bail!(
"rebuild_queue: Spawn entry id={} agent={} arrived without an approval_id — \ "rebuild_queue: Spawn entry id={} agent={} arrived without an approval_id — \
nothing should enqueue this shape today", nothing should enqueue this shape today",
@ -648,9 +648,9 @@ async fn run_meta_update(
/// Compute which agents a `nix flake update <inputs>` on the meta /// Compute which agents a `nix flake update <inputs>` on the meta
/// flake would affect. Used by callers that pre-enqueue cascade /// flake would affect. Used by callers that pre-enqueue cascade
/// `Rebuild` entries at `MetaUpdate` submission time (issue #347) so the /// `Rebuild` entries at `MetaUpdate` submission time so the dashboard
/// dashboard can render the dependent work alongside its parent before /// can render the dependent work alongside its parent before the lock
/// the lock bump actually runs. /// bump actually runs.
/// ///
/// Mirrors `run_meta_update`'s post-bump fan-out logic. Empty `inputs` /// Mirrors `run_meta_update`'s post-bump fan-out logic. Empty `inputs`
/// or any input under `hyperhive` → every container; otherwise just /// or any input under `hyperhive` → every container; otherwise just
@ -749,7 +749,7 @@ mod tests {
#[test] #[test]
fn meta_update_dedup_matches_inputs() { fn meta_update_dedup_matches_inputs() {
// Two MetaUpdate enqueues with identical inputs → dedup (#365). // Two MetaUpdate enqueues with identical inputs → dedup.
let q = RebuildQueue::new(); let q = RebuildQueue::new();
let a = q.enqueue_with_inputs( let a = q.enqueue_with_inputs(
QueueKind::MetaUpdate, QueueKind::MetaUpdate,
@ -774,7 +774,7 @@ mod tests {
#[test] #[test]
fn meta_update_dedup_separates_distinct_inputs() { fn meta_update_dedup_separates_distinct_inputs() {
// Two MetaUpdate enqueues with DIFFERENT inputs → distinct // Two MetaUpdate enqueues with DIFFERENT inputs → distinct
// entries, not deduped (the actual #365 bug). // entries, not deduped.
let q = RebuildQueue::new(); let q = RebuildQueue::new();
let a = q.enqueue_with_inputs( let a = q.enqueue_with_inputs(
QueueKind::MetaUpdate, QueueKind::MetaUpdate,