fix(#1004,#1006): get_host_journal - JournalPriority enum, grep/since/until, default 30/max 100, verbatim container, fix doc comment

This commit is contained in:
damocles 2026-06-01 20:48:02 +02:00 committed by mara
commit b9ecacaafe
6 changed files with 96 additions and 30 deletions

1
Cargo.lock generated
View file

@ -1260,6 +1260,7 @@ dependencies = [
name = "hive-sh4re"
version = "0.1.0"
dependencies = [
"schemars",
"serde",
]

View file

@ -863,11 +863,14 @@ impl AgentServer {
// 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. \
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`)."
`container`: nspawn machine name verbatim (e.g. `h-iris`). Omit for host journal. \
`lines`: how many lines (default 30, max 100). \
`priority`: minimum syslog level enum. \
`grep`: regex matched against log message fields (journalctl --grep). \
`since`: show entries on or newer than this (e.g. `-1h`, `2024-01-01 12:00:00`). \
`until`: show entries on or older than this."
)]
async fn get_host_journal(
&self,
@ -881,6 +884,9 @@ impl AgentServer {
container: args.container,
lines: args.lines,
priority: args.priority,
grep: args.grep,
since: args.since,
until: args.until,
})
.await;
let result = match resp {
@ -1177,16 +1183,24 @@ 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.
/// nspawn machine name verbatim (e.g. `h-iris`). Omit for host journal.
#[serde(default)]
pub container: Option<String>,
/// Number of lines to return (default 100, max 500).
/// Number of lines to return (default 30, max 100).
#[serde(default)]
pub lines: Option<u32>,
/// Minimum syslog priority: `err`, `warning`, `info`, `debug`.
/// Minimum syslog priority level.
#[serde(default)]
pub priority: Option<String>,
pub priority: Option<hive_sh4re::JournalPriority>,
/// Regex to match against log message fields (journalctl --grep).
#[serde(default)]
pub grep: Option<String>,
/// Show entries on or newer than this timestamp (e.g. `-1h`).
#[serde(default)]
pub since: Option<String>,
/// Show entries on or older than this timestamp.
#[serde(default)]
pub until: Option<String>,
}
#[derive(Debug, Clone)]

View file

@ -310,8 +310,8 @@ 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
AgentRequest::GetHostJournal { unit, container, lines, priority, grep, since, until } => {
dispatch_host_journal(agent, unit, container, lines, priority, grep, since, until).await
}
// Manager-only variants are not valid on the agent socket.
_ => AgentResponse::Err {
@ -320,28 +320,29 @@ 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
/// Handle `GetHostJournal` from both the agent and manager sockets.
/// 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).
/// The manager is not exempt - grant `read_host_journal` in
/// `meta/capabilities.json` to enable it for any agent including the manager.
pub async fn dispatch_host_journal(
agent: &str,
unit: &Option<String>,
container: &Option<String>,
lines: &Option<u32>,
priority: &Option<String>,
priority: &Option<hive_sh4re::JournalPriority>,
grep: &Option<String>,
since: &Option<String>,
until: &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 n = lines.unwrap_or(30).min(100);
let mut args: Vec<String> = vec![
"--no-pager".to_owned(),
"--output=short".to_owned(),
@ -353,13 +354,21 @@ pub async fn dispatch_host_journal(
args.push(u.clone());
}
if let Some(c) = container {
let machine = crate::lifecycle::container_name(c);
args.push("-M".to_owned());
args.push(machine);
args.push(c.clone());
}
if let Some(p) = priority {
args.push("-p".to_owned());
args.push(p.clone());
args.push(p.as_str().to_owned());
}
if let Some(g) = grep {
args.push(format!("--grep={g}"));
}
if let Some(s) = since {
args.push(format!("--since={s}"));
}
if let Some(u) = until {
args.push(format!("--until={u}"));
}
tracing::info!(%agent, ?args, "get_host_journal");
match tokio::process::Command::new("journalctl")

View file

@ -582,9 +582,9 @@ async fn dispatch(req: &ManagerRequest, coord: &Arc<Coordinator>) -> ManagerResp
// 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 } => {
ManagerRequest::GetHostJournal { unit, container, lines, priority, grep, since, until } => {
crate::agent_server::dispatch_host_journal(
MANAGER_AGENT, unit, container, lines, priority,
MANAGER_AGENT, unit, container, lines, priority, grep, since, until,
)
.await
}

View file

@ -7,4 +7,5 @@ version.workspace = true
workspace = true
[dependencies]
schemars.workspace = true
serde.workspace = true

View file

@ -435,17 +435,26 @@ pub enum Request {
/// 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.
/// Machine name to pass to journalctl `-M` verbatim (e.g. `h-iris`).
/// The caller is responsible for the correct nspawn machine name.
#[serde(default, skip_serializing_if = "Option::is_none")]
container: Option<String>,
/// Number of journal lines to return (default 100, max 500).
/// Number of journal lines to return (default 30, max 100).
#[serde(default, skip_serializing_if = "Option::is_none")]
lines: Option<u32>,
/// Minimum syslog priority level: `err`, `warning`, `info`, `debug`.
/// Corresponds to journalctl `-p LEVEL`.
/// Minimum syslog priority level.
#[serde(default, skip_serializing_if = "Option::is_none")]
priority: Option<String>,
priority: Option<JournalPriority>,
/// Regex to match against log message fields (journalctl `--grep`).
#[serde(default, skip_serializing_if = "Option::is_none")]
grep: Option<String>,
/// Show entries on or newer than this timestamp (journalctl `--since`).
/// ISO 8601 or journalctl-accepted relative strings (e.g. `"-1h"`).
#[serde(default, skip_serializing_if = "Option::is_none")]
since: Option<String>,
/// Show entries on or older than this timestamp (journalctl `--until`).
#[serde(default, skip_serializing_if = "Option::is_none")]
until: Option<String>,
},
// ---- privileged (manager socket only for now) ---------------------------
@ -847,6 +856,38 @@ 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
/// Syslog priority levels for `GetHostJournal`. Serialised as lowercase
/// strings matching journalctl `-p` accepted values.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum JournalPriority {
Emerg,
Alert,
Crit,
Err,
Warning,
Notice,
Info,
Debug,
}
impl JournalPriority {
/// Returns the lowercase string journalctl expects for `-p`.
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Emerg => "emerg",
Self::Alert => "alert",
Self::Crit => "crit",
Self::Err => "err",
Self::Warning => "warning",
Self::Notice => "notice",
Self::Info => "info",
Self::Debug => "debug",
}
}
}
/// at dispatch time; they are orthogonal to tool groups (which control
/// which MCP tools the harness exposes to claude).
///