diff --git a/CLAUDE.md b/CLAUDE.md index 7331550..0ef3540 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -183,6 +183,34 @@ read them à la carte. In-flight or recent context that hasn't earned a section yet. Prune freely. +- **Just landed:** applied config repos mirrored to the + forge. New private `agent-configs` Forgejo org (renamed + from the unused `agents` org in `SEEDED_ORGS`); core is the + only principal with access (site admin + private repos + + agents not members). `forge::push_config(name)` mirrors an + agent's hive-c0re-owned applied repo — `main` + every tag + (proposal/approved/building/deployed/failed/denied) — to + `agent-configs/.git` via `git push --force`. The + tokenised URL is passed inline per push, never stored as a + named remote: the applied repo is RO-bind-mounted into the + manager at `/applied`, so a token in `.git/config` would + leak core's admin credential to an agent. Call sites: + `forge::ensure_all` (startup, per agent — catches migrate + + offline-forge drift), the spawn task in `actions::approve` + (+ `ensure_config_repo`), `actions::approve` ApplyCommit + branch, `actions::deny` ApplyCommit branch, and + `manager_server::submit_apply_commit`. All best-effort + (warn + continue). `ensure_repo` refactored to share a + `create_repo` helper with the new `ensure_org_repo`. +- **Just landed:** answer questions inline from the per-agent + web page. Question rows in the loose-ends section grew a + textarea + send button; the operator answers as operator by + POSTing cross-origin to the core dashboard's + `/answer-question/{id}` (CORS shim `with_cors` on that + route), never the per-agent socket — keeps the + operator-authority path off the agent's own socket. See + `TODO-ops.md` for the boundary rationale + the deployment/ + gateway/privsep cluster. - **Just landed:** sub-agents get a read-only view of their own config repo. `set_nspawn_flags` now adds `--bind-ro={proposed_dir}:/agents//config` for every diff --git a/hive-c0re/src/actions.rs b/hive-c0re/src/actions.rs index 74c37b2..41e7818 100644 --- a/hive-c0re/src/actions.rs +++ b/hive-c0re/src/actions.rs @@ -50,6 +50,11 @@ pub async fn approve(coord: Arc, id: i64) -> Result<()> { ¬es_dir, ) .await; + // Mirror the applied repo's new tag/branch state (approved/ + // building/deployed-or-failed + main) to the forge. + if let Err(e) = crate::forge::push_config(&approval.agent).await { + tracing::warn!(agent = %approval.agent, error = ?e, "forge: push_config after apply failed"); + } finish_approval(&coord, &approval, result, terminal_tag) } ApprovalKind::Spawn => { @@ -77,10 +82,19 @@ pub async fn approve(coord: Arc, id: i64) -> Result<()> { ) .await; drop(guard); - if result.is_ok() - && let Err(e) = crate::forge::ensure_user_for(&agent_bg).await - { - tracing::warn!(agent = %agent_bg, error = ?e, "forge: ensure_user after spawn failed"); + if result.is_ok() { + if let Err(e) = crate::forge::ensure_user_for(&agent_bg).await { + tracing::warn!(agent = %agent_bg, error = ?e, "forge: ensure_user after spawn failed"); + } + // Create the agent-configs mirror repo and seed it + // with the freshly-initialised applied repo (main + + // deployed/0). + if let Err(e) = crate::forge::ensure_config_repo(&agent_bg).await { + tracing::warn!(agent = %agent_bg, error = ?e, "forge: ensure_config_repo after spawn failed"); + } + if let Err(e) = crate::forge::push_config(&agent_bg).await { + tracing::warn!(agent = %agent_bg, error = ?e, "forge: push_config after spawn failed"); + } } if let Err(e) = finish_approval(&coord_bg, &approval_bg, result, None) { tracing::warn!(agent = %agent_bg, error = ?e, "spawn approval failed"); @@ -417,6 +431,10 @@ pub async fn deny(coord: &Coordinator, id: i64, note: Option<&str>) -> Result<() tag = Some(tag_name); } } + // Mirror the denied/ tag to the forge. + if let Err(e) = crate::forge::push_config(&a.agent).await { + tracing::warn!(%id, agent = %a.agent, error = ?e, "forge: push_config after deny failed"); + } } let approval_kind = match a.kind { ApprovalKind::Spawn => "spawn", diff --git a/hive-c0re/src/forge.rs b/hive-c0re/src/forge.rs index b82a720..6390c81 100644 --- a/hive-c0re/src/forge.rs +++ b/hive-c0re/src/forge.rs @@ -6,6 +6,13 @@ //! when the user already exists, skips token issuance when the file //! is already there. //! +//! It also mirrors each agent's hive-c0re-owned *applied* config repo +//! into the private `agent-configs` org (`push_config`), so every +//! deploy / approval tag core plants is visible on the forge. Core is +//! the only principal with access — agents are not org members and +//! the repos are private, so an agent can't reach a config repo +//! (not even its own) through the forge. +//! //! No-op when `hive-forge` isn't enabled (detected via //! `nixos-container list`), so operators who don't run the bundled //! forge pay nothing. @@ -24,12 +31,16 @@ const TOKEN_NAME_PREFIX: &str = "hyperhive"; /// itself to push the meta repo + drive admin API calls (org /// creation, future webhook setup, etc.). Root-only. const CORE_TOKEN_PATH: &str = "/var/lib/hyperhive/forge-core-token"; -/// Forgejo orgs hive-c0re ensures on startup. The meta repo lives -/// at `core/meta` (the `core` user's own namespace — no org needed); -/// `agents` holds per-agent applied config repos so they're -/// visible/grouped together and access can be granted org-wide -/// (RO membership for the future shared docs/skills repo). -const SEEDED_ORGS: &[&str] = &["agents"]; +/// Forgejo org grouping every agent's applied config repo. Core is a +/// site admin and reads + writes every repo here; agents are NOT +/// members and the repos are private, so no agent — not even the one +/// a repo describes — can reach a config repo through the forge. The +/// applied repos stay hive-c0re-owned on disk; this org is just a +/// mirror target core pushes to. +const CONFIG_ORG: &str = "agent-configs"; +/// Forgejo orgs hive-c0re ensures on startup. The meta repo lives at +/// `core/meta` (the `core` user's own namespace — no org needed). +const SEEDED_ORGS: &[&str] = &[CONFIG_ORG]; /// Forgejo scopes the agent's token gets. Broad-but-not-admin: every /// repo / PR / issue thing an agent needs day-to-day, no admin /// surface. @@ -231,13 +242,15 @@ async fn ensure_core_user_and_token() -> Result { Ok(raw.trim().to_owned()) } -/// POST `/api/v1/user/repos` to create a repo in the authenticated -/// user's own namespace. `token` belongs to the user we want the -/// repo owned by (we use `core`'s token for `core/meta`). Idempotent: -/// HTTP 409 ("repository already exists") is treated as success. -pub async fn ensure_repo(name: &str, token: &str) -> Result<()> { - let body = format!(r#"{{"name":"{name}","auto_init":false,"private":true,"default_branch":"main"}}"#); - let url = format!("{FORGE_HTTP}/api/v1/user/repos"); +/// JSON body for a private, empty repo defaulting to `main`. +fn repo_body(name: &str) -> String { + format!(r#"{{"name":"{name}","auto_init":false,"private":true,"default_branch":"main"}}"#) +} + +/// POST a repo-creation request to `url` and fold "already exists" +/// (HTTP 409 / 422) into success. `label` is `/` — purely +/// for log + error context. +async fn create_repo(url: &str, body: &str, token: &str, label: &str) -> Result<()> { let out = Command::new("curl") .args([ "-sS", @@ -252,28 +265,51 @@ pub async fn ensure_repo(name: &str, token: &str) -> Result<()> { "-H", &format!("Authorization: token {token}"), "-d", - &body, - &url, + body, + url, ]) .output() .await - .context("invoke curl POST /api/v1/user/repos")?; + .with_context(|| format!("invoke curl POST {url}"))?; let code = String::from_utf8_lossy(&out.stdout).trim().to_owned(); match code.as_str() { "201" => { - tracing::info!(%name, "forge: created repo"); + tracing::info!(%label, "forge: created repo"); Ok(()) } "409" | "422" => { - tracing::debug!(%name, "forge: repo already exists"); + tracing::debug!(%label, "forge: repo already exists"); Ok(()) } - other => anyhow::bail!( - "POST /api/v1/user/repos name={name} returned HTTP {other}" - ), + other => anyhow::bail!("POST {url} ({label}) returned HTTP {other}"), } } +/// Create a repo in the token-owner's own namespace. `token` belongs +/// to the user we want the repo owned by (we use `core`'s token for +/// `core/meta`). Idempotent. +pub async fn ensure_repo(name: &str, token: &str) -> Result<()> { + create_repo( + &format!("{FORGE_HTTP}/api/v1/user/repos"), + &repo_body(name), + token, + &format!("core/{name}"), + ) + .await +} + +/// Create `name` inside org `org` (used for `agent-configs/`). +/// Idempotent. +async fn ensure_org_repo(org: &str, name: &str, token: &str) -> Result<()> { + create_repo( + &format!("{FORGE_HTTP}/api/v1/orgs/{org}/repos"), + &repo_body(name), + token, + &format!("{org}/{name}"), + ) + .await +} + /// Read the persisted core token, or None when the forge isn't /// seeded yet. Cheap — just a file read. pub fn core_token() -> Option { @@ -311,6 +347,66 @@ pub async fn push_meta(dir: &Path) -> Result<()> { Ok(()) } +/// Ensure the `agent-configs/` repo exists so the first +/// `push_config` doesn't 404. No-op when the forge isn't running or +/// the core token isn't minted yet. Safe to call on every spawn and +/// on every startup. +pub async fn ensure_config_repo(name: &str) -> Result<()> { + if !is_present().await { + return Ok(()); + } + let Some(token) = core_token() else { + return Ok(()); + }; + ensure_org_repo(CONFIG_ORG, name, &token).await +} + +/// Mirror agent `name`'s applied config repo — `main` plus every tag +/// (`proposal` / `approved` / `building` / `deployed` / `failed` / +/// `denied`) — to `agent-configs/` on the local forge. +/// Best-effort: returns Err which callers log + ignore. No-op when the +/// forge isn't seeded or the applied repo doesn't exist yet. +/// +/// Call this after every hive-c0re mutation of an applied repo's refs +/// so the forge copy always reflects what core actually did. `--force` +/// because a failed build rolls `main` backwards to the last-good sha. +/// +/// The tokenised URL is passed straight to `git push` and deliberately +/// never stored as a named remote: the applied repo is bind-mounted +/// READ-ONLY into the manager container (`/applied`), so a token in +/// `.git/config` would leak core's admin credential to an agent. +pub async fn push_config(name: &str) -> Result<()> { + let Some(token) = core_token() else { + return Ok(()); + }; + let dir = Coordinator::agent_applied_dir(name); + if !dir.join(".git").exists() { + return Ok(()); + } + let url = format!("http://core:{token}@localhost:3000/{CONFIG_ORG}/{name}.git"); + let out = crate::lifecycle::git_command() + .current_dir(&dir) + .args([ + "push", + "--force", + &url, + "refs/heads/main:refs/heads/main", + "refs/tags/*:refs/tags/*", + ]) + .output() + .await + .context("invoke git push agent-configs")?; + if !out.status.success() { + anyhow::bail!( + "git push {CONFIG_ORG}/{name} failed ({}): {}", + out.status, + String::from_utf8_lossy(&out.stderr).trim() + ); + } + tracing::info!(%name, "forge: mirrored applied config to agent-configs"); + Ok(()) +} + /// POST `/api/v1/orgs` to create an org named `name`. Idempotent: /// HTTP 422 ("user already exists") is treated as success. async fn ensure_org(name: &str, admin_token: &str) -> Result<()> { @@ -354,9 +450,10 @@ async fn ensure_org(name: &str, admin_token: &str) -> Result<()> { } /// Sweep every existing container (manager + sub-agents) and ensure -/// each has a forgejo user + token. Also seeds the `core` admin +/// each has a forgejo user + token, plus an `agent-configs/` +/// repo mirroring its applied config. Also seeds the `core` admin /// user (hive-c0re's own identity for pushing the meta repo + driving -/// the API) and the `core` / `agents` orgs the system pushes into. +/// the API), the `agent-configs` org, and the `core/meta` repo. /// Called once at hive-c0re startup. Per-step failures are logged /// but don't abort the sweep. pub async fn ensure_all() { @@ -399,5 +496,16 @@ pub async fn ensure_all() { if let Err(e) = ensure_user_for(&name).await { tracing::warn!(%name, error = ?e, "forge: ensure_user failed"); } + // Mirror the agent's applied config repo into agent-configs. + // ensure_config_repo is idempotent; push_config catches any + // drift since the last run — e.g. the startup migration just + // relocated `deployed/0`, or a deploy landed while the forge + // was down. + if let Err(e) = ensure_config_repo(&name).await { + tracing::warn!(%name, error = ?e, "forge: ensure_config_repo failed"); + } + if let Err(e) = push_config(&name).await { + tracing::warn!(%name, error = ?e, "forge: push_config failed"); + } } } diff --git a/hive-c0re/src/manager_server.rs b/hive-c0re/src/manager_server.rs index 8417e0c..81099cd 100644 --- a/hive-c0re/src/manager_server.rs +++ b/hive-c0re/src/manager_server.rs @@ -481,6 +481,10 @@ async fn submit_apply_commit( .approvals .set_fetched_sha(id, &sha) .map_err(|e| anyhow::anyhow!("persist fetched_sha: {e:#}"))?; + // Mirror the freshly-planted proposal/ tag to the forge. + if let Err(e) = crate::forge::push_config(agent).await { + tracing::warn!(%agent, %id, error = ?e, "forge: push_config after submit failed"); + } // Phase 5b: surface the new pending approval on the dashboard // event channel. Compute the diff once here so live subscribers // get a fully-formed row without a snapshot refetch.