From e1289a3e4c94ba4cd9dc525b8cc1310bd16695ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Fri, 15 May 2026 16:10:55 +0200 Subject: [PATCH] nix templates: factor harness-base.nix (shared scaffolding incl. gitconfig) --- hive-ag3nt/src/bin/hive-ag3nt.rs | 11 +++- hive-ag3nt/src/bin/hive-m1nd.rs | 7 +- hive-ag3nt/src/mcp.rs | 110 +++++++++++++++++-------------- hive-ag3nt/src/web_ui.rs | 11 ++-- hive-c0re/src/actions.rs | 10 ++- hive-c0re/src/auto_update.rs | 1 - hive-c0re/src/lifecycle.rs | 6 +- hive-sh4re/src/lib.rs | 4 +- nix/templates/agent-base.nix | 23 ++----- nix/templates/harness-base.nix | 35 ++++++++++ nix/templates/manager.nix | 32 ++------- 11 files changed, 137 insertions(+), 113 deletions(-) create mode 100644 nix/templates/harness-base.nix diff --git a/hive-ag3nt/src/bin/hive-ag3nt.rs b/hive-ag3nt/src/bin/hive-ag3nt.rs index fb993c3..e421780 100644 --- a/hive-ag3nt/src/bin/hive-ag3nt.rs +++ b/hive-ag3nt/src/bin/hive-ag3nt.rs @@ -80,7 +80,13 @@ async fn main() -> Result<()> { }); match initial { LoginState::Online => { - serve(&cli.socket, Duration::from_millis(poll_ms), login_state, bus).await + serve( + &cli.socket, + Duration::from_millis(poll_ms), + login_state, + bus, + ) + .await } LoginState::NeedsLogin => { // Partial-run mode: keep the harness alive (so the web UI @@ -152,8 +158,7 @@ async fn serve( body: body.clone(), }); let prompt = format_wake_prompt(&label, &from, &body); - let outcome = - drive_turn(&prompt, &mcp_config, &bus, mcp::Flavor::Agent).await; + let outcome = drive_turn(&prompt, &mcp_config, &bus, mcp::Flavor::Agent).await; emit_turn_end(&bus, &outcome); } Ok(AgentResponse::Empty) => {} diff --git a/hive-ag3nt/src/bin/hive-m1nd.rs b/hive-ag3nt/src/bin/hive-m1nd.rs index eb2035a..a4c2634 100644 --- a/hive-ag3nt/src/bin/hive-m1nd.rs +++ b/hive-ag3nt/src/bin/hive-m1nd.rs @@ -90,9 +90,7 @@ async fn main() -> Result<()> { } }); match initial { - LoginState::Online => { - serve(&cli.socket, Duration::from_millis(poll_ms), bus).await - } + LoginState::Online => serve(&cli.socket, Duration::from_millis(poll_ms), bus).await, LoginState::NeedsLogin => { tracing::warn!( claude_dir = %claude_dir.display(), @@ -174,8 +172,7 @@ async fn serve(socket: &Path, interval: Duration, bus: Bus) -> Result<()> { body: body.clone(), }); let prompt = format_wake_prompt(&label, &from, &body); - let outcome = - drive_turn(&prompt, &mcp_config, &bus, mcp::Flavor::Manager).await; + let outcome = drive_turn(&prompt, &mcp_config, &bus, mcp::Flavor::Manager).await; emit_turn_end(&bus, &outcome); } Ok(ManagerResponse::Empty) => {} diff --git a/hive-ag3nt/src/mcp.rs b/hive-ag3nt/src/mcp.rs index f73040f..6d3a653 100644 --- a/hive-ag3nt/src/mcp.rs +++ b/hive-ag3nt/src/mcp.rs @@ -21,10 +21,8 @@ use std::path::PathBuf; use anyhow::Result; use rmcp::{ - ServerHandler, ServiceExt, - handler::server::wrapper::Parameters, - schemars, tool, tool_handler, tool_router, - transport::stdio, + ServerHandler, ServiceExt, handler::server::wrapper::Parameters, schemars, tool, tool_handler, + tool_router, transport::stdio, }; use crate::client; @@ -33,12 +31,7 @@ use crate::client; /// a status line → post-log. Free function so both `AgentServer` and /// `ManagerServer` use the same shape; the per-server `status_line` /// closure is what differs (different `Status` wire types). -pub async fn run_tool_envelope( - tool: &'static str, - args: String, - status: S, - body: F, -) -> String +pub async fn run_tool_envelope(tool: &'static str, args: String, status: S, body: F) -> String where F: Future, S: Future, @@ -223,8 +216,10 @@ impl ManagerServer { #[tool_router] impl ManagerServer { - #[tool(description = "Send a message to a sub-agent (by logical name), to another agent, \ - or to the operator (recipient `operator`, surfaces in the dashboard).")] + #[tool( + description = "Send a message to a sub-agent (by logical name), to another agent, \ + or to the operator (recipient `operator`, surfaces in the dashboard)." + )] async fn send(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); let to = args.to.clone(); @@ -245,8 +240,10 @@ impl ManagerServer { .await } - #[tool(description = "Pop one message from the manager inbox. Returns sender + body, or \ - empty.")] + #[tool( + description = "Pop one message from the manager inbox. Returns sender + body, or \ + empty." + )] async fn recv(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); run_tool_envelope("recv", log, self.status_line(), async move { @@ -256,7 +253,9 @@ impl ManagerServer { format!("from: {from}\n\n{body}") } Ok(hive_sh4re::ManagerResponse::Empty) => "(empty)".into(), - Ok(hive_sh4re::ManagerResponse::Err { message }) => format!("recv failed: {message}"), + Ok(hive_sh4re::ManagerResponse::Err { message }) => { + format!("recv failed: {message}") + } Ok(other) => format!("recv unexpected response: {other:?}"), Err(e) => format!("recv transport error: {e:#}"), } @@ -264,8 +263,10 @@ impl ManagerServer { .await } - #[tool(description = "Queue a Spawn approval for a brand-new sub-agent. The operator \ - approves on the dashboard before the container is actually created.")] + #[tool( + description = "Queue a Spawn approval for a brand-new sub-agent. The operator \ + approves on the dashboard before the container is actually created." + )] async fn request_spawn(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); let name = args.name.clone(); @@ -283,8 +284,10 @@ impl ManagerServer { .await } - #[tool(description = "Stop a sub-agent container (graceful). The state dir is kept; \ - recreating reuses prior config + Claude credentials.")] + #[tool( + description = "Stop a sub-agent container (graceful). The state dir is kept; \ + recreating reuses prior config + Claude credentials." + )] async fn kill(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); let name = args.name.clone(); @@ -292,7 +295,9 @@ impl ManagerServer { let req = hive_sh4re::ManagerRequest::Kill { name: args.name }; match client::request::<_, hive_sh4re::ManagerResponse>(&self.socket, &req).await { Ok(hive_sh4re::ManagerResponse::Ok) => format!("killed {name}"), - Ok(hive_sh4re::ManagerResponse::Err { message }) => format!("kill failed: {message}"), + Ok(hive_sh4re::ManagerResponse::Err { message }) => { + format!("kill failed: {message}") + } Ok(other) => format!("kill unexpected response: {other:?}"), Err(e) => format!("kill transport error: {e:#}"), } @@ -300,9 +305,11 @@ impl ManagerServer { .await } - #[tool(description = "Submit a config change for operator approval. Pass the agent name \ + #[tool( + description = "Submit a config change for operator approval. Pass the agent name \ (e.g. `alice` or `hm1nd` for the manager's own config) and a commit sha in that \ - agent's proposed config repo. On approval hive-c0re rebuilds the container.")] + agent's proposed config repo. On approval hive-c0re rebuilds the container." + )] async fn request_apply_commit( &self, Parameters(args): Parameters, @@ -310,22 +317,27 @@ impl ManagerServer { let log = format!("{args:?}"); let agent = args.agent.clone(); let commit_ref = args.commit_ref.clone(); - run_tool_envelope("request_apply_commit", log, self.status_line(), async move { - let req = hive_sh4re::ManagerRequest::RequestApplyCommit { - agent: args.agent, - commit_ref: args.commit_ref, - }; - match client::request::<_, hive_sh4re::ManagerResponse>(&self.socket, &req).await { - Ok(hive_sh4re::ManagerResponse::Ok) => { - format!("apply approval queued for {agent} @ {commit_ref}") + run_tool_envelope( + "request_apply_commit", + log, + self.status_line(), + async move { + let req = hive_sh4re::ManagerRequest::RequestApplyCommit { + agent: args.agent, + commit_ref: args.commit_ref, + }; + match client::request::<_, hive_sh4re::ManagerResponse>(&self.socket, &req).await { + Ok(hive_sh4re::ManagerResponse::Ok) => { + format!("apply approval queued for {agent} @ {commit_ref}") + } + Ok(hive_sh4re::ManagerResponse::Err { message }) => { + format!("request_apply_commit failed: {message}") + } + Ok(other) => format!("request_apply_commit unexpected response: {other:?}"), + Err(e) => format!("request_apply_commit transport error: {e:#}"), } - Ok(hive_sh4re::ManagerResponse::Err { message }) => { - format!("request_apply_commit failed: {message}") - } - Ok(other) => format!("request_apply_commit unexpected response: {other:?}"), - Err(e) => format!("request_apply_commit transport error: {e:#}"), - } - }) + }, + ) .await } } @@ -350,15 +362,8 @@ pub const SERVER_NAME: &str = "hyperhive"; /// (`Task`) are intentionally omitted for now; `Bash` is allowed pending a /// finer-grained allow-list system for shell command patterns. Edit later /// as our trust model evolves. -pub const ALLOWED_BUILTIN_TOOLS: &[&str] = &[ - "Bash", - "Edit", - "Glob", - "Grep", - "Read", - "TodoWrite", - "Write", -]; +pub const ALLOWED_BUILTIN_TOOLS: &[&str] = + &["Bash", "Edit", "Glob", "Grep", "Read", "TodoWrite", "Write"]; /// Which MCP tool surface to advertise via `--allowedTools`. The agent /// list is the strict subset of the manager list, so we just thread the @@ -376,7 +381,13 @@ pub enum Flavor { pub fn allowed_mcp_tools(flavor: Flavor) -> Vec { let names: &[&str] = match flavor { Flavor::Agent => &["send", "recv"], - Flavor::Manager => &["send", "recv", "request_spawn", "kill", "request_apply_commit"], + Flavor::Manager => &[ + "send", + "recv", + "request_spawn", + "kill", + "request_apply_commit", + ], }; names .iter() @@ -388,7 +399,10 @@ pub fn allowed_mcp_tools(flavor: Flavor) -> Vec { /// both the built-ins and the MCP surface. #[must_use] pub fn allowed_tools_arg(flavor: Flavor) -> String { - let mut all: Vec = ALLOWED_BUILTIN_TOOLS.iter().map(|s| (*s).to_owned()).collect(); + let mut all: Vec = ALLOWED_BUILTIN_TOOLS + .iter() + .map(|s| (*s).to_owned()) + .collect(); all.extend(allowed_mcp_tools(flavor)); all.join(",") } diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index 1997265..d6cd09f 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -323,9 +323,9 @@ async fn events_stream( let rx = state.bus.subscribe(); // Drop a "hello" note into the bus so every new subscriber sees at // least one event immediately and can clear the connecting placeholder. - state - .bus - .emit(crate::events::LiveEvent::Note("live stream attached".into())); + state.bus.emit(crate::events::LiveEvent::Note( + "live stream attached".into(), + )); let stream = BroadcastStream::new(rx).filter_map(|res| { let ev = res.ok()?; let json = serde_json::to_string(&ev).ok()?; @@ -356,10 +356,7 @@ struct CodeForm { code: String, } -async fn post_login_code( - State(state): State, - Form(form): Form, -) -> Response { +async fn post_login_code(State(state): State, Form(form): Form) -> Response { let session = state.session.lock().unwrap().clone(); let Some(session) = session else { return error_response("no login session running"); diff --git a/hive-c0re/src/actions.rs b/hive-c0re/src/actions.rs index 5b0dd7c..e2cfd36 100644 --- a/hive-c0re/src/actions.rs +++ b/hive-c0re/src/actions.rs @@ -6,7 +6,9 @@ use std::sync::Arc; use anyhow::{Result, bail}; -use hive_sh4re::{ApprovalKind, ApprovalStatus, HelperEvent, MANAGER_AGENT, Message, SYSTEM_SENDER}; +use hive_sh4re::{ + ApprovalKind, ApprovalStatus, HelperEvent, MANAGER_AGENT, Message, SYSTEM_SENDER, +}; use crate::coordinator::{Coordinator, TransientKind}; use crate::lifecycle::{self, MANAGER_NAME}; @@ -83,7 +85,11 @@ pub async fn approve(coord: Arc, id: i64) -> Result<()> { } } -fn finish_approval(coord: &Coordinator, approval: &hive_sh4re::Approval, result: Result<()>) -> Result<()> { +fn finish_approval( + coord: &Coordinator, + approval: &hive_sh4re::Approval, + result: Result<()>, +) -> Result<()> { match result { Ok(()) => { notify_manager( diff --git a/hive-c0re/src/auto_update.rs b/hive-c0re/src/auto_update.rs index bfcc464..646609d 100644 --- a/hive-c0re/src/auto_update.rs +++ b/hive-c0re/src/auto_update.rs @@ -72,7 +72,6 @@ pub async fn rebuild_agent(coord: &Arc, name: &str, current_rev: &s Ok(()) } - /// Auto-create the manager container on startup if it isn't already there. /// hive-c0re manages hm1nd end-to-end (Phase 8 follow-up): operators no /// longer declare `containers.hm1nd` in their host NixOS config. Bypasses diff --git a/hive-c0re/src/lifecycle.rs b/hive-c0re/src/lifecycle.rs index 3a5337b..69e3f20 100644 --- a/hive-c0re/src/lifecycle.rs +++ b/hive-c0re/src/lifecycle.rs @@ -75,7 +75,11 @@ pub fn is_manager(name: &str) -> bool { /// extends. Manager → `manager`; everyone else → `agent-base`. #[must_use] pub fn flake_base(name: &str) -> &'static str { - if is_manager(name) { "manager" } else { "agent-base" } + if is_manager(name) { + "manager" + } else { + "agent-base" + } } fn validate(name: &str) -> Result<()> { diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index 2d13954..a5a534d 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -218,7 +218,9 @@ pub enum ManagerRequest { Status, /// Operator-injected message TO the manager (from the manager's own web /// UI). Same shape as `AgentRequest::OperatorMsg`. - OperatorMsg { body: String }, + OperatorMsg { + body: String, + }, /// Submit a spawn request for the user to approve. On approval the host /// creates and starts the container. Brand-new agent names only — if an /// agent of the same name already exists, the approval will fail. diff --git a/nix/templates/agent-base.nix b/nix/templates/agent-base.nix index 4683023..420e129 100644 --- a/nix/templates/agent-base.nix +++ b/nix/templates/agent-base.nix @@ -1,27 +1,14 @@ { pkgs, ... }: { - boot.isNspawnContainer = true; - - nixpkgs.config.allowUnfreePredicate = pkg: builtins.elem (pkgs.lib.getName pkg) [ "claude-code" ]; - - environment.systemPackages = with pkgs; [ - hyperhive - claude-code - bashInteractive - git - coreutils-full - ]; - # claude's Bash tool refuses to run without a POSIX shell + $SHELL set. - environment.variables.SHELL = "${pkgs.bashInteractive}/bin/bash"; + imports = [ ./harness-base.nix ]; systemd.services.hive-ag3nt = { description = "hive-ag3nt harness"; wantedBy = [ "multi-user.target" ]; after = [ "network.target" ]; - # The harness shells out to `claude` (turn loop + login flow). systemd - # units get a minimal PATH by default, so we have to put claude-code on - # it explicitly even though it's in environment.systemPackages above. - # bash is on PATH so claude's Bash tool can spawn `$SHELL`. + # `claude` for the turn loop + `bash` for claude's Bash tool. systemd + # units get a minimal PATH by default; entries in + # `environment.systemPackages` aren't on it. path = [ pkgs.claude-code pkgs.bashInteractive @@ -33,6 +20,4 @@ RestartSec = 2; }; }; - - system.stateVersion = "25.11"; } diff --git a/nix/templates/harness-base.nix b/nix/templates/harness-base.nix new file mode 100644 index 0000000..2804215 --- /dev/null +++ b/nix/templates/harness-base.nix @@ -0,0 +1,35 @@ +{ pkgs, ... }: +{ + # Shared scaffolding for any hyperhive harness container — both + # sub-agents (`agent-base.nix`) and the manager (`manager.nix`) extend + # this. The systemd service that actually runs the harness binary + # differs per role and lives in the child module. + + boot.isNspawnContainer = true; + + nixpkgs.config.allowUnfreePredicate = pkg: builtins.elem (pkgs.lib.getName pkg) [ "claude-code" ]; + + environment.systemPackages = with pkgs; [ + hyperhive + claude-code + bashInteractive + git + coreutils-full + ]; + # claude's Bash tool refuses to run without a POSIX shell + $SHELL set. + environment.variables.SHELL = "${pkgs.bashInteractive}/bin/bash"; + + # Default gitconfig for any commits the harness makes. The per-agent + # `applied//flake.nix` overrides this with the agent's own name + + # email; this fallback only kicks in if the container is built straight + # from `agent-base` / `manager` without the per-agent extension. + environment.etc."gitconfig".text = '' + [user] + name = hyperhive + email = hyperhive@local + [init] + defaultBranch = main + ''; + + system.stateVersion = "25.11"; +} diff --git a/nix/templates/manager.nix b/nix/templates/manager.nix index 0859e0b..2b46261 100644 --- a/nix/templates/manager.nix +++ b/nix/templates/manager.nix @@ -1,27 +1,11 @@ { pkgs, ... }: { - boot.isNspawnContainer = true; - - nixpkgs.config.allowUnfreePredicate = pkg: builtins.elem (pkgs.lib.getName pkg) [ "claude-code" ]; - - environment.systemPackages = with pkgs; [ - hyperhive - claude-code - bashInteractive - git - coreutils-full - ]; - # claude's Bash tool refuses to run without a POSIX shell + $SHELL set. - environment.variables.SHELL = "${pkgs.bashInteractive}/bin/bash"; - - environment.etc."gitconfig".text = '' - [user] - name = hm1nd - email = hm1nd@hyperhive - [init] - defaultBranch = main - ''; + imports = [ ./harness-base.nix ]; + # HIVE_PORT/HIVE_LABEL/gitconfig are also injected by the generated + # `applied/hm1nd/flake.nix` (see `lifecycle::setup_applied`); the values + # here are the base config so the container stays sensible if anyone + # ever evaluates `nixosConfigurations.manager` standalone. systemd.services.hive-m1nd = { description = "hive-m1nd manager harness"; wantedBy = [ "multi-user.target" ]; @@ -29,20 +13,16 @@ environment = { HIVE_PORT = "8000"; HIVE_LABEL = "hm1nd"; + SHELL = "${pkgs.bashInteractive}/bin/bash"; }; - # See note in agent-base.nix — `claude` and a POSIX shell have to be on - # the service PATH explicitly for the harness + claude's Bash tool. path = [ pkgs.claude-code pkgs.bashInteractive ]; - environment.SHELL = "${pkgs.bashInteractive}/bin/bash"; serviceConfig = { ExecStart = "${pkgs.hyperhive}/bin/hive-m1nd serve"; Restart = "on-failure"; RestartSec = 2; }; }; - - system.stateVersion = "25.11"; }