nix templates: factor harness-base.nix (shared scaffolding incl. gitconfig)

This commit is contained in:
müde 2026-05-15 16:10:55 +02:00
parent cb62e15d4f
commit e1289a3e4c
11 changed files with 137 additions and 113 deletions

View file

@ -80,7 +80,13 @@ async fn main() -> Result<()> {
}); });
match initial { match initial {
LoginState::Online => { 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 => { LoginState::NeedsLogin => {
// Partial-run mode: keep the harness alive (so the web UI // Partial-run mode: keep the harness alive (so the web UI
@ -152,8 +158,7 @@ async fn serve(
body: body.clone(), body: body.clone(),
}); });
let prompt = format_wake_prompt(&label, &from, &body); let prompt = format_wake_prompt(&label, &from, &body);
let outcome = let outcome = drive_turn(&prompt, &mcp_config, &bus, mcp::Flavor::Agent).await;
drive_turn(&prompt, &mcp_config, &bus, mcp::Flavor::Agent).await;
emit_turn_end(&bus, &outcome); emit_turn_end(&bus, &outcome);
} }
Ok(AgentResponse::Empty) => {} Ok(AgentResponse::Empty) => {}

View file

@ -90,9 +90,7 @@ async fn main() -> Result<()> {
} }
}); });
match initial { match initial {
LoginState::Online => { LoginState::Online => serve(&cli.socket, Duration::from_millis(poll_ms), bus).await,
serve(&cli.socket, Duration::from_millis(poll_ms), bus).await
}
LoginState::NeedsLogin => { LoginState::NeedsLogin => {
tracing::warn!( tracing::warn!(
claude_dir = %claude_dir.display(), claude_dir = %claude_dir.display(),
@ -174,8 +172,7 @@ async fn serve(socket: &Path, interval: Duration, bus: Bus) -> Result<()> {
body: body.clone(), body: body.clone(),
}); });
let prompt = format_wake_prompt(&label, &from, &body); let prompt = format_wake_prompt(&label, &from, &body);
let outcome = let outcome = drive_turn(&prompt, &mcp_config, &bus, mcp::Flavor::Manager).await;
drive_turn(&prompt, &mcp_config, &bus, mcp::Flavor::Manager).await;
emit_turn_end(&bus, &outcome); emit_turn_end(&bus, &outcome);
} }
Ok(ManagerResponse::Empty) => {} Ok(ManagerResponse::Empty) => {}

View file

@ -21,10 +21,8 @@ use std::path::PathBuf;
use anyhow::Result; use anyhow::Result;
use rmcp::{ use rmcp::{
ServerHandler, ServiceExt, ServerHandler, ServiceExt, handler::server::wrapper::Parameters, schemars, tool, tool_handler,
handler::server::wrapper::Parameters, tool_router, transport::stdio,
schemars, tool, tool_handler, tool_router,
transport::stdio,
}; };
use crate::client; use crate::client;
@ -33,12 +31,7 @@ use crate::client;
/// a status line → post-log. Free function so both `AgentServer` and /// a status line → post-log. Free function so both `AgentServer` and
/// `ManagerServer` use the same shape; the per-server `status_line` /// `ManagerServer` use the same shape; the per-server `status_line`
/// closure is what differs (different `Status` wire types). /// closure is what differs (different `Status` wire types).
pub async fn run_tool_envelope<F, S>( pub async fn run_tool_envelope<F, S>(tool: &'static str, args: String, status: S, body: F) -> String
tool: &'static str,
args: String,
status: S,
body: F,
) -> String
where where
F: Future<Output = String>, F: Future<Output = String>,
S: Future<Output = String>, S: Future<Output = String>,
@ -223,8 +216,10 @@ impl ManagerServer {
#[tool_router] #[tool_router]
impl ManagerServer { impl ManagerServer {
#[tool(description = "Send a message to a sub-agent (by logical name), to another agent, \ #[tool(
or to the operator (recipient `operator`, surfaces in the dashboard).")] 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<SendArgs>) -> String { async fn send(&self, Parameters(args): Parameters<SendArgs>) -> String {
let log = format!("{args:?}"); let log = format!("{args:?}");
let to = args.to.clone(); let to = args.to.clone();
@ -245,8 +240,10 @@ impl ManagerServer {
.await .await
} }
#[tool(description = "Pop one message from the manager inbox. Returns sender + body, or \ #[tool(
empty.")] description = "Pop one message from the manager inbox. Returns sender + body, or \
empty."
)]
async fn recv(&self, Parameters(args): Parameters<RecvArgs>) -> String { async fn recv(&self, Parameters(args): Parameters<RecvArgs>) -> String {
let log = format!("{args:?}"); let log = format!("{args:?}");
run_tool_envelope("recv", log, self.status_line(), async move { run_tool_envelope("recv", log, self.status_line(), async move {
@ -256,7 +253,9 @@ impl ManagerServer {
format!("from: {from}\n\n{body}") format!("from: {from}\n\n{body}")
} }
Ok(hive_sh4re::ManagerResponse::Empty) => "(empty)".into(), 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:?}"), Ok(other) => format!("recv unexpected response: {other:?}"),
Err(e) => format!("recv transport error: {e:#}"), Err(e) => format!("recv transport error: {e:#}"),
} }
@ -264,8 +263,10 @@ impl ManagerServer {
.await .await
} }
#[tool(description = "Queue a Spawn approval for a brand-new sub-agent. The operator \ #[tool(
approves on the dashboard before the container is actually created.")] 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<RequestSpawnArgs>) -> String { async fn request_spawn(&self, Parameters(args): Parameters<RequestSpawnArgs>) -> String {
let log = format!("{args:?}"); let log = format!("{args:?}");
let name = args.name.clone(); let name = args.name.clone();
@ -283,8 +284,10 @@ impl ManagerServer {
.await .await
} }
#[tool(description = "Stop a sub-agent container (graceful). The state dir is kept; \ #[tool(
recreating reuses prior config + Claude credentials.")] 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<KillArgs>) -> String { async fn kill(&self, Parameters(args): Parameters<KillArgs>) -> String {
let log = format!("{args:?}"); let log = format!("{args:?}");
let name = args.name.clone(); let name = args.name.clone();
@ -292,7 +295,9 @@ impl ManagerServer {
let req = hive_sh4re::ManagerRequest::Kill { name: args.name }; let req = hive_sh4re::ManagerRequest::Kill { name: args.name };
match client::request::<_, hive_sh4re::ManagerResponse>(&self.socket, &req).await { match client::request::<_, hive_sh4re::ManagerResponse>(&self.socket, &req).await {
Ok(hive_sh4re::ManagerResponse::Ok) => format!("killed {name}"), 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:?}"), Ok(other) => format!("kill unexpected response: {other:?}"),
Err(e) => format!("kill transport error: {e:#}"), Err(e) => format!("kill transport error: {e:#}"),
} }
@ -300,9 +305,11 @@ impl ManagerServer {
.await .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 \ (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( async fn request_apply_commit(
&self, &self,
Parameters(args): Parameters<RequestApplyCommitArgs>, Parameters(args): Parameters<RequestApplyCommitArgs>,
@ -310,7 +317,11 @@ impl ManagerServer {
let log = format!("{args:?}"); let log = format!("{args:?}");
let agent = args.agent.clone(); let agent = args.agent.clone();
let commit_ref = args.commit_ref.clone(); let commit_ref = args.commit_ref.clone();
run_tool_envelope("request_apply_commit", log, self.status_line(), async move { run_tool_envelope(
"request_apply_commit",
log,
self.status_line(),
async move {
let req = hive_sh4re::ManagerRequest::RequestApplyCommit { let req = hive_sh4re::ManagerRequest::RequestApplyCommit {
agent: args.agent, agent: args.agent,
commit_ref: args.commit_ref, commit_ref: args.commit_ref,
@ -325,7 +336,8 @@ impl ManagerServer {
Ok(other) => format!("request_apply_commit unexpected response: {other:?}"), Ok(other) => format!("request_apply_commit unexpected response: {other:?}"),
Err(e) => format!("request_apply_commit transport error: {e:#}"), Err(e) => format!("request_apply_commit transport error: {e:#}"),
} }
}) },
)
.await .await
} }
} }
@ -350,15 +362,8 @@ pub const SERVER_NAME: &str = "hyperhive";
/// (`Task`) are intentionally omitted for now; `Bash` is allowed pending a /// (`Task`) are intentionally omitted for now; `Bash` is allowed pending a
/// finer-grained allow-list system for shell command patterns. Edit later /// finer-grained allow-list system for shell command patterns. Edit later
/// as our trust model evolves. /// as our trust model evolves.
pub const ALLOWED_BUILTIN_TOOLS: &[&str] = &[ pub const ALLOWED_BUILTIN_TOOLS: &[&str] =
"Bash", &["Bash", "Edit", "Glob", "Grep", "Read", "TodoWrite", "Write"];
"Edit",
"Glob",
"Grep",
"Read",
"TodoWrite",
"Write",
];
/// Which MCP tool surface to advertise via `--allowedTools`. The agent /// Which MCP tool surface to advertise via `--allowedTools`. The agent
/// list is the strict subset of the manager list, so we just thread the /// 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<String> { pub fn allowed_mcp_tools(flavor: Flavor) -> Vec<String> {
let names: &[&str] = match flavor { let names: &[&str] = match flavor {
Flavor::Agent => &["send", "recv"], 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 names
.iter() .iter()
@ -388,7 +399,10 @@ pub fn allowed_mcp_tools(flavor: Flavor) -> Vec<String> {
/// both the built-ins and the MCP surface. /// both the built-ins and the MCP surface.
#[must_use] #[must_use]
pub fn allowed_tools_arg(flavor: Flavor) -> String { pub fn allowed_tools_arg(flavor: Flavor) -> String {
let mut all: Vec<String> = ALLOWED_BUILTIN_TOOLS.iter().map(|s| (*s).to_owned()).collect(); let mut all: Vec<String> = ALLOWED_BUILTIN_TOOLS
.iter()
.map(|s| (*s).to_owned())
.collect();
all.extend(allowed_mcp_tools(flavor)); all.extend(allowed_mcp_tools(flavor));
all.join(",") all.join(",")
} }

View file

@ -323,9 +323,9 @@ async fn events_stream(
let rx = state.bus.subscribe(); let rx = state.bus.subscribe();
// Drop a "hello" note into the bus so every new subscriber sees at // Drop a "hello" note into the bus so every new subscriber sees at
// least one event immediately and can clear the connecting placeholder. // least one event immediately and can clear the connecting placeholder.
state state.bus.emit(crate::events::LiveEvent::Note(
.bus "live stream attached".into(),
.emit(crate::events::LiveEvent::Note("live stream attached".into())); ));
let stream = BroadcastStream::new(rx).filter_map(|res| { let stream = BroadcastStream::new(rx).filter_map(|res| {
let ev = res.ok()?; let ev = res.ok()?;
let json = serde_json::to_string(&ev).ok()?; let json = serde_json::to_string(&ev).ok()?;
@ -356,10 +356,7 @@ struct CodeForm {
code: String, code: String,
} }
async fn post_login_code( async fn post_login_code(State(state): State<AppState>, Form(form): Form<CodeForm>) -> Response {
State(state): State<AppState>,
Form(form): Form<CodeForm>,
) -> Response {
let session = state.session.lock().unwrap().clone(); let session = state.session.lock().unwrap().clone();
let Some(session) = session else { let Some(session) = session else {
return error_response("no login session running"); return error_response("no login session running");

View file

@ -6,7 +6,9 @@
use std::sync::Arc; use std::sync::Arc;
use anyhow::{Result, bail}; 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::coordinator::{Coordinator, TransientKind};
use crate::lifecycle::{self, MANAGER_NAME}; use crate::lifecycle::{self, MANAGER_NAME};
@ -83,7 +85,11 @@ pub async fn approve(coord: Arc<Coordinator>, 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 { match result {
Ok(()) => { Ok(()) => {
notify_manager( notify_manager(

View file

@ -72,7 +72,6 @@ pub async fn rebuild_agent(coord: &Arc<Coordinator>, name: &str, current_rev: &s
Ok(()) Ok(())
} }
/// Auto-create the manager container on startup if it isn't already there. /// 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 /// hive-c0re manages hm1nd end-to-end (Phase 8 follow-up): operators no
/// longer declare `containers.hm1nd` in their host NixOS config. Bypasses /// longer declare `containers.hm1nd` in their host NixOS config. Bypasses

View file

@ -75,7 +75,11 @@ pub fn is_manager(name: &str) -> bool {
/// extends. Manager → `manager`; everyone else → `agent-base`. /// extends. Manager → `manager`; everyone else → `agent-base`.
#[must_use] #[must_use]
pub fn flake_base(name: &str) -> &'static str { 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<()> { fn validate(name: &str) -> Result<()> {

View file

@ -218,7 +218,9 @@ pub enum ManagerRequest {
Status, Status,
/// Operator-injected message TO the manager (from the manager's own web /// Operator-injected message TO the manager (from the manager's own web
/// UI). Same shape as `AgentRequest::OperatorMsg`. /// 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 /// 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 /// creates and starts the container. Brand-new agent names only — if an
/// agent of the same name already exists, the approval will fail. /// agent of the same name already exists, the approval will fail.

View file

@ -1,27 +1,14 @@
{ pkgs, ... }: { pkgs, ... }:
{ {
boot.isNspawnContainer = true; imports = [ ./harness-base.nix ];
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";
systemd.services.hive-ag3nt = { systemd.services.hive-ag3nt = {
description = "hive-ag3nt harness"; description = "hive-ag3nt harness";
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
after = [ "network.target" ]; after = [ "network.target" ];
# The harness shells out to `claude` (turn loop + login flow). systemd # `claude` for the turn loop + `bash` for claude's Bash tool. systemd
# units get a minimal PATH by default, so we have to put claude-code on # units get a minimal PATH by default; entries in
# it explicitly even though it's in environment.systemPackages above. # `environment.systemPackages` aren't on it.
# bash is on PATH so claude's Bash tool can spawn `$SHELL`.
path = [ path = [
pkgs.claude-code pkgs.claude-code
pkgs.bashInteractive pkgs.bashInteractive
@ -33,6 +20,4 @@
RestartSec = 2; RestartSec = 2;
}; };
}; };
system.stateVersion = "25.11";
} }

View file

@ -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/<name>/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";
}

View file

@ -1,27 +1,11 @@
{ pkgs, ... }: { pkgs, ... }:
{ {
boot.isNspawnContainer = true; imports = [ ./harness-base.nix ];
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
'';
# 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 = { systemd.services.hive-m1nd = {
description = "hive-m1nd manager harness"; description = "hive-m1nd manager harness";
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
@ -29,20 +13,16 @@
environment = { environment = {
HIVE_PORT = "8000"; HIVE_PORT = "8000";
HIVE_LABEL = "hm1nd"; 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 = [ path = [
pkgs.claude-code pkgs.claude-code
pkgs.bashInteractive pkgs.bashInteractive
]; ];
environment.SHELL = "${pkgs.bashInteractive}/bin/bash";
serviceConfig = { serviceConfig = {
ExecStart = "${pkgs.hyperhive}/bin/hive-m1nd serve"; ExecStart = "${pkgs.hyperhive}/bin/hive-m1nd serve";
Restart = "on-failure"; Restart = "on-failure";
RestartSec = 2; RestartSec = 2;
}; };
}; };
system.stateVersion = "25.11";
} }