`topology.json` was a map of `name -> parent | null`, and that value fed the whole agent hierarchy: `<parent>` / `<children>` recipient sentinels, the reparenting API (CLI verb, wire verb, dashboard endpoints, DAG node), the dashboard tree, the rebuild depth sort, and an unconditional bind-mount grant giving every agent RW on its direct children's state. Per the operator's ruling the field goes, and with it all of the above. The file survives as what remains once the value is gone: the roster of agent names, which is the set `ManageRootAgent` grants mounts over. It is now a JSON array; `read` still accepts the old map shape and keeps its keys, so a hive that upgrades across this does not blank its roster (and so no capability holder loses its mounts for the length of that window). Two sites kept their behaviour under a different recipient rather than losing it. Both addressed `<parent>`, which the broker already resolved to `operator` for a root agent, and every agent is now what that fallback called a root: - the harness's turn-failure / plugin-failure notification (`Surface::send_to_parent` -> `send_to_operator`), and - the send allow-list's always-permitted escape hatch, so an agent with a restrictive allow-list still has a way to say it is stuck. What is NOT preserved, deliberately: an agent with no capability no longer sees any other agent's dirs. `ManageRootAgent`'s own grant is unchanged -- still every agent in the roster, still state RW + config RO, still no `harness`. The dashboard's reparenting control (the M0V3 picker) is deleted with its CSS. The tree rendering that reads `ContainerView.parent` is left for the frontend owner -- it degrades to a flat list with the field gone.
155 lines
6 KiB
Rust
155 lines
6 KiB
Rust
//! Boot-time `claude plugin install` driver. Reads the list declared
|
|
//! via the `services.hyperhive.agent.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.
|
|
//!
|
|
//! Before installing, all configured marketplaces are updated so that
|
|
//! plugin specs resolve against current index data. Marketplace update
|
|
//! failures are non-fatal — stale index is better than no install attempt.
|
|
|
|
use tokio::process::Command;
|
|
|
|
const PLUGINS_PATH: &str = "/etc/hyperhive/claude-plugins.json";
|
|
const MARKETPLACES_PATH: &str = "/etc/hyperhive/claude-marketplaces.json";
|
|
const AUTO_UPDATE_PATH: &str = "/etc/hyperhive/claude-plugins-auto-update.json";
|
|
|
|
/// Add every marketplace from `/etc/hyperhive/claude-marketplaces.json`
|
|
/// via `claude plugin marketplace add <source>`. Idempotent: re-add of
|
|
/// an existing marketplace is treated as success (claude prints an
|
|
/// "already exists" message and exits non-zero on some versions).
|
|
/// Required before any `<plugin>@<marketplace>` install can resolve.
|
|
async fn add_marketplaces() {
|
|
let Ok(raw) = tokio::fs::read_to_string(MARKETPLACES_PATH).await else {
|
|
return;
|
|
};
|
|
let sources: Vec<String> = match serde_json::from_str(&raw) {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
tracing::warn!(path = MARKETPLACES_PATH, error = ?e, "claude-marketplaces spec parse failed; skipping");
|
|
return;
|
|
}
|
|
};
|
|
for source in sources {
|
|
match Command::new("claude")
|
|
.args(["plugin", "marketplace", "add", &source])
|
|
.output()
|
|
.await
|
|
{
|
|
Ok(out) if out.status.success() => {
|
|
tracing::info!(source = %source, "claude plugin marketplace add ok");
|
|
}
|
|
Ok(out) => {
|
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
|
if stderr.contains("already") {
|
|
tracing::debug!(source = %source, "marketplace already added");
|
|
} else {
|
|
tracing::warn!(
|
|
source = %source,
|
|
status = ?out.status,
|
|
stderr = %stderr,
|
|
"claude plugin marketplace add failed (non-fatal)",
|
|
);
|
|
}
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(source = %source, error = ?e, "claude plugin marketplace add spawn failed");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Read the `services.hyperhive.agent.claudePluginsAutoUpdate` flag written by the NixOS
|
|
/// module. Defaults to `false` when the file is absent or unparseable.
|
|
async fn auto_update_enabled() -> bool {
|
|
match tokio::fs::read_to_string(AUTO_UPDATE_PATH).await {
|
|
Ok(s) => serde_json::from_str::<bool>(s.trim()).unwrap_or(false),
|
|
Err(_) => false,
|
|
}
|
|
}
|
|
|
|
/// Update all configured plugin marketplaces. Non-fatal — logs a warning
|
|
/// on failure but does not abort the install sequence.
|
|
async fn update_marketplaces() {
|
|
match Command::new("claude")
|
|
.args(["plugin", "marketplace", "update"])
|
|
.output()
|
|
.await
|
|
{
|
|
Ok(out) if out.status.success() => {
|
|
tracing::info!("claude plugin marketplace update ok");
|
|
}
|
|
Ok(out) => {
|
|
tracing::warn!(
|
|
status = ?out.status,
|
|
stderr = %String::from_utf8_lossy(&out.stderr),
|
|
"claude plugin marketplace update failed (non-fatal)",
|
|
);
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(error = ?e, "claude plugin marketplace update spawn failed (non-fatal)");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Install every plugin in `/etc/hyperhive/claude-plugins.json`.
|
|
/// Returns a list of human-readable failure messages so the caller can
|
|
/// route them through their own per-role surface (turn-failure-style
|
|
/// notification, see `Surface::send_to_operator`). Wire-agnostic: the
|
|
/// caller picks the recipient, the same way failure-notify does.
|
|
pub async fn install_configured() -> Vec<String> {
|
|
let Ok(raw) = tokio::fs::read_to_string(PLUGINS_PATH).await else {
|
|
return Vec::new();
|
|
};
|
|
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 Vec::new();
|
|
}
|
|
};
|
|
if specs.is_empty() {
|
|
return Vec::new();
|
|
}
|
|
add_marketplaces().await;
|
|
if auto_update_enabled().await {
|
|
update_marketplaces().await;
|
|
} else {
|
|
tracing::debug!("claudePluginsAutoUpdate=false, skipping marketplace update");
|
|
}
|
|
let mut failures = Vec::new();
|
|
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) => {
|
|
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
|
|
tracing::warn!(
|
|
spec = %spec,
|
|
status = ?out.status,
|
|
stderr = %stderr,
|
|
"claude plugin install failed",
|
|
);
|
|
failures.push(format!(
|
|
"claude plugin install failed for `{spec}`:\n{}",
|
|
stderr.trim()
|
|
));
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(spec = %spec, error = ?e, "claude plugin install spawn failed");
|
|
failures.push(format!(
|
|
"claude plugin install spawn failed for `{spec}`: {e}"
|
|
));
|
|
}
|
|
}
|
|
}
|
|
failures
|
|
}
|