diff --git a/Cargo.toml b/Cargo.toml index 327d3cbe..b8ac38ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["hive-ag3nt", "hive-c0re", "hive-forge", "hive-matrix-mcp", "hive-sh4re"] +members = ["hive-ag3nt", "hive-c0re", "hive-forge", "hive-matrix-mcp", "hive-priv", "hive-sh4re"] [workspace.package] edition = "2024" diff --git a/hive-c0re/src/lib.rs b/hive-c0re/src/lib.rs index 44728f3a..d9444f5c 100644 --- a/hive-c0re/src/lib.rs +++ b/hive-c0re/src/lib.rs @@ -39,6 +39,7 @@ pub mod matrix; pub mod meta; pub mod migrate; pub mod operator_questions; +pub mod priv_client; pub mod questions; pub mod rebuild_queue; pub mod reminder_scheduler; diff --git a/hive-c0re/src/priv_client.rs b/hive-c0re/src/priv_client.rs new file mode 100644 index 00000000..14abf071 --- /dev/null +++ b/hive-c0re/src/priv_client.rs @@ -0,0 +1,123 @@ +//! Async client for the `hive-priv` privileged-helper socket. +//! +//! Exposes a standalone async function per operation. Each call opens a +//! fresh connection to `/run/hive/priv.sock`, sends one JSON line, reads +//! the response, and closes. Connection-per-call is intentional: priv +//! calls are infrequent (once per rebuild step), so simplicity wins over +//! a persistent connection. + +use anyhow::{Context as _, Result, bail}; +use hive_sh4re::priv_proto::{PRIV_SOCK, BindMount, PrivRequest, PrivResponse}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::UnixStream; + +/// Send a single request to `hive-priv` and return the response. +pub async fn call(req: &PrivRequest) -> Result { + let mut stream = UnixStream::connect(PRIV_SOCK) + .await + .context("connect to hive-priv socket")?; + let line = serde_json::to_string(req).context("serialise PrivRequest")? + "\n"; + stream + .write_all(line.as_bytes()) + .await + .context("send request to hive-priv")?; + stream.shutdown().await.context("shutdown write half")?; + let mut resp_line = String::new(); + BufReader::new(stream) + .read_line(&mut resp_line) + .await + .context("read response from hive-priv")?; + serde_json::from_str(&resp_line).context("parse PrivResponse") +} + +pub async fn start_container(name: &str) -> Result<()> { + ok(call(&PrivRequest::StartContainer { name: name.to_owned() }).await?) +} + +pub async fn stop_container(name: &str) -> Result<()> { + ok(call(&PrivRequest::StopContainer { name: name.to_owned() }).await?) +} + +pub async fn kill_container(name: &str) -> Result<()> { + ok(call(&PrivRequest::KillContainer { name: name.to_owned() }).await?) +} + +pub async fn update_container(name: &str) -> Result<(String, String)> { + check(call(&PrivRequest::UpdateContainer { name: name.to_owned() }).await?) +} + +pub async fn create_container(name: &str) -> Result<(String, String)> { + check(call(&PrivRequest::CreateContainer { name: name.to_owned() }).await?) +} + +pub async fn destroy_container(name: &str) -> Result<()> { + ok(call(&PrivRequest::DestroyContainer { name: name.to_owned() }).await?) +} + +pub async fn list_containers() -> Result { + let (stdout, _) = check(call(&PrivRequest::ListContainers).await?)?; + Ok(stdout) +} + +pub async fn write_nspawn_flags(container: &str, binds: &[BindMount]) -> Result<()> { + ok(call(&PrivRequest::WriteNspawnFlags { + container: container.to_owned(), + binds: binds.to_vec(), + }).await?) +} + +pub use hive_sh4re::priv_proto::BindMount; + +pub async fn write_resource_limits( + container: &str, + memory_max: &str, + cpu_quota: &str, +) -> Result<()> { + ok(call(&PrivRequest::WriteResourceLimits { + container: container.to_owned(), + memory_max: memory_max.to_owned(), + cpu_quota: cpu_quota.to_owned(), + }).await?) +} + +pub async fn remove_service_dropin(container: &str) -> Result<()> { + ok(call(&PrivRequest::RemoveServiceDropin { + container: container.to_owned(), + }).await?) +} + +pub async fn daemon_reload() -> Result<()> { + ok(call(&PrivRequest::DaemonReload).await?) +} + +pub async fn reload_gateway_nginx() -> Result<()> { + ok(call(&PrivRequest::ReloadGatewayNginx).await?) +} + +pub async fn chown_socket_dir(agent_name: &str, uid: u32, gid: u32) -> Result<()> { + ok(call(&PrivRequest::ChownSocketDir { + agent_name: agent_name.to_owned(), + uid, + gid, + }).await?) +} + +pub async fn chmod_socket_dir(agent_name: &str, mode: u32) -> Result<()> { + ok(call(&PrivRequest::ChmodSocketDir { + agent_name: agent_name.to_owned(), + mode, + }).await?) +} + +fn check(resp: PrivResponse) -> Result<(String, String)> { + if resp.ok { + Ok((resp.stdout, resp.stderr)) + } else { + bail!("{}", resp.error.as_deref().unwrap_or("hive-priv returned error")) + } +} + +fn ok(resp: PrivResponse) -> Result<()> { + check(resp)?; + Ok(()) +} diff --git a/hive-priv/Cargo.toml b/hive-priv/Cargo.toml new file mode 100644 index 00000000..eccd4f69 --- /dev/null +++ b/hive-priv/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "hive-priv" +edition.workspace = true +version.workspace = true + +[lints] +workspace = true + +[dependencies] +anyhow.workspace = true +hive-sh4re.workspace = true +serde_json.workspace = true +tokio.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true diff --git a/hive-priv/src/main.rs b/hive-priv/src/main.rs new file mode 100644 index 00000000..37753711 --- /dev/null +++ b/hive-priv/src/main.rs @@ -0,0 +1,409 @@ +//! Minimal privileged helper for hive-c0re. +//! +//! Runs as root. Exposes a narrow unix socket at `/run/hive/priv.sock` +//! that accepts `PrivRequest` JSON lines and executes only the +//! operations that genuinely require root. All coordination logic, +//! broker, HTTP, and scheduling stay in the unprivileged hive-c0re +//! process. +//! +//! **Security model**: every request is validated against a strict +//! container-name allowlist before any filesystem or process operation. +//! Only containers whose names match the hive convention (`h-*`, +//! the manager container, or known sibling service containers) are +//! accepted. Every variant maps to a single known operation — no +//! arbitrary command pass-through. +//! +//! **Socket activation**: when systemd passes the listener socket via +//! `LISTEN_FDS=1` + `LISTEN_PID=`, the inherited fd 3 is used +//! instead of binding a fresh socket. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context as _, Result, bail}; +use hive_sh4re::priv_proto::{AGENT_PREFIX, MANAGER_NAME, META_DIR, PRIV_SOCK, SIBLING_CONTAINERS, BindMount, PrivRequest, PrivResponse}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::{UnixListener, UnixStream}; +use tokio::process::Command; + +/// Root of the per-agent unix-socket dirs on the host. +const SOCKET_DIR_ROOT: &str = "/run/hive-agent"; + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info".into()), + ) + .init(); + + let listener = socket_listener()?; + tracing::info!("hive-priv listening"); + + loop { + match listener.accept().await { + Ok((stream, _)) => { + tokio::spawn(handle(stream)); + } + Err(e) => { + tracing::error!(error = %e, "accept failed"); + } + } + } +} + +fn socket_listener() -> Result { + // Socket activation: systemd passes the socket as fd 3 when + // LISTEN_FDS >= 1 and LISTEN_PID matches our pid. + let listen_fds: Option = std::env::var("LISTEN_FDS") + .ok() + .and_then(|s| s.parse().ok()); + let listen_pid: Option = std::env::var("LISTEN_PID") + .ok() + .and_then(|s| s.parse().ok()); + + if let (Some(n), Some(p)) = (listen_fds, listen_pid) { + if n >= 1 && p == std::process::id() { + // SAFETY: systemd has passed us a ready UnixListener on fd 3. + let std_listener = unsafe { + use std::os::unix::io::FromRawFd; + std::os::unix::net::UnixListener::from_raw_fd(3) + }; + std_listener + .set_nonblocking(true) + .context("set socket non-blocking")?; + let listener = + tokio::net::UnixListener::from_std(std_listener).context("wrap systemd socket")?; + tracing::info!("using systemd-activated socket"); + return Ok(listener); + } + } + + // Fallback: bind the socket ourselves. + let path = Path::new(PRIV_SOCK); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("create {}", parent.display()))?; + } + let _ = std::fs::remove_file(path); + let listener = UnixListener::bind(path).with_context(|| format!("bind {PRIV_SOCK}"))?; + // Mode 0660: only the hive-core group can connect. + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o660)) + .context("chmod priv.sock")?; + tracing::info!(path = PRIV_SOCK, "bound priv socket"); + Ok(listener) +} + +async fn handle(stream: UnixStream) { + let (reader, mut writer) = stream.into_split(); + let mut lines = BufReader::new(reader).lines(); + while let Ok(Some(line)) = lines.next_line().await { + let resp = dispatch(&line).await; + let mut json = serde_json::to_string(&resp).unwrap_or_else(|e| { + format!("{{\"ok\":false,\"stdout\":\"\",\"stderr\":\"\",\"error\":\"serialise failed: {e}\"}}") + }); + json.push('\n'); + if let Err(e) = writer.write_all(json.as_bytes()).await { + tracing::warn!(error = %e, "write response failed"); + break; + } + } +} + +async fn dispatch(line: &str) -> PrivResponse { + match serde_json::from_str::(line) { + Ok(req) => match exec(req).await { + Ok((stdout, stderr)) => PrivResponse { + ok: true, + stdout, + stderr, + error: None, + }, + Err(e) => PrivResponse { + ok: false, + stdout: String::new(), + stderr: String::new(), + error: Some(format!("{e:#}")), + }, + }, + Err(e) => PrivResponse { + ok: false, + stdout: String::new(), + stderr: String::new(), + error: Some(format!("parse request: {e}")), + }, + } +} + +/// Execute a validated `PrivRequest`. Returns `(stdout, stderr)` on success. +async fn exec(req: PrivRequest) -> Result<(String, String)> { + match req { + PrivRequest::StartContainer { ref name } => { + validate_container_name(name)?; + container_run(&["start", &container_system_name(name)]).await + } + + PrivRequest::StopContainer { ref name } => { + validate_container_name(name)?; + container_run(&["stop", &container_system_name(name)]).await + } + + PrivRequest::KillContainer { ref name } => { + validate_container_name(name)?; + container_run(&["kill", &container_system_name(name)]).await + } + + PrivRequest::UpdateContainer { ref name } => { + validate_container_name(name)?; + let flake_ref = agent_flake_ref(name); + container_run(&["update", &container_system_name(name), "--flake", &flake_ref]).await + } + + PrivRequest::CreateContainer { ref name } => { + validate_container_name(name)?; + let flake_ref = agent_flake_ref(name); + container_run(&["create", &container_system_name(name), "--flake", &flake_ref]).await + } + + PrivRequest::DestroyContainer { ref name } => { + validate_container_name(name)?; + container_run(&["destroy", &container_system_name(name)]).await + } + + PrivRequest::ListContainers => container_run(&["list"]).await, + + PrivRequest::WriteNspawnFlags { ref container, ref binds } => { + validate_container_system_name(container)?; + for bind in binds { + validate_bind_path(&bind.host_path)?; + validate_bind_path(&bind.container_path)?; + } + write_nspawn_flags(container, binds)?; + Ok((String::new(), String::new())) + } + + PrivRequest::WriteResourceLimits { + ref container, + ref memory_max, + ref cpu_quota, + } => { + validate_container_system_name(container)?; + let dir = format!("/run/systemd/system/container@{container}.service.d"); + std::fs::create_dir_all(&dir).with_context(|| format!("create {dir}"))?; + let path = format!("{dir}/hyperhive-limits.conf"); + let content = format!("[Service]\nMemoryMax={memory_max}\nCPUQuota={cpu_quota}\n"); + std::fs::write(&path, content).with_context(|| format!("write {path}"))?; + Ok((String::new(), String::new())) + } + + PrivRequest::RemoveServiceDropin { ref container } => { + validate_container_system_name(container)?; + let dir = format!("/run/systemd/system/container@{container}.service.d"); + if Path::new(&dir).exists() { + std::fs::remove_dir_all(&dir) + .with_context(|| format!("remove {dir}"))?; + } + Ok((String::new(), String::new())) + } + + PrivRequest::DaemonReload => { + let out = Command::new("systemctl") + .arg("daemon-reload") + .output() + .await + .context("invoke systemctl daemon-reload")?; + if !out.status.success() { + bail!( + "systemctl daemon-reload failed ({}): {}", + out.status, + String::from_utf8_lossy(&out.stderr).trim() + ); + } + Ok((String::new(), String::new())) + } + + PrivRequest::ReloadGatewayNginx => { + let out = Command::new("systemd-run") + .args(["--machine=hive-gateway", "--quiet", "--", "nginx", "-s", "reload"]) + .output() + .await + .context("invoke systemd-run for gateway nginx reload")?; + if !out.status.success() { + bail!( + "gateway nginx reload failed ({}): {}", + out.status, + String::from_utf8_lossy(&out.stderr).trim() + ); + } + Ok((String::new(), String::new())) + } + + PrivRequest::ChownSocketDir { ref agent_name, uid, gid } => { + validate_agent_name(agent_name)?; + let path = socket_dir_path(agent_name); + std::os::unix::fs::chown(&path, Some(uid), Some(gid)) + .with_context(|| format!("chown {} to {uid}:{gid}", path.display()))?; + Ok((String::new(), String::new())) + } + + PrivRequest::ChmodSocketDir { ref agent_name, mode } => { + validate_agent_name(agent_name)?; + let path = socket_dir_path(agent_name); + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)) + .with_context(|| format!("chmod {:o} {}", mode, path.display()))?; + Ok((String::new(), String::new())) + } + } +} + +/// Invoke `nixos-container` with the given args, log output to journald. +async fn container_run(args: &[&str]) -> Result<(String, String)> { + let out = Command::new("nixos-container") + .args(args) + .output() + .await + .context("invoke nixos-container")?; + let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); + for line in stdout.lines() { + tracing::info!(target: "nixos-container", "{line}"); + } + for line in stderr.lines() { + tracing::warn!(target: "nixos-container", "{line}"); + } + if !out.status.success() { + bail!( + "nixos-container {} failed ({}): {}", + args.join(" "), + out.status, + stderr.trim() + ); + } + Ok((stdout, stderr)) +} + +/// Return the system container name for a logical agent name. +/// Manager (`MANAGER_NAME`) passes through; sub-agents get `h-` prefix. +fn container_system_name(name: &str) -> String { + if name == MANAGER_NAME { + name.to_owned() + } else { + format!("{AGENT_PREFIX}{name}") + } +} + +/// Path of the per-agent unix-socket dir on the host. +fn socket_dir_path(agent_name: &str) -> PathBuf { + PathBuf::from(format!("{SOCKET_DIR_ROOT}/{agent_name}")) +} + +/// Validate a logical agent name (the name hive-c0re uses internally, +/// before the `h-` container prefix is applied). +fn validate_agent_name(name: &str) -> Result<()> { + if name == MANAGER_NAME { + return Ok(()); + } + validate_name_chars(name)?; + Ok(()) +} + +/// Validate a logical agent name and check it maps to a hive-managed container. +fn validate_container_name(name: &str) -> Result<()> { + if name == MANAGER_NAME { + return Ok(()); + } + if SIBLING_CONTAINERS.contains(&name) { + return Ok(()); + } + validate_name_chars(name)?; + Ok(()) +} + +/// Validate a system-level container name (already has `h-` prefix for +/// sub-agents, or is the manager name / sibling service name). +fn validate_container_system_name(name: &str) -> Result<()> { + if name == MANAGER_NAME { + return Ok(()); + } + if SIBLING_CONTAINERS.contains(&name) { + return Ok(()); + } + if let Some(suffix) = name.strip_prefix(AGENT_PREFIX) { + validate_name_chars(suffix)?; + return Ok(()); + } + bail!("container name {name:?} is not managed by hive"); +} + +fn validate_name_chars(name: &str) -> Result<()> { + if name.is_empty() + || !name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + { + bail!("invalid name {name:?}: must be non-empty lowercase ascii + digits + hyphens"); + } + Ok(()) +} + +/// Derive the meta-flake ref for an agent by name. +fn agent_flake_ref(name: &str) -> String { + format!("{META_DIR}#{name}") +} + +/// Validate a bind-mount path: must be absolute, non-empty, and contain +/// no newlines, null bytes, or double-quotes (which would break the +/// `EXTRA_NSPAWN_FLAGS="..."` conf line format). +fn validate_bind_path(path: &str) -> Result<()> { + if path.is_empty() + || !path.starts_with('/') + || path.bytes().any(|b| b == 0 || b == b'\n' || b == b'"' || b == b':') + { + bail!( + "invalid bind path {path:?}: must be an absolute path with no colons, newlines, null bytes, or double-quotes" + ); + } + Ok(()) +} + +/// Update `/etc/nixos-containers/.conf`: strips network-isolation +/// vars (`PRIVATE_NETWORK`, `HOST_ADDRESS*`, `LOCAL_ADDRESS*`, `HOST_BRIDGE`, +/// `EXTRA_NSPAWN_FLAGS`), forces `PRIVATE_NETWORK=0` and blank network vars, +/// then appends `EXTRA_NSPAWN_FLAGS=""`. +fn write_nspawn_flags(container: &str, binds: &[BindMount]) -> Result<()> { + let path = format!("/etc/nixos-containers/{container}.conf"); + let original = std::fs::read_to_string(&path) + .with_context(|| format!("read {path}"))?; + let mut lines: Vec<&str> = original + .lines() + .filter(|line| { + let t = line.trim_start(); + !t.starts_with("EXTRA_NSPAWN_FLAGS=") + && !t.starts_with("PRIVATE_NETWORK=") + && !t.starts_with("HOST_ADDRESS=") + && !t.starts_with("LOCAL_ADDRESS=") + && !t.starts_with("HOST_ADDRESS6=") + && !t.starts_with("LOCAL_ADDRESS6=") + && !t.starts_with("HOST_BRIDGE=") + }) + .collect(); + let mut out = lines.join("\n"); + if !out.is_empty() { + out.push('\n'); + } + out.push_str("PRIVATE_NETWORK=0\n"); + out.push_str("HOST_ADDRESS=\n"); + out.push_str("LOCAL_ADDRESS=\n"); + out.push_str("HOST_ADDRESS6=\n"); + out.push_str("LOCAL_ADDRESS6=\n"); + out.push_str("HOST_BRIDGE=\n"); + let flags: Vec = binds.iter().map(|b| { + let flag = if b.read_only { "--bind-ro" } else { "--bind" }; + format!("{flag}={}:{}", b.host_path, b.container_path) + }).collect(); + let flags_joined = flags.join(" "); + out.push_str(&format!("EXTRA_NSPAWN_FLAGS=\"{flags_joined}\"\n")); + std::fs::write(&path, out).with_context(|| format!("write {path}")) +} diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index 89c89140..58c8dc8c 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize}; pub mod assets; +pub mod priv_proto; // ----------------------------------------------------------------------------- // Host admin socket — /run/hyperhive/host.sock diff --git a/hive-sh4re/src/priv_proto.rs b/hive-sh4re/src/priv_proto.rs new file mode 100644 index 00000000..37ebf686 --- /dev/null +++ b/hive-sh4re/src/priv_proto.rs @@ -0,0 +1,119 @@ +//! Wire types for the `hive-priv` privileged-helper socket. +//! +//! Both `hive-priv` (server) and `hive-c0re` (client via `priv_client`) +//! import these so the shapes stay in sync. + +use serde::{Deserialize, Serialize}; + +/// Default socket path for the privileged helper. +pub const PRIV_SOCK: &str = "/run/hive/priv.sock"; + +/// Manager container name. Used by `hive-priv` to skip the `h-` prefix +/// and by `hive-c0re` for identity checks. +pub const MANAGER_NAME: &str = "root"; + +/// Sub-agent container prefix. System container name = `h-`. +pub const AGENT_PREFIX: &str = "h-"; + +/// Sibling service containers managed by hive-c0re. +pub const SIBLING_CONTAINERS: &[&str] = &["hive-forge", "hive-matrix", "hive-gateway"]; + +/// Host path of the meta flake. The flake ref for agent `` is +/// `{META_DIR}#{name}`, derived by `hive-priv` — never passed over the wire. +pub const META_DIR: &str = "/var/lib/hyperhive/meta"; + +/// One bind-mount entry for `WriteNspawnFlags`. +/// hive-priv constructs `--bind=:` (or `--bind-ro=`) +/// and validates both paths before writing the conf file. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BindMount { + pub host_path: String, + pub container_path: String, + pub read_only: bool, +} + +/// A request to the privileged helper. +/// +/// Wire format: one JSON object per line over `/run/hive/priv.sock`. +/// Every variant is a specific known operation — no pass-through +/// shell commands or arbitrary paths. New privileged ops get new +/// variants. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "op", rename_all = "snake_case")] +pub enum PrivRequest { + // --- Container lifecycle --- + + /// `nixos-container start ` + StartContainer { name: String }, + + /// `nixos-container stop ` + StopContainer { name: String }, + + /// `nixos-container kill ` + KillContainer { name: String }, + + /// `nixos-container update --flake ` + /// The flake ref is derived from `name` by hive-priv. + UpdateContainer { name: String }, + + /// `nixos-container create --flake ` + /// The flake ref is derived from `name` by hive-priv. + CreateContainer { name: String }, + + /// `nixos-container destroy ` + DestroyContainer { name: String }, + + /// `nixos-container list` + ListContainers, + + // --- Config file writes --- + + /// Update `/etc/nixos-containers/.conf`: strip network-isolation + /// vars, force `PRIVATE_NETWORK=0`, and set `EXTRA_NSPAWN_FLAGS` from the + /// provided bind-mount list. Written by `lifecycle::set_nspawn_flags`. + WriteNspawnFlags { container: String, binds: Vec }, + + /// Write `/run/systemd/system/container@.service.d/hyperhive-limits.conf` + /// with `[Service]\nMemoryMax=\nCPUQuota=\n`. + /// Written by `lifecycle::set_resource_limits`. + WriteResourceLimits { + container: String, + memory_max: String, + cpu_quota: String, + }, + + /// Remove `/run/systemd/system/container@.service.d/` if present. + /// Called by `lifecycle::destroy` to clean up the resource-limits drop-in. + RemoveServiceDropin { container: String }, + + // --- System --- + + /// Run `systemctl daemon-reload`. + DaemonReload, + + /// Reload nginx inside the `hive-gateway` container via + /// `systemd-run --machine=hive-gateway nginx -s reload`. + ReloadGatewayNginx, + + // --- Socket dir ownership --- + + /// Set ownership of `/run/hive-agent//` to `uid:gid`. + /// Called by `lifecycle::set_nspawn_flags` after `create_dir_all`. + ChownSocketDir { agent_name: String, uid: u32, gid: u32 }, + + /// Set mode of `/run/hive-agent//`. + /// Fallback when uid lookup returns `None` on first spawn. + ChmodSocketDir { agent_name: String, mode: u32 }, +} + +/// Response from the privileged helper. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PrivResponse { + pub ok: bool, + #[serde(default)] + pub stdout: String, + #[serde(default)] + pub stderr: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +}