`goal_reached`/`need_help` took the session name as a tool argument, so identity was an assertion by the caller and the only guard on it was `occupancy()` — "does that name have a turn in flight", which two concurrently running siblings both satisfy for each other. A subagent could stop its sibling's run by naming it. Identity moves into the URL. Each spawned run is minted an unguessable token (`Uuid::new_v4`, the OS CSPRNG), the URL carrying it goes into that one subagent's own `--mcp-config`, and the route resolves it back to a session before dispatching to a handler bound to that session. Neither tool takes a `name` any more: a subagent has no field in which to name a sibling, and a sibling's name — which a brief may well mention — is not a token. One route with a path parameter, not a route per session: the `Router` is built once at startup and subagents come and go for the daemon's whole life. An unminted or revoked token gets a bare 404, the same answer either way, so nothing enumerates. A run's token is revoked when the run ends (`finish_turn`) or when a call never reached a spawn. Two things fall out of that: - the config file becomes one per session. A single shared path was already a race between two `start`s; with a per-session URL in it, the loser would read the winner's identity. - `occupancy()` stops being the identity guard and is gone from the signal path entirely rather than kept "just in case" — a revoked token can't reach it, and it never answered the question it was standing in for. It still backs `status`, which is what it was always actually for. Refs #4403 Refs #4413
96 lines
4.4 KiB
Rust
96 lines
4.4 KiB
Rust
//! Builds a subagent's own filtered `--mcp-config`: only
|
|
//! `hyperhive.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)
|
|
}
|