forge: mirror applied config repos to a private agent-configs org
on startup (and after every applied-repo ref mutation) core pushes each agent's hive-c0re-owned applied repo — main plus every proposal/approved/building/deployed/failed/denied tag — to agent-configs/<name> on the local forge. the org is private and agents are not members, so core is the only principal that can read it. the tokenised push url is passed inline, never stored as a named remote: the applied repo is bind-mounted read-only into the manager, so a token in .git/config would leak the core admin credential to an agent. push_config is best-effort at every site (ensure_all, spawn, approve, deny, submit) — a missing or down forge never blocks a deploy.
This commit is contained in:
parent
1529c2d777
commit
5aad2d67e1
4 changed files with 185 additions and 27 deletions
28
CLAUDE.md
28
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/<name>.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/<name>/config` for every
|
||||
|
|
|
|||
|
|
@ -50,6 +50,11 @@ pub async fn approve(coord: Arc<Coordinator>, 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<Coordinator>, 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/<id> 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",
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
|||
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 `<owner>/<name>` — 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/<agent>`).
|
||||
/// 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<String> {
|
||||
|
|
@ -311,6 +347,66 @@ pub async fn push_meta(dir: &Path) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Ensure the `agent-configs/<name>` 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/<name>` 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/<name>`
|
||||
/// 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/<id> 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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue