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
96 lines
4.4 KiB
Rust
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)
|
|
}
|