feat(#1004,#1006): capability system + read_host_journal / get_host_journal MCP tool
This commit is contained in:
parent
6c75030420
commit
dc8a4e2baf
7 changed files with 345 additions and 3 deletions
|
|
@ -46,6 +46,7 @@ pub enum SocketReply {
|
|||
QuestionQueued(i64),
|
||||
Recent(Vec<hive_sh4re::InboxRow>),
|
||||
Logs(String),
|
||||
HostJournal(String),
|
||||
/// `list_schedules` result — used by the manager surface only;
|
||||
/// `AgentResponse` has no equivalent variant.
|
||||
Schedules(Vec<hive_sh4re::WireSchedule>),
|
||||
|
|
@ -79,6 +80,7 @@ impl From<hive_sh4re::Response> for SocketReply {
|
|||
}
|
||||
hive_sh4re::Response::ReminderRollup(stats) => Self::ReminderRollup(stats),
|
||||
hive_sh4re::Response::Logs { content } => Self::Logs(content),
|
||||
hive_sh4re::Response::HostJournal { content } => Self::HostJournal(content),
|
||||
hive_sh4re::Response::Schedules { schedules } => Self::Schedules(schedules),
|
||||
hive_sh4re::Response::AgentMeta {
|
||||
name,
|
||||
|
|
@ -854,6 +856,43 @@ impl AgentServer {
|
|||
})
|
||||
.await
|
||||
}
|
||||
|
||||
// IMPORTANT: this tool is capability-gated (`read_host_journal`).
|
||||
// It is added to `--allowedTools` by `allowed_capability_tools` only
|
||||
// when `HIVE_CAPABILITIES` contains `read_host_journal`. hive-c0re
|
||||
// performs a second capability check server-side before running journalctl.
|
||||
#[tool(
|
||||
description = "Fetch recent lines from the host journal (requires `read_host_journal` \
|
||||
capability). All filters are optional — omit to get the last N host journal lines. \
|
||||
`unit`: filter to a systemd unit (e.g. `hive-c0re.service`). \
|
||||
`container`: logical agent name (e.g. `iris`) — adds `-M h-iris`. \
|
||||
`lines`: how many lines (default 100, max 500). \
|
||||
`priority`: minimum syslog level (`err`, `warning`, `info`, `debug`)."
|
||||
)]
|
||||
async fn get_host_journal(
|
||||
&self,
|
||||
Parameters(args): Parameters<GetHostJournalArgs>,
|
||||
) -> String {
|
||||
let log = format!("{args:?}");
|
||||
run_tool_envelope("get_host_journal", log, async move {
|
||||
let (resp, retries) = self
|
||||
.dispatch(hive_sh4re::AgentRequest::GetHostJournal {
|
||||
unit: args.unit,
|
||||
container: args.container,
|
||||
lines: args.lines,
|
||||
priority: args.priority,
|
||||
})
|
||||
.await;
|
||||
let result = match resp {
|
||||
Ok(SocketReply::HostJournal(content)) => content,
|
||||
Ok(SocketReply::Err(m)) => format!("get_host_journal failed: {m}"),
|
||||
Ok(other) => format!("get_host_journal unexpected response: {other:?}"),
|
||||
Err(e) => format!("get_host_journal transport error: {e:#}"),
|
||||
};
|
||||
annotate_retries(result, retries)
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_handler(
|
||||
|
|
@ -1132,6 +1171,24 @@ pub struct GetLogsArgs {
|
|||
pub lines: Option<u32>,
|
||||
}
|
||||
|
||||
/// Arguments for `get_host_journal` (capability-gated: `read_host_journal`).
|
||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct GetHostJournalArgs {
|
||||
/// Systemd unit to filter (e.g. `hive-c0re.service`). Omit for all units.
|
||||
#[serde(default)]
|
||||
pub unit: Option<String>,
|
||||
/// Logical agent name (e.g. `iris`) — gets that container's journal via `-M h-iris`.
|
||||
/// Omit for the host journal.
|
||||
#[serde(default)]
|
||||
pub container: Option<String>,
|
||||
/// Number of lines to return (default 100, max 500).
|
||||
#[serde(default)]
|
||||
pub lines: Option<u32>,
|
||||
/// Minimum syslog priority: `err`, `warning`, `info`, `debug`.
|
||||
#[serde(default)]
|
||||
pub priority: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ManagerServer {
|
||||
socket: PathBuf,
|
||||
|
|
@ -1832,6 +1889,36 @@ pub enum Flavor {
|
|||
/// instead of using the hardcoded flavor default. See `docs/conventions.md::Tool groups`.
|
||||
const TOOL_GROUPS_ENV: &str = "HIVE_TOOL_GROUPS";
|
||||
|
||||
/// `HIVE_CAPABILITIES` env var injected by `meta::render_flake` when the
|
||||
/// operator grants capabilities to this agent. Comma-separated
|
||||
/// `hive_sh4re::Capability` snake_case names. Absent = no extra capabilities.
|
||||
const CAPABILITIES_ENV: &str = "HIVE_CAPABILITIES";
|
||||
|
||||
/// Returns the MCP tool names (without `mcp__hyperhive__` prefix) that are
|
||||
/// unlocked by the agent's current capability set. These are added to the
|
||||
/// `--allowedTools` list so claude can call them without prompting, and
|
||||
/// hive-c0re performs a second server-side capability check before executing.
|
||||
fn allowed_capability_tools() -> Vec<String> {
|
||||
let raw = match std::env::var(CAPABILITIES_ENV) {
|
||||
Ok(v) if !v.trim().is_empty() => v,
|
||||
_ => return vec![],
|
||||
};
|
||||
let mut tools = Vec::new();
|
||||
for token in raw.split(',') {
|
||||
let t = token.trim().to_ascii_lowercase();
|
||||
match t.as_str() {
|
||||
"read_host_journal" => tools.push("get_host_journal".to_owned()),
|
||||
// manage_root_agent doesn't expose new MCP tools (it gates
|
||||
// existing lifecycle tools via the topology enforcement).
|
||||
"manage_root_agent" => {}
|
||||
unknown => {
|
||||
tracing::warn!(capability = %unknown, "unrecognised capability in HIVE_CAPABILITIES — skipped");
|
||||
}
|
||||
}
|
||||
}
|
||||
tools
|
||||
}
|
||||
|
||||
/// Resolve the active tool groups for a harness session.
|
||||
///
|
||||
/// Reads `HIVE_TOOL_GROUPS` from the environment first. Each comma-separated
|
||||
|
|
@ -1933,6 +2020,13 @@ pub fn allowed_tools_arg(flavor: Flavor) -> String {
|
|||
.collect();
|
||||
let groups = effective_tool_groups(flavor);
|
||||
all.extend(allowed_mcp_tools(&groups));
|
||||
// Capability-gated tools: added to --allowedTools when HIVE_CAPABILITIES
|
||||
// includes the corresponding capability. hive-c0re performs a second
|
||||
// server-side check, so this is a usability gate (no annoying prompts),
|
||||
// not the security boundary.
|
||||
for tool in allowed_capability_tools() {
|
||||
all.push(format!("mcp__{SERVER_NAME}__{tool}"));
|
||||
}
|
||||
all.join(",")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -310,6 +310,9 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc<Coordinator>) ->
|
|||
message: format!("{e:#}"),
|
||||
},
|
||||
},
|
||||
AgentRequest::GetHostJournal { unit, container, lines, priority } => {
|
||||
dispatch_host_journal(agent, unit, container, lines, priority).await
|
||||
}
|
||||
// Manager-only variants are not valid on the agent socket.
|
||||
_ => AgentResponse::Err {
|
||||
message: "request not supported on agent socket".to_owned(),
|
||||
|
|
@ -317,6 +320,68 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc<Coordinator>) ->
|
|||
}
|
||||
}
|
||||
|
||||
/// Handle `GetHostJournal` from the agent socket. Capability-gated:
|
||||
/// the calling agent must hold `read_host_journal` in
|
||||
/// `meta/capabilities.json`. Runs `journalctl` host-side and returns
|
||||
/// the output as a `HostJournal` response.
|
||||
///
|
||||
/// Also called from `manager_server` so the manager can use the same
|
||||
/// tool without duplicating the journalctl invocation. The manager
|
||||
/// is exempt from the capability check (it holds all capabilities
|
||||
/// implicitly until the capability system enforcement is complete).
|
||||
pub async fn dispatch_host_journal(
|
||||
agent: &str,
|
||||
unit: &Option<String>,
|
||||
container: &Option<String>,
|
||||
lines: &Option<u32>,
|
||||
priority: &Option<String>,
|
||||
) -> AgentResponse {
|
||||
if !crate::capabilities::has_cap(agent, hive_sh4re::Capability::ReadHostJournal) {
|
||||
return AgentResponse::Err {
|
||||
message: "agent does not have the read_host_journal capability".to_owned(),
|
||||
};
|
||||
}
|
||||
let n = lines.unwrap_or(100).min(500);
|
||||
let mut args: Vec<String> = vec![
|
||||
"--no-pager".to_owned(),
|
||||
"--output=short".to_owned(),
|
||||
"-n".to_owned(),
|
||||
n.to_string(),
|
||||
];
|
||||
if let Some(u) = unit {
|
||||
args.push("-u".to_owned());
|
||||
args.push(u.clone());
|
||||
}
|
||||
if let Some(c) = container {
|
||||
let machine = crate::lifecycle::container_name(c);
|
||||
args.push("-M".to_owned());
|
||||
args.push(machine);
|
||||
}
|
||||
if let Some(p) = priority {
|
||||
args.push("-p".to_owned());
|
||||
args.push(p.clone());
|
||||
}
|
||||
tracing::info!(%agent, ?args, "get_host_journal");
|
||||
match tokio::process::Command::new("journalctl")
|
||||
.args(&args)
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
Ok(out) => {
|
||||
let content = if out.status.success() || !out.stdout.is_empty() {
|
||||
String::from_utf8_lossy(&out.stdout).into_owned()
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
format!("journalctl exited {}: {stderr}", out.status)
|
||||
};
|
||||
AgentResponse::HostJournal { content }
|
||||
}
|
||||
Err(e) => AgentResponse::Err {
|
||||
message: format!("journalctl spawn failed: {e:#}"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Fan out one message to each recipient in `targets`. Skips the sender
|
||||
/// itself. Returns a list of `"<agent>: <error>"` strings for any delivery
|
||||
/// failures (empty = all good).
|
||||
|
|
|
|||
95
hive-c0re/src/capabilities.rs
Normal file
95
hive-c0re/src/capabilities.rs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
//! Per-agent capability configuration. Stored at
|
||||
//! `/var/lib/hyperhive/meta/capabilities.json` alongside `topology.json`
|
||||
//! and `tool-groups.json`.
|
||||
//!
|
||||
//! Format: a JSON object mapping agent name to an array of
|
||||
//! `hive_sh4re::Capability` snake_case strings:
|
||||
//!
|
||||
//! ```json
|
||||
//! {
|
||||
//! "atlas": ["read_host_journal"],
|
||||
//! "root": ["manage_root_agent"]
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! An absent entry (or an absent file) means "no extra capabilities".
|
||||
//! `render_flake` in `meta.rs` reads this file and injects
|
||||
//! `HIVE_CAPABILITIES` into each agent's systemd service env; absent
|
||||
//! entries emit no env var so agents without capabilities don't trigger
|
||||
//! a spurious rebuild.
|
||||
//!
|
||||
//! Write path: `set_caps` is called from the dashboard action handler
|
||||
//! that the operator uses to grant/revoke capabilities per agent.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
const CAPABILITIES_FILE: &str = "capabilities.json";
|
||||
|
||||
#[must_use]
|
||||
pub fn capabilities_path() -> PathBuf {
|
||||
crate::meta::meta_dir().join(CAPABILITIES_FILE)
|
||||
}
|
||||
|
||||
/// Read the per-agent capability map. Returns an empty map when the
|
||||
/// file is absent or unparsable — callers treat a missing entry as
|
||||
/// "no extra capabilities".
|
||||
#[must_use]
|
||||
pub fn read() -> BTreeMap<String, Vec<String>> {
|
||||
let path = capabilities_path();
|
||||
let Ok(raw) = std::fs::read_to_string(&path) else {
|
||||
return BTreeMap::new();
|
||||
};
|
||||
serde_json::from_str(&raw).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Look up the configured capabilities for one agent. Returns an empty
|
||||
/// vec when the agent has no entry.
|
||||
#[must_use]
|
||||
pub fn caps_for(name: &str) -> Vec<String> {
|
||||
read().get(name).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Check whether an agent holds a specific capability.
|
||||
#[must_use]
|
||||
pub fn has_cap(name: &str, cap: hive_sh4re::Capability) -> bool {
|
||||
caps_for(name)
|
||||
.iter()
|
||||
.any(|s| s.eq_ignore_ascii_case(cap.as_str()))
|
||||
}
|
||||
|
||||
/// Persist the full capability map. Sorted JSON output keeps diffs
|
||||
/// minimal. Best-effort — returns `io::Error` so callers decide
|
||||
/// whether to abort or log.
|
||||
pub fn write(map: &BTreeMap<String, Vec<String>>) -> std::io::Result<()> {
|
||||
let path = capabilities_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let text = serde_json::to_string_pretty(map)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||
std::fs::write(&path, format!("{text}\n"))
|
||||
}
|
||||
|
||||
/// Set the capabilities for one agent and persist the map. An empty
|
||||
/// `caps` vec removes the entry (agent has no capabilities).
|
||||
pub fn set_caps(name: &str, caps: &[String]) -> std::io::Result<()> {
|
||||
let mut current = read();
|
||||
if caps.is_empty() {
|
||||
current.remove(name);
|
||||
} else {
|
||||
current.insert(name.to_owned(), caps.to_vec());
|
||||
}
|
||||
write(¤t)
|
||||
}
|
||||
|
||||
/// Remove an agent from the capability map entirely. Called by
|
||||
/// `meta::sync_agents` when an agent is deprovisioned so stale entries
|
||||
/// don't accumulate. No-op if the agent has no entry.
|
||||
pub fn remove_agent(name: &str) -> std::io::Result<()> {
|
||||
let mut current = read();
|
||||
if current.remove(name).is_some() {
|
||||
write(¤t)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@
|
|||
|
||||
pub mod actions;
|
||||
pub mod agent_ports;
|
||||
pub mod capabilities;
|
||||
pub mod agent_server;
|
||||
pub mod agent_sockets;
|
||||
pub mod gateway_nginx;
|
||||
|
|
|
|||
|
|
@ -579,6 +579,15 @@ async fn dispatch(req: &ManagerRequest, coord: &Arc<Coordinator>) -> ManagerResp
|
|||
message: format!("{e:#}"),
|
||||
},
|
||||
},
|
||||
// GetHostJournal is an agent-socket-only capability-gated variant.
|
||||
// The manager can use the existing GetLogs tool for per-container
|
||||
// logs. Route to the agent_server handler for consistency.
|
||||
ManagerRequest::GetHostJournal { unit, container, lines, priority } => {
|
||||
crate::agent_server::dispatch_host_journal(
|
||||
MANAGER_AGENT, unit, container, lines, priority,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -148,6 +148,11 @@ pub async fn sync_agents(
|
|||
if crate::tool_groups::tool_groups_path().exists() {
|
||||
git(&dir, &["add", "tool-groups.json"]).await?;
|
||||
}
|
||||
// Stage capabilities.json when it exists. Created on first
|
||||
// `set_caps` call; absent = no agents have extra capabilities.
|
||||
if crate::capabilities::capabilities_path().exists() {
|
||||
git(&dir, &["add", "capabilities.json"]).await?;
|
||||
}
|
||||
// Stage roles.json when it exists. Written by topology::write_roles /
|
||||
// reconcile_roles on first role assignment or manager default seeding.
|
||||
// Without this, roles.json appears as untracked in the meta repo
|
||||
|
|
@ -450,7 +455,7 @@ where
|
|||
let pronouns_escaped = operator_pronouns.replace('\\', "\\\\").replace('"', "\\\"");
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" dashboardPort = {dashboard_port};\n operatorPronouns = \"{pronouns_escaped}\";\n mkAgent = {{ name, isManager, port, parent ? null, toolGroups ? null }}:"
|
||||
" dashboardPort = {dashboard_port};\n operatorPronouns = \"{pronouns_escaped}\";\n mkAgent = {{ name, isManager, port, parent ? null, toolGroups ? null, capabilities ? null }}:"
|
||||
);
|
||||
out.push_str(
|
||||
r#" let
|
||||
|
|
@ -461,6 +466,7 @@ where
|
|||
service = "hive-ag3nt";
|
||||
parentEnv = if parent == null then {} else { HIVE_PARENT = parent; };
|
||||
toolGroupsEnv = if toolGroups == null then {} else { HIVE_TOOL_GROUPS = toolGroups; };
|
||||
capabilitiesEnv = if capabilities == null then {} else { HIVE_CAPABILITIES = capabilities; };
|
||||
in
|
||||
base.extendModules {
|
||||
modules = [
|
||||
|
|
@ -494,7 +500,7 @@ where
|
|||
HYPERHIVE_STATE_DIR = "/agents/${name}/state";
|
||||
HYPERHIVE_HARNESS_DIR = "/agents/${name}/harness";
|
||||
};
|
||||
systemd.services.${service}.environment = parentEnv // toolGroupsEnv // {
|
||||
systemd.services.${service}.environment = parentEnv // toolGroupsEnv // capabilitiesEnv // {
|
||||
HIVE_PORT = toString port;
|
||||
HIVE_LABEL = name;
|
||||
HIVE_DASHBOARD_PORT = toString dashboardPort;
|
||||
|
|
@ -546,6 +552,7 @@ where
|
|||
// on first run with manager as root + everyone else under manager.
|
||||
let topology = crate::topology::read();
|
||||
let tool_groups_map = crate::tool_groups::read();
|
||||
let capabilities_map = crate::capabilities::read();
|
||||
for spec in agents {
|
||||
let parent_attr = topology
|
||||
.get(&spec.name)
|
||||
|
|
@ -562,15 +569,26 @@ where
|
|||
let joined = groups.join(",");
|
||||
format!("\"{joined}\"")
|
||||
};
|
||||
// Emit `capabilities = "cap1,cap2"` when the operator has
|
||||
// granted capabilities to this agent. Absent entry = null = no
|
||||
// capability env var injected, capability-gated tools hidden.
|
||||
let caps = capabilities_map.get(&spec.name).cloned().unwrap_or_default();
|
||||
let capabilities_attr = if caps.is_empty() {
|
||||
"null".to_owned()
|
||||
} else {
|
||||
let joined = caps.join(",");
|
||||
format!("\"{joined}\"")
|
||||
};
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" {} = mkAgent {{ name = \"{}\"; isManager = {}; port = {}; parent = {}; toolGroups = {}; }};",
|
||||
" {} = mkAgent {{ name = \"{}\"; isManager = {}; port = {}; parent = {}; toolGroups = {}; capabilities = {}; }};",
|
||||
spec.name,
|
||||
spec.name,
|
||||
if spec.is_manager { "true" } else { "false" },
|
||||
spec.port,
|
||||
parent_attr,
|
||||
tool_groups_attr,
|
||||
capabilities_attr,
|
||||
);
|
||||
}
|
||||
out.push_str(" };\n };\n}\n");
|
||||
|
|
|
|||
|
|
@ -428,6 +428,25 @@ pub enum Request {
|
|||
/// crashed-mid-turn sessions. See
|
||||
/// `docs/conventions.md::Broker delivery + ack cycle`.
|
||||
RequeueInflight,
|
||||
/// *(capability-gated: `read_host_journal`)* Fetch recent lines
|
||||
/// from the host journal. Filters are all optional; omitting all
|
||||
/// returns the last `lines` entries from the global journal.
|
||||
GetHostJournal {
|
||||
/// Filter to a specific systemd unit (e.g. `hive-c0re.service`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
unit: Option<String>,
|
||||
/// Filter to a specific nspawn container by logical agent name
|
||||
/// (e.g. `"iris"` → machine `h-iris`). Adds `-M` flag.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
container: Option<String>,
|
||||
/// Number of journal lines to return (default 100, max 500).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
lines: Option<u32>,
|
||||
/// Minimum syslog priority level: `err`, `warning`, `info`, `debug`.
|
||||
/// Corresponds to journalctl `-p LEVEL`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
priority: Option<String>,
|
||||
},
|
||||
|
||||
// ---- privileged (manager socket only for now) ---------------------------
|
||||
|
||||
|
|
@ -556,6 +575,10 @@ pub enum Response {
|
|||
/// `GetLogs` result: journal lines for the requested container.
|
||||
/// Returned on the manager socket only.
|
||||
Logs { content: String },
|
||||
/// `GetHostJournal` result: host journal lines matching the
|
||||
/// requested filters. Returned on the agent socket when the agent
|
||||
/// holds the `read_host_journal` capability.
|
||||
HostJournal { content: String },
|
||||
/// `ListSchedules` result. Snapshot of every schedule.
|
||||
/// Returned on the manager socket only.
|
||||
Schedules { schedules: Vec<WireSchedule> },
|
||||
|
|
@ -821,6 +844,43 @@ impl ToolGroup {
|
|||
}
|
||||
}
|
||||
|
||||
/// Per-agent capability grants. Stored in `meta/capabilities.json`
|
||||
/// (same shape as `tool-groups.json`: `{ "alice": ["read_host_journal"] }`).
|
||||
/// Capabilities control system-level access that hive-c0re enforces
|
||||
/// at dispatch time; they are orthogonal to tool groups (which control
|
||||
/// which MCP tools the harness exposes to claude).
|
||||
///
|
||||
/// Injected into containers as `HIVE_CAPABILITIES` (comma-separated
|
||||
/// snake_case) via `meta::render_flake`. The harness reads this to
|
||||
/// conditionally register capability-gated MCP tools so claude only
|
||||
/// sees tools it can actually invoke. See `docs/conventions.md::Capabilities`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Capability {
|
||||
/// Agent can lifecycle-manage the root agent (kill/start/restart)
|
||||
/// on behalf of the hive when the root has crashed. Named capability
|
||||
/// for the existing manager privilege — future topology enforcement
|
||||
/// will gate this via the capability system instead of the hardcoded
|
||||
/// `container == MANAGER_CONTAINER` check.
|
||||
ManageRootAgent,
|
||||
/// Agent can read the full host journal via `GetHostJournal`.
|
||||
/// hive-c0re checks this capability before running journalctl.
|
||||
/// MCP tool `get_host_journal` is only registered in the harness
|
||||
/// when this capability is present.
|
||||
ReadHostJournal,
|
||||
}
|
||||
|
||||
impl Capability {
|
||||
/// Canonical snake_case name for this capability (matches serde).
|
||||
#[must_use]
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::ManageRootAgent => "manage_root_agent",
|
||||
Self::ReadHostJournal => "read_host_journal",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedule row shape on the wire — mirror of
|
||||
/// `scheduled_prompts::Schedule` but in the public crate so the
|
||||
/// dashboard and agent surfaces can deserialize without depending
|
||||
|
|
|
|||
Loading…
Reference in a new issue