Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6dd17864ac | ||
|
|
8e7405db13 |
8 changed files with 99 additions and 84 deletions
|
|
@ -1,5 +1,9 @@
|
||||||
{
|
{
|
||||||
"autoCompactEnabled": false,
|
"autoCompactEnabled": false,
|
||||||
"autoMemoryEnabled": false,
|
"autoMemoryEnabled": false,
|
||||||
"effortLevel": "medium"
|
"effortLevel": "medium",
|
||||||
|
"permissions": {
|
||||||
|
"defaultMode": "bypassPermissions",
|
||||||
|
"deny": ["WebFetch", "WebSearch", "Task", "TodoWrite"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ use anyhow::Result;
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use hive_ag3nt::events::{Bus, LiveEvent, TurnState};
|
use hive_ag3nt::events::{Bus, LiveEvent, TurnState};
|
||||||
use hive_ag3nt::login::{self, LoginState};
|
use hive_ag3nt::login::{self, LoginState};
|
||||||
use hive_ag3nt::{DEFAULT_SOCKET, DEFAULT_WEB_PORT, client, mcp, turn, web_ui};
|
use hive_ag3nt::{DEFAULT_SOCKET, DEFAULT_WEB_PORT, client, mcp, plugins, turn, web_ui};
|
||||||
use hive_sh4re::{AgentRequest, AgentResponse};
|
use hive_sh4re::{AgentRequest, AgentResponse};
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
|
|
@ -71,6 +71,7 @@ async fn main() -> Result<()> {
|
||||||
let login_state = Arc::new(Mutex::new(initial));
|
let login_state = Arc::new(Mutex::new(initial));
|
||||||
let bus = Bus::new();
|
let bus = Bus::new();
|
||||||
let files = turn::TurnFiles::prepare(&cli.socket, &label, mcp::Flavor::Agent).await?;
|
let files = turn::TurnFiles::prepare(&cli.socket, &label, mcp::Flavor::Agent).await?;
|
||||||
|
plugins::install_configured().await;
|
||||||
tokio::spawn(web_ui::serve(
|
tokio::spawn(web_ui::serve(
|
||||||
label,
|
label,
|
||||||
port,
|
port,
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ use anyhow::Result;
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use hive_ag3nt::events::{Bus, LiveEvent, TurnState};
|
use hive_ag3nt::events::{Bus, LiveEvent, TurnState};
|
||||||
use hive_ag3nt::login::{self, LoginState};
|
use hive_ag3nt::login::{self, LoginState};
|
||||||
use hive_ag3nt::{DEFAULT_SOCKET, DEFAULT_WEB_PORT, client, mcp, turn, web_ui};
|
use hive_ag3nt::{DEFAULT_SOCKET, DEFAULT_WEB_PORT, client, mcp, plugins, turn, web_ui};
|
||||||
use hive_sh4re::{HelperEvent, ManagerRequest, ManagerResponse, SYSTEM_SENDER};
|
use hive_sh4re::{HelperEvent, ManagerRequest, ManagerResponse, SYSTEM_SENDER};
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
|
|
@ -61,6 +61,7 @@ async fn main() -> Result<()> {
|
||||||
let login_state = Arc::new(Mutex::new(initial));
|
let login_state = Arc::new(Mutex::new(initial));
|
||||||
let bus = Bus::new();
|
let bus = Bus::new();
|
||||||
let files = turn::TurnFiles::prepare(&cli.socket, &label, mcp::Flavor::Manager).await?;
|
let files = turn::TurnFiles::prepare(&cli.socket, &label, mcp::Flavor::Manager).await?;
|
||||||
|
plugins::install_configured().await;
|
||||||
tokio::spawn(web_ui::serve(
|
tokio::spawn(web_ui::serve(
|
||||||
label,
|
label,
|
||||||
port,
|
port,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ pub mod events;
|
||||||
pub mod login;
|
pub mod login;
|
||||||
pub mod login_session;
|
pub mod login_session;
|
||||||
pub mod mcp;
|
pub mod mcp;
|
||||||
|
pub mod plugins;
|
||||||
pub mod turn;
|
pub mod turn;
|
||||||
pub mod web_ui;
|
pub mod web_ui;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -548,86 +548,23 @@ impl ServerHandler for ManagerServer {}
|
||||||
/// tools as `mcp__<this>__<tool>` (e.g. `mcp__hyperhive__send`).
|
/// tools as `mcp__<this>__<tool>` (e.g. `mcp__hyperhive__send`).
|
||||||
pub const SERVER_NAME: &str = "hyperhive";
|
pub const SERVER_NAME: &str = "hyperhive";
|
||||||
|
|
||||||
/// Built-in claude tools the turn loop enables via `--tools`. Anything not
|
/// Which hyperhive MCP surface to advertise — sub-agent (short tool
|
||||||
/// in this list literally doesn't exist in the session (claude won't even
|
/// list) or manager (full lifecycle surface). Threaded through the
|
||||||
/// try to call it). Web egress (`WebFetch`/`WebSearch`) and nested agents
|
/// system-prompt renderer and the per-flavor web UI dispatch; tool
|
||||||
/// (`Task`) are intentionally omitted for now; `Bash` is allowed pending a
|
/// gating itself now lives in `claude-settings.json`'s
|
||||||
/// finer-grained allow-list system for shell command patterns. Edit later
|
/// `permissions.{defaultMode, deny}`, not here.
|
||||||
/// as our trust model evolves.
|
|
||||||
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
|
|
||||||
/// flavor through.
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub enum Flavor {
|
pub enum Flavor {
|
||||||
Agent,
|
Agent,
|
||||||
Manager,
|
Manager,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// MCP tools claude is allowed to call without prompting. Mirrors the
|
|
||||||
/// hyperhive surface so a new tool added in the corresponding `#[tool_router]`
|
|
||||||
/// impl needs to be listed here too.
|
|
||||||
#[must_use]
|
|
||||||
pub fn allowed_mcp_tools(flavor: Flavor) -> Vec<String> {
|
|
||||||
let names: &[&str] = match flavor {
|
|
||||||
Flavor::Agent => &["send", "recv", "ask_operator"],
|
|
||||||
Flavor::Manager => &[
|
|
||||||
"send",
|
|
||||||
"recv",
|
|
||||||
"request_spawn",
|
|
||||||
"kill",
|
|
||||||
"start",
|
|
||||||
"restart",
|
|
||||||
"update",
|
|
||||||
"request_apply_commit",
|
|
||||||
"ask_operator",
|
|
||||||
],
|
|
||||||
};
|
|
||||||
let mut out: Vec<String> = names
|
|
||||||
.iter()
|
|
||||||
.map(|t| format!("mcp__{SERVER_NAME}__{t}"))
|
|
||||||
.collect();
|
|
||||||
// Extra MCP servers declared via `hyperhive.extraMcpServers` in
|
|
||||||
// the agent's NixOS config. Each entry maps its `allowedTools`
|
|
||||||
// pattern list to `mcp__<server>__<pattern>` so claude can call
|
|
||||||
// them without per-tool operator approval. `["*"]` (the default)
|
|
||||||
// expands to `mcp__<server>__*` — every tool from that server.
|
|
||||||
for (server, spec) in load_extra_mcp() {
|
|
||||||
if server == SERVER_NAME {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
for pat in spec.allowed_tools {
|
|
||||||
out.push(format!("mcp__{server}__{pat}"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Combined allow-list passed to `--allowedTools` (auto-approve) — covers
|
|
||||||
/// 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();
|
|
||||||
all.extend(allowed_mcp_tools(flavor));
|
|
||||||
all.join(",")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Built-in tools list for `--tools` (which built-ins exist in this
|
|
||||||
/// session). Same as `ALLOWED_BUILTIN_TOOLS` but joined comma-separated.
|
|
||||||
#[must_use]
|
|
||||||
pub fn builtin_tools_arg() -> String {
|
|
||||||
ALLOWED_BUILTIN_TOOLS.join(",")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Where the NixOS module writes the per-agent extra-MCP spec (see
|
/// Where the NixOS module writes the per-agent extra-MCP spec (see
|
||||||
/// `nix/templates/harness-base.nix`). Each entry becomes an additional
|
/// `nix/templates/harness-base.nix`). Each entry becomes an additional
|
||||||
/// `mcpServers.<key>` block in the rendered claude config + a
|
/// `mcpServers.<key>` block in the rendered claude config; the
|
||||||
/// `mcp__<key>__<tool>` pattern in `--allowedTools`.
|
/// `allowedTools` field is parsed for back-compat but no longer wired
|
||||||
|
/// anywhere — under `bypassPermissions` every MCP tool auto-approves
|
||||||
|
/// unless listed in `permissions.deny`.
|
||||||
const EXTRA_MCP_PATH: &str = "/etc/hyperhive/extra-mcp.json";
|
const EXTRA_MCP_PATH: &str = "/etc/hyperhive/extra-mcp.json";
|
||||||
|
|
||||||
/// Where the NixOS module writes the per-agent send allow-list (see
|
/// Where the NixOS module writes the per-agent send allow-list (see
|
||||||
|
|
@ -687,6 +624,7 @@ struct ExtraMcpServer {
|
||||||
env: std::collections::BTreeMap<String, String>,
|
env: std::collections::BTreeMap<String, String>,
|
||||||
#[serde(default = "default_allowed_tools")]
|
#[serde(default = "default_allowed_tools")]
|
||||||
#[serde(rename = "allowedTools")]
|
#[serde(rename = "allowedTools")]
|
||||||
|
#[allow(dead_code)] // back-compat: superseded by `permissions.deny`
|
||||||
allowed_tools: Vec<String>,
|
allowed_tools: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
48
hive-ag3nt/src/plugins.rs
Normal file
48
hive-ag3nt/src/plugins.rs
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
//! Boot-time `claude plugin install` driver. Reads the list declared
|
||||||
|
//! via the `hyperhive.claudePlugins` NixOS option (rendered to
|
||||||
|
//! `/etc/hyperhive/claude-plugins.json` by the harness module) and
|
||||||
|
//! shells out `claude plugin install <spec>` for each entry. Runs once
|
||||||
|
//! per harness boot before the turn loop; `claude plugin install`
|
||||||
|
//! is expected to be idempotent so reinstalling on each container
|
||||||
|
//! recreate is fine. Failures log a warning but do not abort boot —
|
||||||
|
//! we'd rather start without a plugin than refuse to serve.
|
||||||
|
|
||||||
|
use tokio::process::Command;
|
||||||
|
|
||||||
|
const PLUGINS_PATH: &str = "/etc/hyperhive/claude-plugins.json";
|
||||||
|
|
||||||
|
pub async fn install_configured() {
|
||||||
|
let raw = match tokio::fs::read_to_string(PLUGINS_PATH).await {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
let specs: Vec<String> = match serde_json::from_str(&raw) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(path = PLUGINS_PATH, error = ?e, "claude-plugins spec parse failed; skipping");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for spec in specs {
|
||||||
|
match Command::new("claude")
|
||||||
|
.args(["plugin", "install", &spec])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(out) if out.status.success() => {
|
||||||
|
tracing::info!(spec = %spec, "claude plugin install ok");
|
||||||
|
}
|
||||||
|
Ok(out) => {
|
||||||
|
tracing::warn!(
|
||||||
|
spec = %spec,
|
||||||
|
status = ?out.status,
|
||||||
|
stderr = %String::from_utf8_lossy(&out.stderr),
|
||||||
|
"claude plugin install failed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(spec = %spec, error = ?e, "claude plugin install spawn failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,9 +22,13 @@ use crate::mcp;
|
||||||
/// to read and edit; we ship it via `include_str!`. We turn off claude's
|
/// to read and edit; we ship it via `include_str!`. We turn off claude's
|
||||||
/// in-session auto-compaction and its cross-session auto-memory because
|
/// in-session auto-compaction and its cross-session auto-memory because
|
||||||
/// hyperhive owns those concerns (`/compact` on overflow, notes
|
/// hyperhive owns those concerns (`/compact` on overflow, notes
|
||||||
/// persistence under `/state`). Unknown keys are silently ignored by
|
/// persistence under `/state`). `permissions.defaultMode =
|
||||||
/// claude-code; if a key gets renamed we'll spot it because the
|
/// bypassPermissions` skips the per-tool approval prompt entirely;
|
||||||
/// corresponding behavior will start firing mid-turn again.
|
/// `permissions.deny` keeps a short list of tools we don't want claude
|
||||||
|
/// reaching for (web egress, nested agents, the ephemeral todo list).
|
||||||
|
/// Unknown keys are silently ignored by claude-code; if a key gets
|
||||||
|
/// renamed we'll spot it because the corresponding behavior will start
|
||||||
|
/// firing mid-turn again.
|
||||||
const CLAUDE_SETTINGS: &str = include_str!("../prompts/claude-settings.json");
|
const CLAUDE_SETTINGS: &str = include_str!("../prompts/claude-settings.json");
|
||||||
|
|
||||||
/// Regex-ish marker claude-code emits when context overflows. Same string
|
/// Regex-ish marker claude-code emits when context overflows. Same string
|
||||||
|
|
@ -81,7 +85,10 @@ pub async fn write_mcp_config(socket: &Path) -> Result<PathBuf> {
|
||||||
|
|
||||||
/// Drop the static `--settings` JSON next to the MCP config so we can
|
/// Drop the static `--settings` JSON next to the MCP config so we can
|
||||||
/// pass a path (`--settings <file>`) instead of an ever-growing inline
|
/// pass a path (`--settings <file>`) instead of an ever-growing inline
|
||||||
/// blob — the CLI argv has a finite length budget.
|
/// blob — the CLI argv has a finite length budget. The file carries
|
||||||
|
/// `permissions.defaultMode = bypassPermissions` + a small `deny` list,
|
||||||
|
/// so everything not in `deny` auto-approves without a per-flavor allow
|
||||||
|
/// list.
|
||||||
pub async fn write_settings(socket: &Path) -> Result<PathBuf> {
|
pub async fn write_settings(socket: &Path) -> Result<PathBuf> {
|
||||||
let parent = socket.parent().unwrap_or_else(|| Path::new("/run/hive"));
|
let parent = socket.parent().unwrap_or_else(|| Path::new("/run/hive"));
|
||||||
tokio::fs::create_dir_all(parent).await.ok();
|
tokio::fs::create_dir_all(parent).await.ok();
|
||||||
|
|
@ -247,11 +254,7 @@ async fn run_claude(prompt: &str, files: &TurnFiles, bus: &Bus) -> Result<bool>
|
||||||
cmd.arg("--system-prompt-file").arg(&files.system_prompt);
|
cmd.arg("--system-prompt-file").arg(&files.system_prompt);
|
||||||
cmd.arg("--mcp-config")
|
cmd.arg("--mcp-config")
|
||||||
.arg(&files.mcp_config)
|
.arg(&files.mcp_config)
|
||||||
.arg("--strict-mcp-config")
|
.arg("--strict-mcp-config");
|
||||||
.arg("--tools")
|
|
||||||
.arg(mcp::builtin_tools_arg())
|
|
||||||
.arg("--allowedTools")
|
|
||||||
.arg(mcp::allowed_tools_arg(files.flavor));
|
|
||||||
let mut child = cmd
|
let mut child = cmd
|
||||||
.stdin(Stdio::piped())
|
.stdin(Stdio::piped())
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,22 @@
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
options.hyperhive.claudePlugins = lib.mkOption {
|
||||||
|
type = lib.types.listOf lib.types.str;
|
||||||
|
default = [ ];
|
||||||
|
example = [ "formatter@my-marketplace" "thinking-tools@anthropics" ];
|
||||||
|
description = ''
|
||||||
|
Claude Code plugins to install at harness boot. Each entry is
|
||||||
|
passed verbatim to `claude plugin install <spec>` once per
|
||||||
|
container start, before the turn loop opens. `claude plugin
|
||||||
|
install` is expected to be idempotent, so reinstalling on every
|
||||||
|
boot is cheap. Failures log a warning but do not abort boot — a
|
||||||
|
missing plugin is preferable to a non-serving agent. Rendered to
|
||||||
|
`/etc/hyperhive/claude-plugins.json`; the harness reads it via
|
||||||
|
`plugins::install_configured`.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
environment.etc."hyperhive/extra-mcp.json".text =
|
environment.etc."hyperhive/extra-mcp.json".text =
|
||||||
builtins.toJSON config.hyperhive.extraMcpServers;
|
builtins.toJSON config.hyperhive.extraMcpServers;
|
||||||
|
|
@ -89,6 +105,9 @@
|
||||||
environment.etc."hyperhive/send-allow.json".text =
|
environment.etc."hyperhive/send-allow.json".text =
|
||||||
builtins.toJSON config.hyperhive.allowedRecipients;
|
builtins.toJSON config.hyperhive.allowedRecipients;
|
||||||
|
|
||||||
|
environment.etc."hyperhive/claude-plugins.json".text =
|
||||||
|
builtins.toJSON config.hyperhive.claudePlugins;
|
||||||
|
|
||||||
boot.isNspawnContainer = true;
|
boot.isNspawnContainer = true;
|
||||||
|
|
||||||
# `claude-code` is unfree. Each per-agent container's nixosConfiguration
|
# `claude-code` is unfree. Each per-agent container's nixosConfiguration
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue