diff --git a/hive-c0re/src/actions.rs b/hive-c0re/src/actions.rs index 877543f..5fcb768 100644 --- a/hive-c0re/src/actions.rs +++ b/hive-c0re/src/actions.rs @@ -56,14 +56,23 @@ pub async fn approve(coord: Arc, id: i64) -> Result<()> { tracing::warn!(agent = %approval.agent, error = ?e, "forge: push_config after apply failed"); } if is_first_spawn && result.is_ok() { - // First-spawn bookkeeping: create the per-agent forge user - // and mirror the applied repo into agent-configs/. + // First-spawn bookkeeping: create the per-agent forge user, + // mirror the applied repo into agent-configs/, and grant + // read access to core/meta. if let Err(e) = crate::forge::ensure_user_for(&approval.agent).await { tracing::warn!(agent = %approval.agent, error = ?e, "forge: ensure_user after first spawn failed"); } if let Err(e) = crate::forge::ensure_config_repo(&approval.agent).await { tracing::warn!(agent = %approval.agent, error = ?e, "forge: ensure_config_repo after first spawn failed"); } + if let Some(core_token) = crate::forge::core_token() { + if let Err(e) = crate::forge::meta_read_access(&approval.agent, &core_token).await { + tracing::warn!(agent = %approval.agent, error = ?e, "forge: meta_read_access after first spawn failed"); + } + } + if let Err(e) = crate::forge::ensure_meta_remote(&approval.agent).await { + tracing::warn!(agent = %approval.agent, error = ?e, "forge: ensure_meta_remote after first spawn failed"); + } // New container row appeared — rescan so the dashboard // reflects the post-spawn state without a manual refetch. coord.rescan_containers_and_emit().await; @@ -81,6 +90,12 @@ pub async fn approve(coord: Arc, id: i64) -> Result<()> { Ok(()) } .await; + // Wire the meta remote now that the proposed repo exists. + if result.is_ok() { + if let Err(e) = crate::forge::ensure_meta_remote(&approval.agent).await { + tracing::warn!(agent = %approval.agent, error = ?e, "forge: ensure_meta_remote after init_config failed"); + } + } finish_approval(&coord, &approval, result, None, false) } ApprovalKind::UpdateMetaInputs => { @@ -130,6 +145,14 @@ pub async fn approve(coord: Arc, id: i64) -> Result<()> { 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 Some(core_token) = crate::forge::core_token() { + if let Err(e) = crate::forge::meta_read_access(&agent_bg, &core_token).await { + tracing::warn!(agent = %agent_bg, error = ?e, "forge: meta_read_access after spawn failed"); + } + } + if let Err(e) = crate::forge::ensure_meta_remote(&agent_bg).await { + tracing::warn!(agent = %agent_bg, error = ?e, "forge: ensure_meta_remote after spawn failed"); + } } if let Err(e) = finish_approval(&coord_bg, &approval_bg, result, None, false) { tracing::warn!(agent = %agent_bg, error = ?e, "spawn approval failed"); diff --git a/hive-c0re/src/forge.rs b/hive-c0re/src/forge.rs index 27bfea5..0d07f1a 100644 --- a/hive-c0re/src/forge.rs +++ b/hive-c0re/src/forge.rs @@ -8,10 +8,10 @@ //! //! 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. +//! deploy / approval tag core plants is visible on the forge. Each +//! agent is a read-only collaborator on `core/meta` (the meta flake) +//! so they can fetch their deployment context; the `agent-configs` +//! repos remain core-only. //! //! No-op when `hive-forge` isn't enabled (detected via //! `nixos-container list`), so operators who don't run the bundled @@ -381,6 +381,75 @@ pub async fn ensure_config_repo(name: &str) -> Result<()> { ensure_org_repo(CONFIG_ORG, name, &token).await } +/// Grant agent `name` read-only collaborator access to `core/meta` on +/// the forge so the agent can clone/fetch the meta flake. Idempotent: +/// HTTP 204 (already a collaborator) is treated as success. +pub async fn meta_read_access(name: &str, core_token: &str) -> Result<()> { + let url = format!("{FORGE_HTTP}/api/v1/repos/core/meta/collaborators/{name}"); + let body = r#"{"permission":"read"}"#; + let out = Command::new("curl") + .args([ + "-sS", + "-o", + "/dev/null", + "-w", + "%{http_code}", + "-X", + "PUT", + "-H", + "Content-Type: application/json", + "-H", + &format!("Authorization: token {core_token}"), + "-d", + body, + &url, + ]) + .output() + .await + .context("invoke curl PUT core/meta/collaborators")?; + let code = String::from_utf8_lossy(&out.stdout).trim().to_owned(); + match code.as_str() { + "204" => { + tracing::info!(%name, "forge: granted meta read access"); + Ok(()) + } + other => anyhow::bail!( + "PUT core/meta/collaborators/{name} returned HTTP {other}" + ), + } +} + +/// Add `http://localhost:3000/core/meta.git` as the `meta` remote in +/// the agent's proposed config repo so the agent (and the manager) can +/// fetch the meta flake from the forge. Idempotent: no-op when the +/// remote already points at the right URL, or when the proposed repo +/// does not exist yet. No-op when the forge is not running. +pub async fn ensure_meta_remote(name: &str) -> Result<()> { + if !is_present().await { + return Ok(()); + } + let proposed_dir = Coordinator::agent_proposed_dir(name); + if !proposed_dir.join(".git").exists() { + return Ok(()); + } + let want = format!("{FORGE_HTTP}/core/meta.git"); + let existing = crate::lifecycle::git_command() + .current_dir(&proposed_dir) + .args(["remote", "get-url", "meta"]) + .output() + .await + .context("git remote get-url meta")?; + if existing.status.success() { + let current = String::from_utf8_lossy(&existing.stdout).trim().to_owned(); + if current == want { + return Ok(()); + } + crate::lifecycle::git(&proposed_dir, &["remote", "set-url", "meta", &want]).await + } else { + crate::lifecycle::git(&proposed_dir, &["remote", "add", "meta", &want]).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. @@ -531,5 +600,15 @@ pub async fn ensure_all() { if let Err(e) = push_config(&name).await { tracing::warn!(%name, error = ?e, "forge: push_config failed"); } + // Grant read-only access to core/meta and wire the `meta` remote + // into the proposed repo so agents can fetch their deployment context. + if let Some(token) = core_token.as_deref() { + if let Err(e) = meta_read_access(&name, token).await { + tracing::warn!(%name, error = ?e, "forge: ensure_meta_read_access failed"); + } + } + if let Err(e) = ensure_meta_remote(&name).await { + tracing::warn!(%name, error = ?e, "forge: ensure_meta_remote failed"); + } } } diff --git a/hive-c0re/src/lifecycle.rs b/hive-c0re/src/lifecycle.rs index 2623882..5e34a02 100644 --- a/hive-c0re/src/lifecycle.rs +++ b/hive-c0re/src/lifecycle.rs @@ -579,7 +579,7 @@ pub fn git_command() -> Command { Command::new(exe) } -async fn git(dir: &Path, args: &[&str]) -> Result<()> { +pub async fn git(dir: &Path, args: &[&str]) -> Result<()> { let out = git_command() .current_dir(dir) .args(args)