This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/crates/maestro-cli/src/policy.rs
T
naps62-yoloandClaude Opus 4.7 f69c7a9eee
ci / rust (push) Failing after 2m51s
feat(maestro): hot-reload [daemon.policy] via SIGHUP + maestro policy CLI (NAP-237)
`PairAttachVerifier` now stores `allow_inbound` behind `Arc<RwLock<_>>` so
clones share the same set. Two triggers re-read the unified config file
and swap it atomically:

- SIGHUP — `systemctl --user reload maestrod`, or any `kill -HUP`. The
  installer's user unit now sets `ExecReload=/bin/kill -HUP $MAINPID`.
- `POST /api/policy/reload` — used by `maestro policy reload`. The CLI
  reads `[daemon].bind_addr` from the unified config (default 7070) and
  POSTs to `http://127.0.0.1:<port>`. No PID lookup required.

Also adds `maestro policy show [--json]` and `GET /api/policy` for
"did my edit get picked up?" debugging. A broken mid-edit config logs
an error and leaves the previously-effective list in place rather than
locking everyone out.

In-flight attaches keep their existing connection — revocation requires
killing the session, as spec'd.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 09:53:19 +00:00

128 lines
3.6 KiB
Rust

//! `maestro policy reload | show` — talk to the local daemon's
//! `/api/policy` endpoints (NAP-237).
//!
//! The local daemon's bind port is read from `[daemon].bind_addr` in the
//! unified config; the host is always `127.0.0.1` (these endpoints are
//! same-host by design — the daemon's bind may be `0.0.0.0` for attach,
//! but operators reload policy from the same machine).
use std::io::Write;
use std::path::Path;
use maestro_config::MaestroConfigFile;
use serde::Deserialize;
const DEFAULT_PORT: u16 = 7070;
#[derive(Debug, thiserror::Error)]
pub enum PolicyError {
#[error("config: {0}")]
Config(#[from] maestro_config::ConfigError),
#[error("http: {0}")]
Http(#[from] reqwest::Error),
#[error("daemon returned {status}: {body}")]
DaemonError {
status: reqwest::StatusCode,
body: String,
},
#[error("io: {0}")]
Io(#[from] std::io::Error),
}
#[derive(Debug, Deserialize)]
pub struct PolicyView {
pub allow_inbound: Vec<String>,
}
pub async fn run_reload(config_path: &Path, out: &mut impl Write) -> Result<(), PolicyError> {
let url = format!("{}/api/policy/reload", local_base_url(config_path)?);
let resp = reqwest::Client::new().post(&url).send().await?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(PolicyError::DaemonError { status, body });
}
let view: PolicyView = resp.json().await?;
writeln!(
out,
"reloaded — allow_inbound now has {} entr{}",
view.allow_inbound.len(),
if view.allow_inbound.len() == 1 {
"y"
} else {
"ies"
},
)?;
for entry in &view.allow_inbound {
writeln!(out, " {entry}")?;
}
Ok(())
}
pub async fn run_show(
config_path: &Path,
json: bool,
out: &mut impl Write,
) -> Result<(), PolicyError> {
let url = format!("{}/api/policy", local_base_url(config_path)?);
let resp = reqwest::Client::new().get(&url).send().await?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(PolicyError::DaemonError { status, body });
}
let view: PolicyView = resp.json().await?;
if json {
writeln!(
out,
"{}",
serde_json::to_string(&view.allow_inbound).unwrap()
)?;
} else if view.allow_inbound.is_empty() {
writeln!(
out,
"(allow_inbound is empty — every cross-daemon attach is denied)"
)?;
} else {
for entry in &view.allow_inbound {
writeln!(out, "{entry}")?;
}
}
Ok(())
}
/// `http://127.0.0.1:<port>` where `<port>` is `[daemon].bind_addr`'s port
/// or `7070` if unset / file missing.
fn local_base_url(config_path: &Path) -> Result<String, PolicyError> {
let file = MaestroConfigFile::load_or_default(config_path)?;
let port = file
.daemon
.bind_addr
.map(|a| a.port())
.unwrap_or(DEFAULT_PORT);
Ok(format!("http://127.0.0.1:{port}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn local_base_url_uses_bind_port_or_default() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
// missing file → default
assert_eq!(local_base_url(&path).unwrap(), "http://127.0.0.1:7070");
std::fs::write(
&path,
r#"
[daemon]
bind_addr = "0.0.0.0:9090"
"#,
)
.unwrap();
assert_eq!(local_base_url(&path).unwrap(), "http://127.0.0.1:9090");
}
}