diff --git a/TODO.md b/TODO.md index 6e3d90b1..3634237b 100644 --- a/TODO.md +++ b/TODO.md @@ -7,7 +7,6 @@ - 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/flake.nix b/flake.nix index aefc28c3..ff7f31ab 100644 --- a/flake.nix +++ b/flake.nix @@ -98,7 +98,6 @@ hyperhivePackage = system: self.packages.${system}.default; hyperhiveFlake = "${self}"; }; - hive-forge = ./nix/modules/hive-forge.nix; }; nixosConfigurations = diff --git a/hive-ag3nt/prompts/agent.md b/hive-ag3nt/prompts/agent.md index dbbf5006..1f3d0330 100644 --- a/hive-ag3nt/prompts/agent.md +++ b/hive-ag3nt/prompts/agent.md @@ -15,8 +15,6 @@ 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 `/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. 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 b7ae7394..f65e106f 100644 --- a/hive-ag3nt/prompts/manager.md +++ b/hive-ag3nt/prompts/manager.md @@ -83,8 +83,6 @@ 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. diff --git a/hive-c0re/src/actions.rs b/hive-c0re/src/actions.rs index 1e6da8d3..67e15f91 100644 --- a/hive-c0re/src/actions.rs +++ b/hive-c0re/src/actions.rs @@ -77,11 +77,6 @@ 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 deleted file mode 100644 index 3014dce2..00000000 --- a/hive-c0re/src/forge.rs +++ /dev/null @@ -1,207 +0,0 @@ -//! 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. 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). -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 3ebbabee..8d036822 100644 --- a/hive-c0re/src/main.rs +++ b/hive-c0re/src/main.rs @@ -15,7 +15,6 @@ mod coordinator; mod crash_watch; mod dashboard; mod events_vacuum; -mod forge; mod lifecycle; mod manager_server; mod meta; @@ -135,13 +134,6 @@ 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. diff --git a/nix/modules/hive-forge.nix b/nix/modules/hive-forge.nix deleted file mode 100644 index b35ea33d..00000000 --- a/nix/modules/hive-forge.nix +++ /dev/null @@ -1,128 +0,0 @@ -{ - pkgs, - lib, - config, - ... -}: -let - cfg = config.services.hive-forge; -in -{ - # 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. - # - # 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 (in a nixos-container) 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). Change this if you already have - another forgejo bound to 3000. - ''; - }; - - 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 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. - ''; - }; - - 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 container shares host netns, so this is the only - firewall layer that matters.) - ''; - }; - }; - - config = lib.mkIf cfg.enable { - 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 ]; - }; - }; - - networking.firewall = lib.mkIf cfg.openFirewall { - allowedTCPPorts = [ - cfg.httpPort - cfg.sshPort - ]; - }; - }; -} diff --git a/nix/templates/harness-base.nix b/nix/templates/harness-base.nix index cd23ae4b..c9b4b267 100644 --- a/nix/templates/harness-base.nix +++ b/nix/templates/harness-base.nix @@ -82,20 +82,6 @@ ''; }; - 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 = [ ]; @@ -138,55 +124,8 @@ # 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 - CONFIG=/root/.config/tea/config.yml - # 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 - 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} from $TOKEN_FILE" - ''; - }; - # 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