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 {
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) => {}

View file

@ -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) => {}

View file

@ -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<F, S>(
tool: &'static str,
args: String,
status: S,
body: F,
) -> String
pub async fn run_tool_envelope<F, S>(tool: &'static str, args: String, status: S, body: F) -> String
where
F: Future<Output = String>,
S: Future<Output = String>,
@ -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<SendArgs>) -> 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<RecvArgs>) -> 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<RequestSpawnArgs>) -> 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<KillArgs>) -> 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<RequestApplyCommitArgs>,
@ -310,7 +317,11 @@ 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 {
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,
@ -325,7 +336,8 @@ impl ManagerServer {
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<String> {
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<String> {
/// both the built-ins and the MCP surface.
#[must_use]
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.join(",")
}

View file

@ -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<AppState>,
Form(form): Form<CodeForm>,
) -> Response {
async fn post_login_code(State(state): State<AppState>, Form(form): Form<CodeForm>) -> Response {
let session = state.session.lock().unwrap().clone();
let Some(session) = session else {
return error_response("no login session running");

View file

@ -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<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 {
Ok(()) => {
notify_manager(

View file

@ -72,7 +72,6 @@ pub async fn rebuild_agent(coord: &Arc<Coordinator>, 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

View file

@ -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<()> {

View file

@ -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.

View file

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

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, ... }:
{
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";
}