hyperhive/hive-subagent-mcp/src/mcp_config.rs
atlas 6ea81aae65 hive-c0re: render the new agent option paths into generated agent flakes
meta.rs writes each agent's flake, and it still named the pre-move
`hyperhive.*` paths — so every agent rebuild would print a rename
deprecation warning about a line no human wrote and no operator could fix.
A warning nobody can act on trains everyone to ignore the ones that matter,
which is the whole value of the alias shims.

Repoints the FORWARDED_VAR_OPTIONS table and every other emitted option
assignment (otel.*, docs.source, claudeCodePath, github.enable, user.name,
claudeMemoryMaxBytes) to `services.hyperhive.agent.*`, with the test
expectations that pin the rendered text. The flake input named `hyperhive`
(`hyperhive.url`, `hyperhive.inputs.nixpkgs.follows`,
`hyperhive.nixosConfigurations.*`), hive-tier `services.hyperhive.*` paths,
and the `@hyperhive.local` git identity share the word and are untouched.

Also repoints the same option paths where they appear in comments, rustdoc
and runtime message strings across the other crates — a refusal message
naming `hyperhive.allowedRecipients` sends an operator to a path that will
stop existing. Prose under docs/ is deliberately not in this commit.

Refs #4473
2026-09-17 20:19:30 +02:00

96 lines
4.4 KiB
Rust

//! Builds a subagent's own filtered `--mcp-config`: only
//! `services.hyperhive.agent.extraMcpServers` entries with `availableToSubagents = true`
//! (see `hive_agent_sock::extra_mcp::ExtraMcpServer::available_to_subagents`) ever reach
//! a subagent's claude invocation, plus this daemon's own two-tool signal
//! surface. Everything else — the built-in hyperhive
//! surface (todos/messaging), the automatically injected `bash` and `subagent`
//! entries — stays unreachable by construction: none of those default to
//! opted in, and the built-in surface isn't an `extraMcpServers` entry at
//! all, so there's no name for an operator to opt it in under even if they
//! wanted to.
//!
//! The signal surface is the one server a subagent always gets. It is a
//! *different route* on this daemon's listener from the one the parent
//! uses, serving `goal_reached` and `need_help` and nothing else — so
//! "a subagent can say it is done or stuck" never widens into "a subagent
//! can spawn subagents", which is what handing it the parent's route would
//! have meant.
//!
//! **One file per session, not one file.** The signal URL carries the
//! session's own token (`State::mint_signal_url`), so the
//! file it is written into has to be the session's own too: a single shared
//! path is a race between two `start`s — whichever wrote last decides which
//! token the other's `claude` reads at startup, which would hand a subagent
//! a sibling's identity by accident.
use std::path::PathBuf;
/// Filename the rendered config lives at, under [`crate::paths::harness_dir`],
/// per session. The name is a validated [`hive_types::Ident`] by the time it
/// gets here (`crate::session::validate_name`), so it is a single safe path
/// segment and never escapes the directory.
fn config_file(name: &str) -> String {
format!("subagent-mcp-config-{name}.json")
}
/// Name the signal surface appears under in a subagent's own MCP config,
/// and therefore the prefix its tools are called by
/// (`mcp__subagent_control__goal_reached`).
const SIGNAL_SERVER: &str = "subagent_control";
/// Render a subagent's `--mcp-config` file, returning its path — or `None`
/// when there is nothing to put in it (or the render/write fails), so
/// [`hive_claude::Config::mcp_config`] stays unset and the subagent gets
/// literally zero MCP servers.
///
/// `signal_url` is `name`'s *own* `goal_reached`/`need_help` route — this
/// daemon's signal path plus the token minted for this run, and the only
/// place that token is ever written. It is always included when given, since
/// a subagent that can't say it's done or stuck is exactly the one the turn
/// cap has to stop on its behalf. `None` reproduces the pre-continuation
/// shape — only the opted-in extras, and no file at all when none opt in.
///
/// Re-rendered on every call (cheap: a filter plus a small
/// file write) rather than cached once at daemon startup, so a config change
/// takes effect on this subagent's next `start`/`continue` without needing
/// the daemon itself restarted.
#[must_use]
pub fn build(name: &str, signal_url: Option<&str>) -> Option<PathBuf> {
let state_dir = crate::paths::state_dir();
let mut servers: serde_json::Map<String, serde_json::Value> =
hive_agent_sock::extra_mcp::load_extra_mcp()
.into_iter()
.filter(|(_, spec)| spec.available_to_subagents())
.map(|(name, spec)| (name, spec.to_json_entry(&state_dir)))
.collect();
if let Some(url) = signal_url {
servers.insert(
SIGNAL_SERVER.to_owned(),
serde_json::json!({ "type": "http", "url": url }),
);
}
if servers.is_empty() {
return None;
}
let body = serde_json::to_string_pretty(&serde_json::json!({ "mcpServers": servers }))
.unwrap_or_else(|_| "{}".into());
let dir = crate::paths::harness_dir();
if let Err(e) = std::fs::create_dir_all(&dir) {
tracing::warn!(
error = ?e,
dir = %dir.display(),
"subagent mcp-config: harness dir create failed; subagent gets zero extra MCP servers this turn",
);
return None;
}
let path = dir.join(config_file(name));
if let Err(e) = std::fs::write(&path, body) {
tracing::warn!(
error = ?e,
path = %path.display(),
"subagent mcp-config: write failed; subagent gets zero extra MCP servers this turn",
);
return None;
}
Some(path)
}