From c2d176ed13b3c36d311015e30c015b03ffced976 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Sat, 16 May 2026 20:50:36 +0200 Subject: [PATCH 1/7] add hive-forge module: private forgejo for agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit new `services.hive-forge.enable` (off by default) wraps `services.forgejo` with hyperhive-friendly defaults: sqlite (no extra db service), built-in ssh on 2222 so it doesn't fight the host's openssh, http on 3000 (outside hyperhive's 7000/8000/8100-8999 ranges), registration off (operator seeds agent users), private repos by default. exported as `nixosModules.hive-forge` — operator imports it on the host alongside hive-c0re. container-side wiring (MCP tools or a bind-mounted token) is deferred; containers already share the host netns so they can reach http://localhost:3000 today. --- flake.nix | 1 + nix/modules/hive-forge.nix | 116 +++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 nix/modules/hive-forge.nix diff --git a/flake.nix b/flake.nix index ff7f31ab..aefc28c3 100644 --- a/flake.nix +++ b/flake.nix @@ -98,6 +98,7 @@ hyperhivePackage = system: self.packages.${system}.default; hyperhiveFlake = "${self}"; }; + hive-forge = ./nix/modules/hive-forge.nix; }; nixosConfigurations = diff --git a/nix/modules/hive-forge.nix b/nix/modules/hive-forge.nix new file mode 100644 index 00000000..d11a432d --- /dev/null +++ b/nix/modules/hive-forge.nix @@ -0,0 +1,116 @@ +{ + pkgs, + lib, + config, + ... +}: +let + cfg = config.services.hive-forge; +in +{ + # Thin wrapper around `services.forgejo` with hyperhive-friendly + # defaults: sqlite (no extra service to manage), built-in SSH on a + # non-22 port so it doesn't fight the host's sshd, registration off + # (agents get accounts seeded out of band), and ports opened in the + # firewall. + # + # Forge wiring into the agent containers is intentionally out of + # scope here — containers already share the host network namespace, + # so once this module is enabled an agent can reach the forge at + # http://localhost: without any extra bind mount. The MCP + # tool surface (open PR, list repos, etc.) lives in a separate + # follow-up that the operator opts into per agent. + + options.services.hive-forge = { + enable = lib.mkEnableOption "hive-forge — private Forgejo instance for hyperhive agents"; + + httpPort = lib.mkOption { + type = lib.types.port; + default = 3000; + description = '' + TCP port the forge serves HTTP on. Default 3000 sits outside + hyperhive's claimed ranges (dashboard 7000, manager 8000, + sub-agents 8100..8999) so they don't collide. + ''; + }; + + sshPort = lib.mkOption { + type = lib.types.port; + default = 2222; + description = '' + TCP port the forge's built-in SSH server listens on. Kept off + 22 so it doesn't clash with the host's openssh. Agents push + with `ssh -p git@:/.git`. + ''; + }; + + domain = lib.mkOption { + type = lib.types.str; + default = "localhost"; + example = "forge.internal"; + description = '' + Hostname agents and operator dial the forge by. `localhost` is + fine for a single-host setup since containers share the host + netns; set a real hostname when you want clones from outside + the host to look canonical. + ''; + }; + + openFirewall = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Open `httpPort` + `sshPort` in the host firewall. Off when the + forge should only be reachable from inside the host (the + forge listens on all interfaces either way; this just gates + external access). + ''; + }; + }; + + config = lib.mkIf cfg.enable { + services.forgejo = { + enable = true; + database.type = "sqlite3"; + lfs.enable = true; + settings = { + server = { + DOMAIN = cfg.domain; + ROOT_URL = "http://${cfg.domain}:${toString cfg.httpPort}/"; + HTTP_PORT = cfg.httpPort; + START_SSH_SERVER = true; + SSH_PORT = cfg.sshPort; + SSH_LISTEN_PORT = cfg.sshPort; + BUILTIN_SSH_SERVER_USER = "git"; + DISABLE_SSH = false; + }; + # Registration off — operator creates agent users via + # `forgejo admin user create` (or the API once seeded). + # Agents that need access get a dedicated account + token. + service = { + DISABLE_REGISTRATION = true; + REQUIRE_SIGNIN_VIEW = false; + }; + repository = { + DEFAULT_BRANCH = "main"; + DEFAULT_PRIVATE = "private"; + }; + # Quiet logs at idle; bump when debugging. + log.LEVEL = "Warn"; + }; + }; + + networking.firewall = lib.mkIf cfg.openFirewall { + allowedTCPPorts = [ + cfg.httpPort + cfg.sshPort + ]; + }; + + # Convenience: drop the forgejo CLI on the host PATH so the + # operator can `forgejo admin user create …` without hunting for + # the wrapped binary. The forgejo service runs as its own user; + # admin commands need `sudo -u forgejo`. + environment.systemPackages = [ pkgs.forgejo ]; + }; +} From 6e9c67dd949c73c2db4aa54d24f5edb7adf3523e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Sat, 16 May 2026 20:52:36 +0200 Subject: [PATCH 2/7] hive-forge: wrap forgejo in a nixos-container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit avoids fighting an operator-side `services.forgejo` over the singleton module options. container shares host netns (`privateNetwork = false`) so agents still dial the forge via plain `localhost:` and the host firewall is the only layer that matters. container name is `hive-forge` (no `h-` prefix) so hive-c0re's lifecycle scanner ignores it — operator manages it with the standard `nixos-container` CLI. state lives at `/var/lib/nixos-containers/hive-forge/var/lib/forgejo/` and survives restarts. --- nix/modules/hive-forge.nix | 122 ++++++++++++++++++++----------------- 1 file changed, 67 insertions(+), 55 deletions(-) diff --git a/nix/modules/hive-forge.nix b/nix/modules/hive-forge.nix index d11a432d..b35ea33d 100644 --- a/nix/modules/hive-forge.nix +++ b/nix/modules/hive-forge.nix @@ -8,21 +8,24 @@ let cfg = config.services.hive-forge; in { - # Thin wrapper around `services.forgejo` with hyperhive-friendly - # defaults: sqlite (no extra service to manage), built-in SSH on a - # non-22 port so it doesn't fight the host's sshd, registration off - # (agents get accounts seeded out of band), and ports opened in the - # firewall. + # Private Forgejo for hyperhive agents, wrapped in a nixos-container + # so it doesn't fight any `services.forgejo` the operator already + # runs on the host. The container shares the host network namespace + # (`privateNetwork = false`) so agents reach the forge at + # `http://localhost:` without any extra plumbing — + # nixos-container is just here for state + systemd-unit isolation, + # not network isolation. # - # Forge wiring into the agent containers is intentionally out of - # scope here — containers already share the host network namespace, - # so once this module is enabled an agent can reach the forge at - # http://localhost: without any extra bind mount. The MCP - # tool surface (open PR, list repos, etc.) lives in a separate - # follow-up that the operator opts into per agent. + # Container name is `hive-forge` (not `h-*`), so hive-c0re's + # lifecycle scanner ignores it; the operator manages it via the + # standard `nixos-container` CLI. + # + # State lives at `/var/lib/nixos-containers/hive-forge/var/lib/forgejo/` + # and survives container restart / host reboot. To wipe, destroy the + # container. options.services.hive-forge = { - enable = lib.mkEnableOption "hive-forge — private Forgejo instance for hyperhive agents"; + enable = lib.mkEnableOption "hive-forge — private Forgejo (in a nixos-container) for hyperhive agents"; httpPort = lib.mkOption { type = lib.types.port; @@ -30,7 +33,8 @@ in description = '' TCP port the forge serves HTTP on. Default 3000 sits outside hyperhive's claimed ranges (dashboard 7000, manager 8000, - sub-agents 8100..8999) so they don't collide. + sub-agents 8100..8999). Change this if you already have + another forgejo bound to 3000. ''; }; @@ -49,10 +53,10 @@ in default = "localhost"; example = "forge.internal"; description = '' - Hostname agents and operator dial the forge by. `localhost` is - fine for a single-host setup since containers share the host - netns; set a real hostname when you want clones from outside - the host to look canonical. + Hostname used in repo clone URLs the forge advertises. The + container shares host netns so `localhost` works for any + agent on the same host; set a real hostname when you want + clones from outside the host to look canonical. ''; }; @@ -60,44 +64,58 @@ in type = lib.types.bool; default = true; description = '' - Open `httpPort` + `sshPort` in the host firewall. Off when the - forge should only be reachable from inside the host (the - forge listens on all interfaces either way; this just gates - external access). + Open `httpPort` + `sshPort` in the host firewall. Off when + the forge should only be reachable from inside the host. + (The container shares host netns, so this is the only + firewall layer that matters.) ''; }; }; config = lib.mkIf cfg.enable { - services.forgejo = { - enable = true; - database.type = "sqlite3"; - lfs.enable = true; - settings = { - server = { - DOMAIN = cfg.domain; - ROOT_URL = "http://${cfg.domain}:${toString cfg.httpPort}/"; - HTTP_PORT = cfg.httpPort; - START_SSH_SERVER = true; - SSH_PORT = cfg.sshPort; - SSH_LISTEN_PORT = cfg.sshPort; - BUILTIN_SSH_SERVER_USER = "git"; - DISABLE_SSH = false; + containers.hive-forge = { + autoStart = true; + ephemeral = false; + # Share host netns — forgejo's HTTP / SSH listeners then look + # exactly like a host-side service, no port forwarding dance, + # and agent containers (which also share host netns) reach it + # via plain `localhost`. + privateNetwork = false; + config = + { pkgs, ... }: + { + system.stateVersion = "25.11"; + services.forgejo = { + enable = true; + database.type = "sqlite3"; + lfs.enable = true; + settings = { + server = { + DOMAIN = cfg.domain; + ROOT_URL = "http://${cfg.domain}:${toString cfg.httpPort}/"; + HTTP_PORT = cfg.httpPort; + START_SSH_SERVER = true; + SSH_PORT = cfg.sshPort; + SSH_LISTEN_PORT = cfg.sshPort; + BUILTIN_SSH_SERVER_USER = "git"; + DISABLE_SSH = false; + }; + # Registration off — operator seeds agent users via + # `nixos-container run hive-forge -- forgejo admin + # user create …`. + service = { + DISABLE_REGISTRATION = true; + REQUIRE_SIGNIN_VIEW = false; + }; + repository = { + DEFAULT_BRANCH = "main"; + DEFAULT_PRIVATE = "private"; + }; + log.LEVEL = "Warn"; + }; + }; + environment.systemPackages = [ pkgs.forgejo ]; }; - # Registration off — operator creates agent users via - # `forgejo admin user create` (or the API once seeded). - # Agents that need access get a dedicated account + token. - service = { - DISABLE_REGISTRATION = true; - REQUIRE_SIGNIN_VIEW = false; - }; - repository = { - DEFAULT_BRANCH = "main"; - DEFAULT_PRIVATE = "private"; - }; - # Quiet logs at idle; bump when debugging. - log.LEVEL = "Warn"; - }; }; networking.firewall = lib.mkIf cfg.openFirewall { @@ -106,11 +124,5 @@ in cfg.sshPort ]; }; - - # Convenience: drop the forgejo CLI on the host PATH so the - # operator can `forgejo admin user create …` without hunting for - # the wrapped binary. The forgejo service runs as its own user; - # admin commands need `sudo -u forgejo`. - environment.systemPackages = [ pkgs.forgejo ]; }; } From 480d646f691f0b2f2a6f29d28197066971485bb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Sat, 16 May 2026 20:55:13 +0200 Subject: [PATCH 3/7] forge: auto-create a user + token per agent on spawn / startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit new forge module probes the hive-forge nixos-container (no-op when absent), and ensures every agent + the manager has a forgejo user named after them with an access token at `/forge-token` (visible inside the container as `/state/forge-token`). idempotent: skips user creation when forgejo reports 'already exists', skips token issuance when the file is present, scopes the token to read:user,write:repository,write:issue. token-name suffixed with a clock so re-issuing doesn't collide with a stale name. shells out via `nixos-container run hive-forge -- runuser -u forgejo -- forgejo admin` (runuser instead of sudo since sudo isn't in the container by default). hooks: ensure_all sweeps existing containers at hive-c0re startup (backgrounded), and the actions.rs spawn task calls ensure_user_for the new agent right after lifecycle::spawn succeeds. failures log a warning but don't abort spawn — a missing token is recoverable from the next startup sweep. --- hive-c0re/src/actions.rs | 5 + hive-c0re/src/forge.rs | 198 +++++++++++++++++++++++++++++++++++++++ hive-c0re/src/main.rs | 8 ++ 3 files changed, 211 insertions(+) create mode 100644 hive-c0re/src/forge.rs diff --git a/hive-c0re/src/actions.rs b/hive-c0re/src/actions.rs index 67e15f91..1e6da8d3 100644 --- a/hive-c0re/src/actions.rs +++ b/hive-c0re/src/actions.rs @@ -77,6 +77,11 @@ 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 let Err(e) = finish_approval(&coord_bg, &approval_bg, result, None) { tracing::warn!(agent = %agent_bg, error = ?e, "spawn approval failed"); } diff --git a/hive-c0re/src/forge.rs b/hive-c0re/src/forge.rs new file mode 100644 index 00000000..e9b7db81 --- /dev/null +++ b/hive-c0re/src/forge.rs @@ -0,0 +1,198 @@ +//! Optional Forgejo wiring. When the `hive-forge` nixos-container is +//! present and running, hive-c0re ensures every agent (and the +//! manager) has a corresponding forgejo user with an API token +//! written to `/forge-token` — visible inside the +//! container as `/state/forge-token`. Idempotent: skips creation +//! when the user already exists, skips token issuance when the file +//! is already there. +//! +//! No-op when `hive-forge` isn't enabled (detected via +//! `nixos-container list`), so operators who don't run the bundled +//! forge pay nothing. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use tokio::process::Command; + +use crate::coordinator::Coordinator; + +const FORGE_CONTAINER: &str = "hive-forge"; +const TOKEN_NAME_PREFIX: &str = "hyperhive"; +/// Forgejo scopes the agent's token gets. `write:repository` covers +/// clone/push/repo-create on the user's own repos; `write:issue` is +/// what PRs and comments ride under; `read:user` is mandatory for +/// the token-owner endpoint clients use to introspect. +const TOKEN_SCOPES: &str = "read:user,write:repository,write:issue"; + +/// Token file inside the agent's bind-mounted state dir (visible as +/// `/state/forge-token` from inside the container). +fn token_path(name: &str) -> PathBuf { + Coordinator::agent_notes_dir(name).join("forge-token") +} + +/// Probe whether `hive-forge` exists as a nixos-container. Cheap — +/// `nixos-container list` is just a directory scan in /etc. +pub async fn is_present() -> bool { + let Ok(out) = Command::new("nixos-container") + .arg("list") + .output() + .await + else { + return false; + }; + if !out.status.success() { + return false; + } + String::from_utf8_lossy(&out.stdout) + .lines() + .any(|l| l.trim() == FORGE_CONTAINER) +} + +/// Run `forgejo admin ` inside the hive-forge container as the +/// forgejo user (the only uid with write access to the state dir). +/// Returns stdout on success; bails with stderr context on failure. +async fn forge_admin(args: &[&str]) -> Result { + let mut cmd = Command::new("nixos-container"); + // `runuser` (util-linux, always present in a NixOS container) + // beats `sudo` here — sudo isn't installed unless `security.sudo` + // is enabled, and we don't want to depend on that. + cmd.args(["run", FORGE_CONTAINER, "--", "runuser", "-u", "forgejo", "--", "forgejo", "admin"]); + cmd.args(args); + let out = cmd + .output() + .await + .context("invoke nixos-container run hive-forge -- forgejo admin")?; + if !out.status.success() { + anyhow::bail!( + "forgejo admin {} failed ({}): {}", + args.join(" "), + out.status, + String::from_utf8_lossy(&out.stderr).trim(), + ); + } + Ok(String::from_utf8_lossy(&out.stdout).into_owned()) +} + +/// Pull the access token out of forgejo's success message. Format +/// has shifted across versions (table form vs. "Access token was +/// successfully created: "), so just hunt the output for the +/// first long hex-looking word. +fn extract_token(output: &str) -> Option { + output + .split(|c: char| c.is_whitespace() || c == ',' || c == ':') + .find(|w| w.len() >= 32 && w.chars().all(|c| c.is_ascii_hexdigit())) + .map(str::to_owned) +} + +/// Ensure a forgejo user named `name` exists. Idempotent: forgejo +/// returns a "user already exists" error which we treat as success. +async fn ensure_user_exists(name: &str) -> Result<()> { + let result = forge_admin(&[ + "user", + "create", + "--username", + name, + "--email", + &format!("{name}@hive.local"), + "--random-password", + "--must-change-password=false", + ]) + .await; + match result { + Ok(_) => { + tracing::info!(%name, "forge: created user"); + Ok(()) + } + Err(e) => { + // Forgejo's "already exists" error wording varies; just + // try the next step and let token issuance surface a + // real failure if the user truly isn't there. + let msg = format!("{e:#}"); + if msg.contains("already exists") || msg.contains("user already") { + tracing::debug!(%name, "forge: user already exists"); + Ok(()) + } else { + tracing::warn!(%name, error = %msg, "forge: user create unclear; trying token anyway"); + Ok(()) + } + } + } +} + +/// Mint a fresh access token for `name` and persist it to +/// `/forge-token` (0600). Token name is suffixed with a +/// monotonic clock so re-issuing doesn't collide with an existing +/// token of the same name in the DB. +async fn mint_and_persist_token(name: &str, path: &Path) -> Result<()> { + let token_name = format!( + "{TOKEN_NAME_PREFIX}-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) + ); + let stdout = forge_admin(&[ + "user", + "generate-access-token", + "--username", + name, + "--token-name", + &token_name, + "--scopes", + TOKEN_SCOPES, + ]) + .await?; + let token = extract_token(&stdout) + .with_context(|| format!("parse token from forgejo output: {stdout:?}"))?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).ok(); + } + std::fs::write(path, format!("{token}\n")) + .with_context(|| format!("write token to {}", path.display()))?; + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); + tracing::info!(%name, path = %path.display(), %token_name, "forge: persisted access token"); + Ok(()) +} + +/// Ensure `name` has a forgejo user + token file. No-op when the +/// token file is already present. Safe to call on every spawn and +/// on every hive-c0re startup. +pub async fn ensure_user_for(name: &str) -> Result<()> { + if !is_present().await { + return Ok(()); + } + let path = token_path(name); + if path.exists() { + return Ok(()); + } + ensure_user_exists(name).await?; + mint_and_persist_token(name, &path).await +} + +/// Sweep every existing container (manager + sub-agents) and ensure +/// each has a forgejo user + token. Called once at hive-c0re +/// startup. Per-agent failures are logged but don't abort the sweep. +pub async fn ensure_all() { + if !is_present().await { + tracing::debug!("forge: hive-forge container absent, skipping user sweep"); + return; + } + let Ok(containers) = crate::lifecycle::list().await else { + tracing::warn!("forge: nixos-container list failed; skipping user sweep"); + return; + }; + for c in containers { + let name = if c == crate::lifecycle::MANAGER_NAME { + c + } else if let Some(n) = c.strip_prefix(crate::lifecycle::AGENT_PREFIX) { + n.to_owned() + } else { + continue; + }; + if let Err(e) = ensure_user_for(&name).await { + tracing::warn!(%name, error = ?e, "forge: ensure_user failed"); + } + } +} diff --git a/hive-c0re/src/main.rs b/hive-c0re/src/main.rs index 8d036822..3ebbabee 100644 --- a/hive-c0re/src/main.rs +++ b/hive-c0re/src/main.rs @@ -15,6 +15,7 @@ mod coordinator; mod crash_watch; mod dashboard; mod events_vacuum; +mod forge; mod lifecycle; mod manager_server; mod meta; @@ -134,6 +135,13 @@ async fn main() -> Result<()> { tracing::warn!(error = ?e, "auto-update task failed"); } }); + // Forge user sweep: ensure every existing container has a + // forgejo user + access token. No-op when the hive-forge + // container isn't running. Backgrounded — touches the + // forge state dir via `nixos-container run` which is slow. + tokio::spawn(async move { + forge::ensure_all().await; + }); // Periodic broker vacuum: drop delivered messages older than // 30 days. Undelivered messages are always kept (still in // flight). Runs hourly; first sweep happens immediately. From dccbd99b0c3bd387aa56db8e506c095fc07391de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Sat, 16 May 2026 20:58:20 +0200 Subject: [PATCH 4/7] forge: broaden token scopes for repo create / PRs / orgs / misc bumped from (read:user,write:repository,write:issue) to also include write:user (own profile + create repos under own namespace), write:organization (share namespaces between agents), write:misc (hooks/attachments). still excludes admin and package scopes. --- hive-c0re/src/forge.rs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/hive-c0re/src/forge.rs b/hive-c0re/src/forge.rs index e9b7db81..3014dce2 100644 --- a/hive-c0re/src/forge.rs +++ b/hive-c0re/src/forge.rs @@ -19,11 +19,20 @@ use crate::coordinator::Coordinator; const FORGE_CONTAINER: &str = "hive-forge"; const TOKEN_NAME_PREFIX: &str = "hyperhive"; -/// Forgejo scopes the agent's token gets. `write:repository` covers -/// clone/push/repo-create on the user's own repos; `write:issue` is -/// what PRs and comments ride under; `read:user` is mandatory for -/// the token-owner endpoint clients use to introspect. -const TOKEN_SCOPES: &str = "read:user,write:repository,write:issue"; +/// 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. +/// - `write:repository` — create, clone, push, delete repos in the +/// user's own namespace; merge PRs. +/// - `write:issue` — open / comment / review issues *and* pull +/// requests (forgejo namespaces PR conversation under issues). +/// - `write:user` — edit own profile, create repos under own user. +/// - `write:organization` — create + manage orgs (lets agents share +/// a forge namespace). +/// - `read:user` — token-owner endpoint clients call to introspect. +/// - `write:misc` — hooks, attachments, the rest of the long tail. +const TOKEN_SCOPES: &str = + "read:user,write:user,write:repository,write:issue,write:organization,write:misc"; /// Token file inside the agent's bind-mounted state dir (visible as /// `/state/forge-token` from inside the container). From 787c058c71f9fb30363697b19820a266a1ce8f2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Sat, 16 May 2026 23:35:28 +0200 Subject: [PATCH 5/7] harness: install tea + auto-login from /state/forge-token agents get `pkgs.tea` (gitea/forgejo CLI) and a tea-login oneshot that runs `tea login add --url --token $(cat /state/forge-token)` before the harness starts. idempotent: exits 0 when the token file is absent (hive-forge not on) or when ~/.config/tea/config.yml already exists. new `hyperhive.forge.url` option (default http://localhost:3000) so operators can point at a non-default forge port. claude can now shell out to `tea repos create`, `tea pulls create`, etc. --- nix/templates/harness-base.nix | 53 ++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/nix/templates/harness-base.nix b/nix/templates/harness-base.nix index c9b4b267..e6cf7aa6 100644 --- a/nix/templates/harness-base.nix +++ b/nix/templates/harness-base.nix @@ -82,6 +82,20 @@ ''; }; + options.hyperhive.forge.url = lib.mkOption { + type = lib.types.str; + default = "http://localhost:3000"; + example = "http://forge.internal:3000"; + description = '' + Base URL of the hyperhive-managed Forgejo. Used at container + boot by a oneshot systemd unit that calls + `tea login add --url --token "$(cat /state/forge-token)"` + so the agent's claude can shell out to `tea` without an extra + auth dance. No-op when `/state/forge-token` is missing (i.e. + hive-forge isn't running on the host). + ''; + }; + options.hyperhive.claudePlugins = lib.mkOption { type = lib.types.listOf lib.types.str; default = [ ]; @@ -124,8 +138,47 @@ # procps for pkill — used by the web UI's /api/cancel to SIGINT the # in-flight claude turn. procps + # tea: gitea/forgejo CLI client. Configured at boot by the + # tea-login oneshot below if /state/forge-token is present, so + # claude can `tea repos create`, `tea pulls create`, etc. + tea ]; + # One-shot: configure tea with the agent's forge token if + # hive-c0re seeded one and tea hasn't been configured yet. + # Runs before the harness service so the first turn can already + # `tea repos create`. Idempotent — exits 0 if config already + # exists, exits 0 if no token file (hive-forge not enabled). + systemd.services.tea-login = { + description = "configure tea CLI from /state/forge-token"; + wantedBy = [ "multi-user.target" ]; + after = [ "local-fs.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + path = [ pkgs.tea pkgs.coreutils ]; + script = '' + set -eu + TOKEN_FILE=/state/forge-token + CONFIG=/root/.config/tea/config.yml + if [ ! -f "$TOKEN_FILE" ]; then + echo "tea-login: no $TOKEN_FILE (hive-forge not seeded); skipping" + exit 0 + fi + if [ -f "$CONFIG" ]; then + echo "tea-login: $CONFIG already present; skipping" + exit 0 + fi + mkdir -p "$(dirname "$CONFIG")" + tea login add \ + --name forge \ + --url ${lib.escapeShellArg config.hyperhive.forge.url} \ + --token "$(cat "$TOKEN_FILE")" + echo "tea-login: configured for ${config.hyperhive.forge.url}" + ''; + }; + # Git is needed by claude's Bash tool (for the agent <-> manager config # request flow) and by hive-c0re's own setup_applied / setup_proposed. # The per-agent `applied//flake.nix` overrides `user.name` and From 9fc7cae132269c26fb53cd06f6df4b7a98f81386 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Sat, 16 May 2026 23:36:05 +0200 Subject: [PATCH 6/7] prompts: tell agents + manager about the code forge; todo: shared docs repo system prompts now describe the hyperhive Forgejo at localhost:3000, the per-agent user, the pre-configured tea CLI, and the REST API fallback with /state/forge-token. todo gains the shared docs/skills RO-repo follow-up (org-shared + per-agent read membership). --- TODO.md | 1 + hive-ag3nt/prompts/agent.md | 2 ++ hive-ag3nt/prompts/manager.md | 2 ++ 3 files changed, 5 insertions(+) diff --git a/TODO.md b/TODO.md index 3634237b..6e3d90b1 100644 --- a/TODO.md +++ b/TODO.md @@ -7,6 +7,7 @@ - Move bind mounts in agents to `/agents//state` so path for agent = path for manager - **Broadcast messaging**: allow sending messages with recipient "*" to all agents; deliver with hint "this was a broadcast and may not need any action from you" - **Multi-agent restart coordination**: when rebuilding all agents, manager should start first so it can coordinate post-restart confusion (notify agents, suppress unnecessary retries, etc) +- **Shared docs/skills repo (RO)**: a single repo on the hive forge that every agent has read-only access to — common references, prompts, runbooks, "skills" the operator wants every agent to inherit without baking into the system prompt or `/shared`. Implementation likely: seed an `org-shared/docs` repo on first hive-forge boot, grant every per-agent user a read membership in the org. Agents `git clone` it (or use the API) to read; only the manager + operator can push. ## Reminder Tool diff --git a/hive-ag3nt/prompts/agent.md b/hive-ag3nt/prompts/agent.md index 1f3d0330..680ea92b 100644 --- a/hive-ag3nt/prompts/agent.md +++ b/hive-ag3nt/prompts/agent.md @@ -15,6 +15,8 @@ Claude session (OAuth credentials) lives at `/root/.claude/` and persists across **Shared space**: `/shared` is accessible to all agents (read/write). Only put things here you're willing to lose — other agents may delete them. Use for explicit cross-agent communication or shared artifacts when appropriate. +**Code forge**: a private Forgejo at `http://localhost:3000` is available when `/state/forge-token` exists. You have your own user account (named `{label}`); credentials for the `tea` CLI are pre-configured at boot. Use `tea repos create`, `tea pulls create --base main --head `, `tea pulls list`, `tea issues create`, etc. for any persistent code work — git repos that should outlive a single turn, code you want a peer or the operator to review, anything you'd otherwise jam into `/shared`. Falls back to plain `git`/`curl` if `tea` doesn't fit; the REST API is at `http://localhost:3000/api/v1/` with the same token (`Authorization: token $(cat /state/forge-token)`). + Keep messages short — a few sentences each. For anything big (file listings, long diffs, transcripts, analysis): write the payload to `/agents/{label}/state/` and `send` a short pointer ("dropped the cluster audit in /agents/{label}/state/cluster-audit-2026-05.md, headline: 3 nodes over 80% mem"). The manager + operator can read your state from the host as `/agents/{label}/state/`. Sub-agent peers can't read each other's state directly — go through the manager if a payload needs to reach another sub-agent. When your inbox has a message, handle it and stop. Don't narrate intent — act. diff --git a/hive-ag3nt/prompts/manager.md b/hive-ag3nt/prompts/manager.md index f65e106f..b7ae7394 100644 --- a/hive-ag3nt/prompts/manager.md +++ b/hive-ag3nt/prompts/manager.md @@ -83,6 +83,8 @@ Keep messages short — a few sentences each. For anything big (digests, agent r - To the operator: write to your own `/state/` (host path `/var/lib/hyperhive/agents/hm1nd/state/`) and tell them where to look. - For shared artifacts (coordination, common reference data): write to `/shared/`. Only put things here you're willing to lose — other agents may delete them. +**Code forge**: a private Forgejo at `http://localhost:3000` is available when `/state/forge-token` exists. You have your own user (`hm1nd`) and so does every sub-agent (one per name). The `tea` CLI is pre-configured at boot. Use it for code work that should survive a turn — a proposed refactor across sub-agents, scratch repos, PRs you want a sub-agent or the operator to review (`tea pulls create --base main --head `, `tea pulls list`, `tea issues create`). REST API at `http://localhost:3000/api/v1/` with `Authorization: token $(cat /state/forge-token)` for anything `tea` can't express. + A one-line headline + the file path beats a wall-of-text every time — it survives context compaction and the operator can read it in their own time. When your inbox has a message, handle it and stop. Don't narrate intent — act. From 4a06615c5c9251c15c1d8931887fa66fc69bb056 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Sat, 16 May 2026 23:37:49 +0200 Subject: [PATCH 7/7] fix /state paths: sub-agents use /agents//state, not /state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sub-agent containers post-refactor bind their state at /agents//state (manager keeps the legacy /state — see lifecycle.rs:751). agent.md still said /state/forge-token; corrected to /agents/{label}/state/forge-token (template-substituted at boot). tea-login systemd unit now walks both candidates so the same harness module works for the manager and sub-agents. --- hive-ag3nt/prompts/agent.md | 2 +- nix/templates/harness-base.nix | 16 ++++++++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/hive-ag3nt/prompts/agent.md b/hive-ag3nt/prompts/agent.md index 680ea92b..dbbf5006 100644 --- a/hive-ag3nt/prompts/agent.md +++ b/hive-ag3nt/prompts/agent.md @@ -15,7 +15,7 @@ Claude session (OAuth credentials) lives at `/root/.claude/` and persists across **Shared space**: `/shared` is accessible to all agents (read/write). Only put things here you're willing to lose — other agents may delete them. Use for explicit cross-agent communication or shared artifacts when appropriate. -**Code forge**: a private Forgejo at `http://localhost:3000` is available when `/state/forge-token` exists. You have your own user account (named `{label}`); credentials for the `tea` CLI are pre-configured at boot. Use `tea repos create`, `tea pulls create --base main --head `, `tea pulls list`, `tea issues create`, etc. for any persistent code work — git repos that should outlive a single turn, code you want a peer or the operator to review, anything you'd otherwise jam into `/shared`. Falls back to plain `git`/`curl` if `tea` doesn't fit; the REST API is at `http://localhost:3000/api/v1/` with the same token (`Authorization: token $(cat /state/forge-token)`). +**Code forge**: a private Forgejo at `http://localhost:3000` is available when `/agents/{label}/state/forge-token` exists. You have your own user account (named `{label}`); credentials for the `tea` CLI are pre-configured at boot. Use `tea repos create`, `tea pulls create --base main --head `, `tea pulls list`, `tea issues create`, etc. for any persistent code work — git repos that should outlive a single turn, code you want a peer or the operator to review, anything you'd otherwise jam into `/shared`. Falls back to plain `git`/`curl` if `tea` doesn't fit; the REST API is at `http://localhost:3000/api/v1/` with the same token (`Authorization: token $(cat /agents/{label}/state/forge-token)`). Keep messages short — a few sentences each. For anything big (file listings, long diffs, transcripts, analysis): write the payload to `/agents/{label}/state/` and `send` a short pointer ("dropped the cluster audit in /agents/{label}/state/cluster-audit-2026-05.md, headline: 3 nodes over 80% mem"). The manager + operator can read your state from the host as `/agents/{label}/state/`. Sub-agent peers can't read each other's state directly — go through the manager if a payload needs to reach another sub-agent. diff --git a/nix/templates/harness-base.nix b/nix/templates/harness-base.nix index e6cf7aa6..cd23ae4b 100644 --- a/nix/templates/harness-base.nix +++ b/nix/templates/harness-base.nix @@ -160,10 +160,18 @@ path = [ pkgs.tea pkgs.coreutils ]; script = '' set -eu - TOKEN_FILE=/state/forge-token CONFIG=/root/.config/tea/config.yml - if [ ! -f "$TOKEN_FILE" ]; then - echo "tea-login: no $TOKEN_FILE (hive-forge not seeded); skipping" + # Manager keeps the legacy /state bind; sub-agents have + # /agents//state. Glob covers both — there's exactly one + # hit either way (manager: /state, sub-agent: its own + # /agents/* mount), since each container only sees its own + # state dir. + TOKEN_FILE="" + for f in /state/forge-token /agents/*/state/forge-token; do + [ -f "$f" ] && TOKEN_FILE="$f" && break + done + if [ -z "$TOKEN_FILE" ]; then + echo "tea-login: no forge-token (hive-forge not seeded); skipping" exit 0 fi if [ -f "$CONFIG" ]; then @@ -175,7 +183,7 @@ --name forge \ --url ${lib.escapeShellArg config.hyperhive.forge.url} \ --token "$(cat "$TOKEN_FILE")" - echo "tea-login: configured for ${config.hyperhive.forge.url}" + echo "tea-login: configured for ${config.hyperhive.forge.url} from $TOKEN_FILE" ''; };