use super::context::AgentContext;
use super::tool_registry::ToolHandler;
use async_trait::async_trait;
use nexo_llm::ToolDef;
use serde_json::{json, Value};
use std::path::PathBuf;
use std::sync::Arc;
pub trait YamlPatchApplier: Send + Sync {
fn read(
&self,
agent_id: &str,
dotted: &str,
) -> Result<Option<serde_yaml::Value>, PatchAppliedError>;
fn apply(&self, patch: &PatchInfo) -> Result<(), PatchAppliedError>;
fn snapshot(&self) -> Result<Vec<u8>, PatchAppliedError>;
fn restore(&self, snapshot: &[u8]) -> Result<(), PatchAppliedError>;
}
pub trait DenylistChecker: Send + Sync {
fn check(&self, dotted: &str) -> Option<&'static str>;
}
#[derive(Debug, Clone)]
pub struct PatchInfo {
pub patch_id: String,
pub binding_id: String,
pub agent_id: String,
pub created_at: i64,
pub expires_at: i64,
pub justification: String,
pub op: PatchOp,
}
#[derive(Debug, Clone)]
pub enum PatchOp {
Upsert {
agent_id: String,
dotted: String,
value: serde_yaml::Value,
},
Remove {
agent_id: String,
dotted: String,
},
}
impl PatchOp {
pub fn dotted(&self) -> &str {
match self {
PatchOp::Upsert { dotted, .. } | PatchOp::Remove { dotted, .. } => dotted.as_str(),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum PatchAppliedError {
#[error("path `{path}` denied (matched glob `{matched_glob}`)")]
Forbidden { path: String, matched_glob: String },
#[error("io: {0}")]
Io(String),
#[error("yaml: {0}")]
Yaml(String),
}
pub trait SecretRedactor: Send + Sync {
fn is_secret_path(&self, dotted: &str) -> bool;
}
pub struct DefaultSecretRedactor;
impl SecretRedactor for DefaultSecretRedactor {
fn is_secret_path(&self, dotted: &str) -> bool {
let lower = dotted.to_ascii_lowercase();
lower.ends_with("_token")
|| lower.ends_with("_secret")
|| lower.ends_with("_password")
|| lower.ends_with("_key")
}
}
#[async_trait]
pub trait ReloadTrigger: Send + Sync {
async fn reload(&self) -> Result<(), String>;
}
#[derive(Debug, Clone)]
pub struct ActorOrigin {
pub channel: String,
pub account_id: String,
pub sender_id: String,
}
pub struct ConfigTool {
pub agent_id: String,
pub binding_id: String,
pub allowed_paths: Vec<String>,
pub approval_timeout_secs: u64,
pub proposals_dir: PathBuf,
pub actor_origin: ActorOrigin,
pub applier: Arc<dyn YamlPatchApplier>,
pub denylist: Arc<dyn DenylistChecker>,
pub redactor: Arc<dyn SecretRedactor>,
pub changes_store: Arc<dyn crate::config_changes_store::ConfigChangesStore>,
pub correlator: Arc<crate::agent::approval_correlator::ApprovalCorrelator>,
pub reload: Arc<dyn ReloadTrigger>,
pub pending_receivers: Arc<
tokio::sync::Mutex<
std::collections::HashMap<
String,
tokio::sync::oneshot::Receiver<crate::agent::approval_correlator::ApprovalDecision>,
>,
>,
>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct StagedProposal {
patch_id: String,
binding_id: String,
agent_id: String,
created_at: i64,
expires_at: i64,
justification: String,
actor: ActorOrigin,
patch: SerializablePatch,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct SerializablePatch {
op: String,
agent_id: String,
dotted: String,
value: Option<serde_yaml::Value>,
}
impl SerializablePatch {
fn from(info: &PatchInfo) -> Self {
match &info.op {
PatchOp::Upsert {
agent_id,
dotted,
value,
} => Self {
op: "upsert".into(),
agent_id: agent_id.clone(),
dotted: dotted.clone(),
value: Some(value.clone()),
},
PatchOp::Remove { agent_id, dotted } => Self {
op: "remove".into(),
agent_id: agent_id.clone(),
dotted: dotted.clone(),
value: None,
},
}
}
fn into_patch_info(self, parent: &StagedProposal) -> PatchInfo {
let op = match self.op.as_str() {
"upsert" => PatchOp::Upsert {
agent_id: self.agent_id,
dotted: self.dotted,
value: self.value.unwrap_or(serde_yaml::Value::Null),
},
_ => PatchOp::Remove {
agent_id: self.agent_id,
dotted: self.dotted,
},
};
PatchInfo {
patch_id: parent.patch_id.clone(),
binding_id: parent.binding_id.clone(),
agent_id: parent.agent_id.clone(),
created_at: parent.created_at,
expires_at: parent.expires_at,
justification: parent.justification.clone(),
op,
}
}
}
impl serde::Serialize for ActorOrigin {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeStruct;
let mut st = s.serialize_struct("ActorOrigin", 3)?;
st.serialize_field("channel", &self.channel)?;
st.serialize_field("account_id", &self.account_id)?;
st.serialize_field("sender_id", &self.sender_id)?;
st.end()
}
}
impl<'de> serde::Deserialize<'de> for ActorOrigin {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
#[derive(serde::Deserialize)]
struct Tmp {
channel: String,
account_id: String,
sender_id: String,
}
let t = Tmp::deserialize(d)?;
Ok(ActorOrigin {
channel: t.channel,
account_id: t.account_id,
sender_id: t.sender_id,
})
}
}
fn serialize_redacted_value(
value: &Value,
redactor: &dyn SecretRedactor,
key: &str,
) -> Option<String> {
if value.is_null() {
return None;
}
if redactor.is_secret_path(key) {
return Some("\"<REDACTED>\"".to_string());
}
serde_json::to_string(value).ok()
}
impl ConfigTool {
pub fn tool_def() -> ToolDef {
ToolDef {
name: "Config".to_string(),
description: r#"Read or propose changes to your own agent YAML configuration. Three operations:
- `op: "read"` — return the current value for `key` (a dotted path like "model.model" or "language"). Read-only; passes plan-mode.
- `op: "propose"` — stage a candidate change and notify the operator on the originating channel for approval. Returns a `patch_id`.
- `op: "apply"` — apply a previously-proposed and operator-approved patch.
Rules:
- Only paths in the agent's `config_tool.allowed_paths` whitelist (or all `SUPPORTED_SETTINGS` when empty) are touchable.
- A hard-coded denylist blocks credentials, pairing internals, MCP server commands, role/plan-mode self-elevation, and other sensitive keys. Refusal is not negotiable.
- `apply` requires an operator approval message of shape `[config-approve patch_id=<id>]` on the same channel/account that originated the proposal.
- Proposals expire after the agent's `approval_timeout_secs` (default 24 h)."#
.to_string(),
parameters: json!({
"type": "object",
"properties": {
"op": {
"type": "string",
"enum": ["read", "propose", "apply"],
"description": "Operation: read | propose | apply."
},
"key": {
"type": "string",
"description": "Dotted YAML path. Required for `read` and `propose`."
},
"value": {
"description": "New value for `propose`. JSON literal; `null` means \"remove the key\". Omit for `read`."
},
"justification": {
"type": "string",
"description": "One-sentence reason for `propose`. Surfaces in the operator approval message."
},
"patch_id": {
"type": "string",
"description": "Patch id returned by an earlier `propose`. Required for `apply`."
}
},
"required": ["op"]
}),
}
}
}
#[async_trait]
impl ToolHandler for ConfigTool {
async fn call(&self, ctx: &AgentContext, args: Value) -> anyhow::Result<Value> {
let op = args
.get("op")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Config: missing required field `op`"))?;
match op {
"read" => self.handle_read(args).await,
"propose" => self.handle_propose(ctx, args).await,
"apply" => self.handle_apply(args).await,
other => Ok(json!({
"ok": false,
"error": format!("unknown op `{other}`. Supported: read, propose, apply"),
"kind": "UnknownOp",
})),
}
}
}
impl ConfigTool {
pub async fn recover_pending_from_staging(&self) -> anyhow::Result<usize> {
let entries = match std::fs::read_dir(&self.proposals_dir) {
Ok(e) => e,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0),
Err(e) => return Err(anyhow::anyhow!("read proposals dir: {e}")),
};
let now = chrono::Utc::now().timestamp();
let mut recovered = 0usize;
for entry in entries {
let path = match entry {
Ok(e) => e.path(),
Err(e) => {
tracing::warn!(error = %e, "[config] unreadable proposals dir entry");
continue;
}
};
if path.extension().and_then(|s| s.to_str()) != Some("yaml") {
continue;
}
let bytes = match std::fs::read(&path) {
Ok(b) => b,
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "[config] could not read staged proposal during recovery");
continue;
}
};
let staged: StagedProposal = match serde_yaml::from_slice(&bytes) {
Ok(s) => s,
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "[config] could not parse staged proposal during recovery");
continue;
}
};
if staged.agent_id != self.agent_id || staged.binding_id != self.binding_id {
continue;
}
if staged.expires_at <= now {
let _ = std::fs::remove_file(&path);
continue;
}
{
let pending = self.pending_receivers.lock().await;
if pending.contains_key(&staged.patch_id) {
continue;
}
}
let pending = crate::agent::approval_correlator::PendingApproval {
patch_id: staged.patch_id.clone(),
binding_id: staged.binding_id.clone(),
agent_id: staged.agent_id.clone(),
channel: staged.actor.channel.clone(),
account_id: staged.actor.account_id.clone(),
sender_id: staged.actor.sender_id.clone(),
created_at: staged.created_at,
expires_at: staged.expires_at,
};
let rx = self.correlator.park(pending);
self.pending_receivers
.lock()
.await
.insert(staged.patch_id, rx);
recovered = recovered.saturating_add(1);
}
Ok(recovered)
}
async fn recover_receiver_for_patch(
&self,
patch_id: &str,
) -> Result<
Option<tokio::sync::oneshot::Receiver<crate::agent::approval_correlator::ApprovalDecision>>,
PatchAppliedError,
> {
let staging_path = self.proposals_dir.join(format!("{patch_id}.yaml"));
let bytes = match std::fs::read(&staging_path) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(PatchAppliedError::Io(format!("read staging: {e}"))),
};
let staged: StagedProposal = serde_yaml::from_slice(&bytes)
.map_err(|e| PatchAppliedError::Yaml(format!("parse staging: {e}")))?;
if staged.patch_id != patch_id {
return Ok(None);
}
if staged.agent_id != self.agent_id || staged.binding_id != self.binding_id {
return Ok(None);
}
if staged.expires_at <= chrono::Utc::now().timestamp() {
let _ = std::fs::remove_file(&staging_path);
return Ok(None);
}
let pending = crate::agent::approval_correlator::PendingApproval {
patch_id: staged.patch_id,
binding_id: staged.binding_id,
agent_id: staged.agent_id,
channel: staged.actor.channel,
account_id: staged.actor.account_id,
sender_id: staged.actor.sender_id,
created_at: staged.created_at,
expires_at: staged.expires_at,
};
Ok(Some(self.correlator.park(pending)))
}
fn resolve_actor_origin(&self, ctx: &AgentContext) -> ActorOrigin {
match ctx.inbound_origin.as_ref() {
Some((channel, account_id, sender_id)) => ActorOrigin {
channel: channel.clone(),
account_id: account_id.clone(),
sender_id: sender_id.clone(),
},
None => self.actor_origin.clone(),
}
}
async fn handle_read(&self, args: Value) -> anyhow::Result<Value> {
let key = args
.get("key")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Config read: missing required field `key`"))?
.to_string();
if !self.path_in_supported(&key) {
return Ok(json!({
"ok": false,
"error": format!("key `{key}` is not in SUPPORTED_SETTINGS"),
"kind": "UnknownKey",
"key": key,
}));
}
if !self.path_in_allowed(&key) {
return Ok(json!({
"ok": false,
"error": format!(
"key `{key}` is not in this agent's `config_tool.allowed_paths` whitelist"
),
"kind": "PathNotAllowed",
"key": key,
}));
}
if let Some(matched_glob) = self.denylist.check(&key) {
return Ok(json!({
"ok": false,
"error": format!("key `{key}` denied by self-edit policy"),
"kind": "ForbiddenKey",
"key": key,
"matched_glob": matched_glob,
}));
}
let value = match self.applier.read(&self.agent_id, &key) {
Ok(v) => v,
Err(e) => {
return Ok(json!({
"ok": false,
"error": e.to_string(),
"kind": "Yaml",
"key": key,
}))
}
};
let rendered = if self.redactor.is_secret_path(&key) {
json!("<REDACTED>")
} else {
match value {
Some(v) => yaml_to_json(&v),
None => Value::Null,
}
};
Ok(json!({
"ok": true,
"key": key,
"value": rendered,
}))
}
async fn handle_propose(&self, ctx: &AgentContext, args: Value) -> anyhow::Result<Value> {
let key = args
.get("key")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Config propose: missing required field `key`"))?
.to_string();
let justification = args
.get("justification")
.and_then(|v| v.as_str())
.unwrap_or("(no justification)")
.trim()
.to_string();
let value = args.get("value").cloned().unwrap_or(Value::Null);
if !self.path_in_supported(&key) {
return Ok(json!({
"ok": false,
"error": format!("key `{key}` is not in SUPPORTED_SETTINGS"),
"kind": "UnknownKey",
"key": key,
}));
}
if !self.path_in_allowed(&key) {
return Ok(json!({
"ok": false,
"error": format!(
"key `{key}` is not in this agent's `config_tool.allowed_paths` whitelist"
),
"kind": "PathNotAllowed",
"key": key,
}));
}
if let Some(matched_glob) = self.denylist.check(&key) {
return Ok(json!({
"ok": false,
"error": format!("key `{key}` denied by self-edit policy"),
"kind": "ForbiddenKey",
"key": key,
"matched_glob": matched_glob,
}));
}
let setting = nexo_config::types::config_tool::lookup(&key).expect("supported");
if let Some(validate) = setting.validate {
if let Err(reason) = validate(&value) {
return Ok(json!({
"ok": false,
"error": format!("validation failed for `{key}`: {reason}"),
"kind": "ValidationFailed",
"key": key,
}));
}
}
let patch_id = format!(
"01J{}",
uuid::Uuid::new_v4()
.simple()
.to_string()
.to_ascii_uppercase()
);
let now = chrono::Utc::now().timestamp();
let expires_at = now + self.approval_timeout_secs as i64;
let op = if value.is_null() {
PatchOp::Remove {
agent_id: self.agent_id.clone(),
dotted: key.clone(),
}
} else {
let yaml_value = match serde_yaml::to_value(&value) {
Ok(v) => v,
Err(e) => {
return Ok(json!({
"ok": false,
"error": format!("value JSON→YAML conversion failed: {e}"),
"kind": "Yaml",
}))
}
};
PatchOp::Upsert {
agent_id: self.agent_id.clone(),
dotted: key.clone(),
value: yaml_value,
}
};
let patch = PatchInfo {
patch_id: patch_id.clone(),
binding_id: self.binding_id.clone(),
agent_id: self.agent_id.clone(),
created_at: now,
expires_at,
justification: justification.clone(),
op,
};
let actor_origin = self.resolve_actor_origin(ctx);
let pending = crate::agent::approval_correlator::PendingApproval {
patch_id: patch_id.clone(),
binding_id: self.binding_id.clone(),
agent_id: self.agent_id.clone(),
channel: actor_origin.channel.clone(),
account_id: actor_origin.account_id.clone(),
sender_id: actor_origin.sender_id.clone(),
created_at: now,
expires_at,
};
let _rx = self.correlator.park(pending);
self.pending_receivers
.lock()
.await
.insert(patch_id.clone(), _rx);
if let Err(e) = std::fs::create_dir_all(&self.proposals_dir) {
self.pending_receivers.lock().await.remove(&patch_id);
let _ = self.correlator.cancel_patch(&patch_id);
return Ok(json!({
"ok": false,
"error": format!("could not create proposals dir: {e}"),
"kind": "Io",
}));
}
let staging_path = self.proposals_dir.join(format!("{patch_id}.yaml"));
let staged = StagedProposal {
patch_id: patch_id.clone(),
binding_id: self.binding_id.clone(),
agent_id: self.agent_id.clone(),
created_at: now,
expires_at,
justification: justification.clone(),
actor: actor_origin,
patch: SerializablePatch::from(&patch),
};
let yaml = match serde_yaml::to_string(&staged) {
Ok(y) => y,
Err(e) => {
self.pending_receivers.lock().await.remove(&patch_id);
let _ = self.correlator.cancel_patch(&patch_id);
return Ok(json!({
"ok": false,
"error": format!("serialise staging: {e}"),
"kind": "Yaml",
}));
}
};
if let Err(e) = std::fs::write(&staging_path, yaml) {
self.pending_receivers.lock().await.remove(&patch_id);
let _ = self.correlator.cancel_patch(&patch_id);
return Ok(json!({
"ok": false,
"error": format!("write staging: {e}"),
"kind": "Io",
}));
}
let _ = self
.changes_store
.record(&crate::config_changes_store::ConfigChangeRow {
patch_id: patch_id.clone(),
binding_id: self.binding_id.clone(),
agent_id: self.agent_id.clone(),
op: "propose".into(),
key: key.clone(),
value: serialize_redacted_value(&value, self.redactor.as_ref(), &key),
status: "proposed".into(),
error: None,
created_at: now,
applied_at: None,
})
.await;
tracing::info!(
target: "config::propose",
patch_id = %patch_id,
agent = %self.agent_id,
binding = %self.binding_id,
key = %key,
"[config] proposal staged — awaiting operator approval"
);
Ok(json!({
"ok": true,
"patch_id": patch_id,
"expires_at": expires_at,
"approval_message_format": format!(
"[config-approve patch_id={patch_id}] or [config-reject patch_id={patch_id} reason=...]"
),
}))
}
async fn handle_apply(&self, args: Value) -> anyhow::Result<Value> {
let patch_id = args
.get("patch_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Config apply: missing required field `patch_id`"))?
.to_string();
let rx = self.pending_receivers.lock().await.remove(&patch_id);
let rx = match rx {
Some(r) => r,
None => match self.recover_receiver_for_patch(&patch_id).await {
Ok(Some(r)) => r,
Ok(None) => {
return Ok(json!({
"ok": false,
"error": format!("no pending proposal for patch_id `{patch_id}` (expired or already consumed)"),
"kind": "NoPending",
"patch_id": patch_id,
}))
}
Err(PatchAppliedError::Io(e)) => {
return Ok(json!({
"ok": false,
"error": e,
"kind": "Io",
"patch_id": patch_id,
}))
}
Err(PatchAppliedError::Yaml(e)) => {
return Ok(json!({
"ok": false,
"error": e,
"kind": "Yaml",
"patch_id": patch_id,
}))
}
Err(e) => {
return Ok(json!({
"ok": false,
"error": e.to_string(),
"kind": "InternalError",
"patch_id": patch_id,
}))
}
},
};
let staging_path = self.proposals_dir.join(format!("{patch_id}.yaml"));
let staging_bytes = match std::fs::read(&staging_path) {
Ok(b) => b,
Err(e) => {
self.pending_receivers
.lock()
.await
.insert(patch_id.clone(), rx);
return Ok(json!({
"ok": false,
"error": format!("could not read staging file: {e}"),
"kind": "Io",
"patch_id": patch_id,
}));
}
};
let staged: StagedProposal = match serde_yaml::from_slice(&staging_bytes) {
Ok(s) => s,
Err(e) => {
self.pending_receivers
.lock()
.await
.insert(patch_id.clone(), rx);
return Ok(json!({
"ok": false,
"error": format!("could not parse staging file: {e}"),
"kind": "Yaml",
"patch_id": patch_id,
}));
}
};
let staged_key = staged.patch.dotted.clone();
if let Some(matched_glob) = self.denylist.check(&staged_key) {
return Ok(json!({
"ok": false,
"error": format!("staged path `{staged_key}` denied by self-edit policy"),
"kind": "ForbiddenKey",
"matched_glob": matched_glob,
}));
}
let decision = match rx.await {
Ok(d) => d,
Err(_) => {
return Ok(json!({
"ok": false,
"error": "approval responder dropped before decision",
"kind": "InternalError",
"patch_id": patch_id,
}))
}
};
match decision {
crate::agent::approval_correlator::ApprovalDecision::Rejected { reason } => {
let now = chrono::Utc::now().timestamp();
let _ = self
.changes_store
.record(&crate::config_changes_store::ConfigChangeRow {
patch_id: patch_id.clone(),
binding_id: self.binding_id.clone(),
agent_id: self.agent_id.clone(),
op: "apply".into(),
key: staged_key.clone(),
value: None,
status: "rejected".into(),
error: reason.clone(),
created_at: now,
applied_at: None,
})
.await;
return Ok(json!({
"ok": false,
"error": "operator rejected the proposal",
"kind": "Rejected",
"reason": reason,
"patch_id": patch_id,
}));
}
crate::agent::approval_correlator::ApprovalDecision::Expired => {
let now = chrono::Utc::now().timestamp();
let _ = self
.changes_store
.record(&crate::config_changes_store::ConfigChangeRow {
patch_id: patch_id.clone(),
binding_id: self.binding_id.clone(),
agent_id: self.agent_id.clone(),
op: "apply".into(),
key: staged_key.clone(),
value: None,
status: "expired".into(),
error: None,
created_at: now,
applied_at: None,
})
.await;
return Ok(json!({
"ok": false,
"error": "approval timed out",
"kind": "Expired",
"patch_id": patch_id,
}));
}
crate::agent::approval_correlator::ApprovalDecision::Approved => {
}
}
let snapshot = match self.applier.snapshot() {
Ok(s) => s,
Err(e) => {
return Ok(json!({
"ok": false,
"error": format!("snapshot failed: {e}"),
"kind": "Io",
}))
}
};
let patch_info = staged.patch.clone().into_patch_info(&staged);
if let Err(e) = self.applier.apply(&patch_info) {
return Ok(json!({
"ok": false,
"error": format!("apply failed: {e}"),
"kind": "Yaml",
"patch_id": patch_id,
}));
}
match self.reload.reload().await {
Ok(()) => {
let now = chrono::Utc::now().timestamp();
let _ = self
.changes_store
.record(&crate::config_changes_store::ConfigChangeRow {
patch_id: patch_id.clone(),
binding_id: self.binding_id.clone(),
agent_id: self.agent_id.clone(),
op: "apply".into(),
key: staged_key.clone(),
value: None,
status: "applied".into(),
error: None,
created_at: now,
applied_at: Some(now),
})
.await;
tracing::info!(
target: "config::apply",
patch_id = %patch_id,
key = %staged_key,
"[config] applied"
);
let _ = std::fs::remove_file(&staging_path);
Ok(json!({
"ok": true,
"patch_id": patch_id,
"applied": true,
"key": staged_key,
}))
}
Err(reload_err) => {
let restore_result = self.applier.restore(&snapshot);
let now = chrono::Utc::now().timestamp();
let restore_failed = restore_result.is_err();
let _ = self
.changes_store
.record(&crate::config_changes_store::ConfigChangeRow {
patch_id: patch_id.clone(),
binding_id: self.binding_id.clone(),
agent_id: self.agent_id.clone(),
op: "apply".into(),
key: staged_key.clone(),
value: None,
status: "rolled_back".into(),
error: Some(reload_err.clone()),
created_at: now,
applied_at: Some(now),
})
.await;
tracing::warn!(
target: "config::apply_rolled_back",
patch_id = %patch_id,
reload_error = %reload_err,
restore_failed = restore_failed,
"[config] reload rejected post-apply — rolling back"
);
Ok(json!({
"ok": false,
"error": "reload rejected the post-apply config; restored previous snapshot",
"kind": "RolledBack",
"reload_error": reload_err,
"rolled_back_to_previous": !restore_failed,
"patch_id": patch_id,
}))
}
}
}
fn path_in_supported(&self, key: &str) -> bool {
nexo_config::types::config_tool::is_supported(key)
}
fn path_in_allowed(&self, key: &str) -> bool {
if self.allowed_paths.is_empty() {
return true;
}
self.allowed_paths.iter().any(|p| p == key)
}
}
fn yaml_to_json(v: &serde_yaml::Value) -> Value {
match v {
serde_yaml::Value::Null => Value::Null,
serde_yaml::Value::Bool(b) => Value::Bool(*b),
serde_yaml::Value::Number(n) => {
if let Some(i) = n.as_i64() {
Value::from(i)
} else if let Some(f) = n.as_f64() {
serde_json::Number::from_f64(f)
.map(Value::Number)
.unwrap_or(Value::Null)
} else {
Value::Null
}
}
serde_yaml::Value::String(s) => Value::String(s.clone()),
serde_yaml::Value::Sequence(seq) => Value::Array(seq.iter().map(yaml_to_json).collect()),
serde_yaml::Value::Mapping(m) => {
let mut out = serde_json::Map::new();
for (k, v) in m {
if let Some(s) = k.as_str() {
out.insert(s.to_string(), yaml_to_json(v));
}
}
Value::Object(out)
}
serde_yaml::Value::Tagged(t) => yaml_to_json(&t.value),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config_changes_store::SqliteConfigChangesStore;
use crate::session::SessionManager;
use nexo_broker::AnyBroker;
use nexo_config::types::agents::{
AgentConfig, AgentRuntimeConfig, DreamingYamlConfig, HeartbeatConfig, ModelConfig,
OutboundAllowlistConfig, WorkspaceGitConfig,
};
use std::sync::Mutex;
struct MockApplier {
data: Mutex<serde_yaml::Mapping>,
}
impl MockApplier {
fn new(yaml: &str) -> Self {
let m: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
Self {
data: Mutex::new(m.as_mapping().cloned().unwrap_or_default()),
}
}
}
impl YamlPatchApplier for MockApplier {
fn read(
&self,
agent_id: &str,
dotted: &str,
) -> Result<Option<serde_yaml::Value>, PatchAppliedError> {
let map = self.data.lock().unwrap();
let agents = match map.get(serde_yaml::Value::String("agents".into())) {
Some(serde_yaml::Value::Sequence(s)) => s,
_ => return Ok(None),
};
let agent = agents.iter().find(|a| {
a.as_mapping()
.and_then(|m| m.get(serde_yaml::Value::String("id".into())))
.and_then(|v| v.as_str())
== Some(agent_id)
});
let mut cur = match agent {
Some(a) => a.clone(),
None => return Ok(None),
};
for part in dotted.split('.') {
let next = cur
.as_mapping()
.and_then(|m| m.get(serde_yaml::Value::String(part.to_string())))
.cloned();
match next {
Some(v) => cur = v,
None => return Ok(None),
}
}
Ok(Some(cur))
}
fn apply(&self, _patch: &PatchInfo) -> Result<(), PatchAppliedError> {
Ok(())
}
fn snapshot(&self) -> Result<Vec<u8>, PatchAppliedError> {
Ok(Vec::new())
}
fn restore(&self, _snapshot: &[u8]) -> Result<(), PatchAppliedError> {
Ok(())
}
}
struct PermissiveDenylist;
impl DenylistChecker for PermissiveDenylist {
fn check(&self, _dotted: &str) -> Option<&'static str> {
None
}
}
struct StrictDenylist;
impl DenylistChecker for StrictDenylist {
fn check(&self, dotted: &str) -> Option<&'static str> {
if dotted.starts_with("pairing.") {
Some("pairing.*")
} else {
None
}
}
}
fn agent_context_fixture() -> AgentContext {
let cfg = AgentConfig {
id: "cody".into(),
model: ModelConfig {
provider: "x".into(),
model: "y".into(),
},
plugins: Vec::new(),
heartbeat: HeartbeatConfig::default(),
config: AgentRuntimeConfig::default(),
system_prompt: String::new(),
workspace: String::new(),
skills: Vec::new(),
skills_dir: "./skills".into(),
skill_overrides: Default::default(),
transcripts_dir: String::new(),
dreaming: DreamingYamlConfig::default(),
workspace_git: WorkspaceGitConfig::default(),
tool_rate_limits: None,
tool_args_validation: None,
extra_docs: Vec::new(),
inbound_bindings: Vec::new(),
allowed_tools: Vec::new(),
sender_rate_limit: None,
allowed_delegates: Vec::new(),
accept_delegates_from: Vec::new(),
description: String::new(),
google_auth: None,
credentials: Default::default(),
link_understanding: serde_json::Value::Null,
web_search: serde_json::Value::Null,
pairing_policy: serde_json::Value::Null,
language: None,
outbound_allowlist: OutboundAllowlistConfig::default(),
context_optimization: None,
dispatch_policy: Default::default(),
plan_mode: Default::default(),
remote_triggers: Vec::new(),
lsp: nexo_config::types::lsp::LspPolicy::default(),
config_tool: nexo_config::types::config_tool::ConfigToolPolicy::default(),
team: nexo_config::types::team::TeamPolicy::default(),
proactive: Default::default(),
repl: Default::default(),
auto_dream: None,
assistant_mode: None,
away_summary: None,
brief: None,
channels: None,
auto_approve: false,
extract_memories: None,
event_subscribers: Vec::new(),
tenant_id: None,
extensions_config: std::collections::BTreeMap::new(),
active: true,
};
AgentContext::new(
"cody",
Arc::new(cfg),
AnyBroker::local(),
Arc::new(SessionManager::new(std::time::Duration::from_secs(60), 8)),
)
}
struct MockReload {
outcome: Mutex<Result<(), String>>,
}
impl MockReload {
fn new(outcome: Result<(), String>) -> Self {
Self {
outcome: Mutex::new(outcome),
}
}
}
#[async_trait]
impl ReloadTrigger for MockReload {
async fn reload(&self) -> Result<(), String> {
self.outcome.lock().unwrap().clone()
}
}
async fn build_tool(
applier: Arc<dyn YamlPatchApplier>,
denylist: Arc<dyn DenylistChecker>,
allowed: Vec<String>,
) -> ConfigTool {
build_tool_with_reload(
applier,
denylist,
allowed,
Arc::new(MockReload::new(Ok(()))),
)
.await
}
async fn build_tool_with_reload(
applier: Arc<dyn YamlPatchApplier>,
denylist: Arc<dyn DenylistChecker>,
allowed: Vec<String>,
reload: Arc<dyn ReloadTrigger>,
) -> ConfigTool {
let dir = tempfile::tempdir().unwrap().keep();
build_tool_with_reload_in_dir(applier, denylist, allowed, reload, dir).await
}
async fn build_tool_with_reload_in_dir(
applier: Arc<dyn YamlPatchApplier>,
denylist: Arc<dyn DenylistChecker>,
allowed: Vec<String>,
reload: Arc<dyn ReloadTrigger>,
proposals_dir: std::path::PathBuf,
) -> ConfigTool {
let store: Arc<dyn crate::config_changes_store::ConfigChangesStore> =
Arc::new(SqliteConfigChangesStore::open_in_memory().await.unwrap());
let correlator = crate::agent::approval_correlator::ApprovalCorrelator::new(
crate::agent::approval_correlator::ApprovalCorrelatorConfig::default(),
);
ConfigTool {
agent_id: "cody".into(),
binding_id: "wa:default".into(),
allowed_paths: allowed,
approval_timeout_secs: 86_400,
proposals_dir,
actor_origin: ActorOrigin {
channel: "whatsapp".into(),
account_id: "default".into(),
sender_id: "5511".into(),
},
applier,
denylist,
redactor: Arc::new(DefaultSecretRedactor),
changes_store: store,
correlator,
reload,
pending_receivers: Arc::new(tokio::sync::Mutex::new(Default::default())),
}
}
fn fixture_yaml() -> &'static str {
r#"
agents:
- id: cody
model:
provider: anthropic
model: claude-sonnet-4-6
language: en
"#
}
#[test]
fn tool_def_advertises_three_ops() {
let def = ConfigTool::tool_def();
assert_eq!(def.name, "Config");
let params = &def.parameters;
let ops = params["properties"]["op"]["enum"].as_array().unwrap();
let names: Vec<&str> = ops.iter().map(|v| v.as_str().unwrap()).collect();
assert_eq!(names, vec!["read", "propose", "apply"]);
}
#[tokio::test]
async fn read_returns_value_for_supported_key() {
let tool = build_tool(
Arc::new(MockApplier::new(fixture_yaml())),
Arc::new(PermissiveDenylist),
vec![],
)
.await;
let res = tool
.call(
&agent_context_fixture(),
json!({ "op": "read", "key": "model.model" }),
)
.await
.unwrap();
assert_eq!(res["ok"], true);
assert_eq!(res["key"], "model.model");
assert_eq!(res["value"], "claude-sonnet-4-6");
}
#[tokio::test]
async fn read_rejects_off_whitelist_key() {
let tool = build_tool(
Arc::new(MockApplier::new(fixture_yaml())),
Arc::new(PermissiveDenylist),
vec![],
)
.await;
let res = tool
.call(
&agent_context_fixture(),
json!({ "op": "read", "key": "agents[0].pairing.session_token" }),
)
.await
.unwrap();
assert_eq!(res["ok"], false);
let kind = res["kind"].as_str().unwrap();
assert!(
kind == "UnknownKey" || kind == "ForbiddenKey",
"got kind `{kind}`"
);
}
#[tokio::test]
async fn read_blocks_path_outside_allowed_paths() {
let tool = build_tool(
Arc::new(MockApplier::new(fixture_yaml())),
Arc::new(PermissiveDenylist),
vec!["language".into()],
)
.await;
let res = tool
.call(
&agent_context_fixture(),
json!({ "op": "read", "key": "model.model" }),
)
.await
.unwrap();
assert_eq!(res["ok"], false);
assert_eq!(res["kind"], "PathNotAllowed");
}
#[tokio::test]
async fn read_returns_denied_when_denylist_hits() {
let tool = build_tool(
Arc::new(MockApplier::new(fixture_yaml())),
Arc::new(StrictDenylist),
vec![],
)
.await;
let res = tool
.call(
&agent_context_fixture(),
json!({ "op": "read", "key": "model.model" }),
)
.await
.unwrap();
assert_eq!(res["ok"], true);
}
#[tokio::test]
async fn propose_writes_staging_file_and_records_proposed() {
let tool = build_tool(
Arc::new(MockApplier::new(fixture_yaml())),
Arc::new(PermissiveDenylist),
vec![],
)
.await;
let res = tool
.call(
&agent_context_fixture(),
json!({
"op": "propose",
"key": "model.model",
"value": "claude-opus-4-7",
"justification": "operator asked"
}),
)
.await
.unwrap();
assert_eq!(res["ok"], true);
let patch_id = res["patch_id"].as_str().unwrap().to_string();
let path = tool.proposals_dir.join(format!("{patch_id}.yaml"));
assert!(
path.exists(),
"staging file at `{}` must exist",
path.display()
);
let row = tool.changes_store.get(&patch_id).await.unwrap().unwrap();
assert_eq!(row.status, "proposed");
assert_eq!(row.key, "model.model");
assert_eq!(row.value.unwrap(), "\"claude-opus-4-7\"");
}
#[tokio::test]
async fn propose_uses_inbound_origin_from_context_when_available() {
let tool = build_tool(
Arc::new(MockApplier::new(fixture_yaml())),
Arc::new(PermissiveDenylist),
vec![],
)
.await;
let ctx = agent_context_fixture().with_inbound_origin("telegram", "sales", "u-42");
let res = tool
.call(
&ctx,
json!({
"op": "propose",
"key": "model.model",
"value": "claude-opus-4-7",
"justification": "operator asked"
}),
)
.await
.unwrap();
assert_eq!(res["ok"], true);
let patch_id = res["patch_id"].as_str().unwrap().to_string();
let path = tool.proposals_dir.join(format!("{patch_id}.yaml"));
let staged: StagedProposal = serde_yaml::from_slice(&std::fs::read(path).unwrap()).unwrap();
assert_eq!(staged.actor.channel, "telegram");
assert_eq!(staged.actor.account_id, "sales");
assert_eq!(staged.actor.sender_id, "u-42");
}
#[tokio::test]
async fn propose_staging_failure_cleans_pending_maps() {
let mut tool = build_tool(
Arc::new(MockApplier::new(fixture_yaml())),
Arc::new(PermissiveDenylist),
vec![],
)
.await;
let marker = tempfile::NamedTempFile::new().unwrap();
tool.proposals_dir = marker.path().to_path_buf();
let res = tool
.call(
&agent_context_fixture(),
json!({
"op": "propose",
"key": "model.model",
"value": "claude-opus-4-7",
"justification": "operator asked"
}),
)
.await
.unwrap();
assert_eq!(res["ok"], false);
assert_eq!(res["kind"], "Io");
assert!(tool.pending_receivers.lock().await.is_empty());
assert_eq!(tool.correlator.pending_count(), 0);
}
#[tokio::test]
async fn apply_staging_read_error_requeues_receiver() {
let tool = build_tool(
Arc::new(MockApplier::new(fixture_yaml())),
Arc::new(PermissiveDenylist),
vec![],
)
.await;
let propose = tool
.call(
&agent_context_fixture(),
json!({
"op": "propose",
"key": "model.model",
"value": "claude-opus-4-7",
"justification": "test"
}),
)
.await
.unwrap();
let patch_id = propose["patch_id"].as_str().unwrap().to_string();
let path = tool.proposals_dir.join(format!("{patch_id}.yaml"));
std::fs::remove_file(&path).unwrap();
let apply = tool
.call(
&agent_context_fixture(),
json!({ "op": "apply", "patch_id": patch_id.clone() }),
)
.await
.unwrap();
assert_eq!(apply["ok"], false);
assert_eq!(apply["kind"], "Io");
let pending = tool.pending_receivers.lock().await;
assert!(pending.contains_key(&patch_id));
}
#[tokio::test]
async fn boot_recovery_rehydrates_pending_proposals_from_staging() {
let tmp = tempfile::tempdir().unwrap();
let proposals_dir = tmp.path().to_path_buf();
let tool_before = build_tool_with_reload_in_dir(
Arc::new(MockApplier::new(fixture_yaml())),
Arc::new(PermissiveDenylist),
vec![],
Arc::new(MockReload::new(Ok(()))),
proposals_dir.clone(),
)
.await;
let propose = tool_before
.call(
&agent_context_fixture(),
json!({
"op": "propose",
"key": "model.model",
"value": "claude-opus-4-7",
"justification": "restart test"
}),
)
.await
.unwrap();
let patch_id = propose["patch_id"].as_str().unwrap().to_string();
let tool_after = build_tool_with_reload_in_dir(
Arc::new(MockApplier::new(fixture_yaml())),
Arc::new(PermissiveDenylist),
vec![],
Arc::new(MockReload::new(Ok(()))),
proposals_dir,
)
.await;
assert_eq!(tool_after.pending_receivers.lock().await.len(), 0);
assert_eq!(tool_after.correlator.pending_count(), 0);
let recovered = tool_after.recover_pending_from_staging().await.unwrap();
assert_eq!(recovered, 1);
assert!(tool_after
.pending_receivers
.lock()
.await
.contains_key(&patch_id));
assert_eq!(tool_after.correlator.pending_count(), 1);
tool_after.correlator.on_inbound(
crate::agent::approval_correlator::InboundApprovalMessage {
channel: "whatsapp".into(),
account_id: "default".into(),
sender_id: "5511".into(),
body: format!("[config-approve patch_id={patch_id}]"),
received_at: 0,
},
);
let apply = tool_after
.call(
&agent_context_fixture(),
json!({ "op": "apply", "patch_id": patch_id.clone() }),
)
.await
.unwrap();
assert_eq!(apply["ok"], true);
assert_eq!(apply["applied"], true);
}
#[tokio::test]
async fn apply_no_pending_can_recover_receiver_from_staging_file() {
let tool = Arc::new(
build_tool(
Arc::new(MockApplier::new(fixture_yaml())),
Arc::new(PermissiveDenylist),
vec![],
)
.await,
);
let propose = tool
.call(
&agent_context_fixture(),
json!({
"op": "propose",
"key": "model.model",
"value": "claude-opus-4-7",
"justification": "lazy recover test"
}),
)
.await
.unwrap();
let patch_id = propose["patch_id"].as_str().unwrap().to_string();
tool.pending_receivers.lock().await.remove(&patch_id);
let _ = tool.correlator.cancel_patch(&patch_id);
let tool_for_apply = Arc::clone(&tool);
let patch_for_apply = patch_id.clone();
let apply_task = tokio::spawn(async move {
tool_for_apply
.call(
&agent_context_fixture(),
json!({ "op": "apply", "patch_id": patch_for_apply }),
)
.await
.unwrap()
});
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
tool.correlator
.on_inbound(crate::agent::approval_correlator::InboundApprovalMessage {
channel: "whatsapp".into(),
account_id: "default".into(),
sender_id: "5511".into(),
body: format!("[config-approve patch_id={patch_id}]"),
received_at: 0,
});
let apply = apply_task.await.unwrap();
assert_eq!(apply["ok"], true);
assert_eq!(apply["applied"], true);
}
#[tokio::test]
async fn propose_blocks_forbidden_key() {
let tool = build_tool(
Arc::new(MockApplier::new(fixture_yaml())),
Arc::new(StrictDenylist),
vec![],
)
.await;
let res = tool
.call(
&agent_context_fixture(),
json!({
"op": "propose",
"key": "pairing.session_token",
"value": "x"
}),
)
.await
.unwrap();
assert_eq!(res["ok"], false);
assert_eq!(res["kind"], "UnknownKey");
}
#[tokio::test]
async fn propose_rejects_invalid_value() {
let tool = build_tool(
Arc::new(MockApplier::new(fixture_yaml())),
Arc::new(PermissiveDenylist),
vec![],
)
.await;
let res = tool
.call(
&agent_context_fixture(),
json!({
"op": "propose",
"key": "lsp.languages",
"value": ["rust", "kotlin"]
}),
)
.await
.unwrap();
assert_eq!(res["ok"], false);
assert_eq!(res["kind"], "ValidationFailed");
}
#[tokio::test]
async fn apply_returns_no_pending_when_patch_id_unknown() {
let tool = build_tool(
Arc::new(MockApplier::new(fixture_yaml())),
Arc::new(PermissiveDenylist),
vec![],
)
.await;
let res = tool
.call(
&agent_context_fixture(),
json!({ "op": "apply", "patch_id": "01J7UNKNOWN" }),
)
.await
.unwrap();
assert_eq!(res["ok"], false);
assert_eq!(res["kind"], "NoPending");
}
#[tokio::test]
async fn apply_after_approval_writes_yaml_and_marks_applied() {
let tool = build_tool_with_reload(
Arc::new(MockApplier::new(fixture_yaml())),
Arc::new(PermissiveDenylist),
vec![],
Arc::new(MockReload::new(Ok(()))),
)
.await;
let propose = tool
.call(
&agent_context_fixture(),
json!({
"op": "propose",
"key": "model.model",
"value": "claude-opus-4-7",
"justification": "test"
}),
)
.await
.unwrap();
let patch_id = propose["patch_id"].as_str().unwrap().to_string();
tool.correlator
.on_inbound(crate::agent::approval_correlator::InboundApprovalMessage {
channel: "whatsapp".into(),
account_id: "default".into(),
sender_id: "5511".into(),
body: format!("[config-approve patch_id={patch_id}]"),
received_at: 0,
});
let apply = tool
.call(
&agent_context_fixture(),
json!({ "op": "apply", "patch_id": patch_id }),
)
.await
.unwrap();
assert_eq!(apply["ok"], true);
assert_eq!(apply["applied"], true);
let row = apply["patch_id"]
.as_str()
.map(|id| tool.changes_store.get(id))
.unwrap()
.await
.unwrap()
.unwrap();
assert_eq!(row.status, "applied");
}
#[tokio::test]
async fn apply_after_rejection_returns_rejected_with_reason() {
let tool = build_tool(
Arc::new(MockApplier::new(fixture_yaml())),
Arc::new(PermissiveDenylist),
vec![],
)
.await;
let propose = tool
.call(
&agent_context_fixture(),
json!({
"op": "propose",
"key": "model.model",
"value": "claude-opus-4-7",
"justification": "test"
}),
)
.await
.unwrap();
let patch_id = propose["patch_id"].as_str().unwrap().to_string();
tool.correlator
.on_inbound(crate::agent::approval_correlator::InboundApprovalMessage {
channel: "whatsapp".into(),
account_id: "default".into(),
sender_id: "5511".into(),
body: format!("[config-reject patch_id={patch_id} reason=cost]"),
received_at: 0,
});
let apply = tool
.call(
&agent_context_fixture(),
json!({ "op": "apply", "patch_id": patch_id.clone() }),
)
.await
.unwrap();
assert_eq!(apply["ok"], false);
assert_eq!(apply["kind"], "Rejected");
assert_eq!(apply["reason"], "cost");
let row = tool.changes_store.get(&patch_id).await.unwrap().unwrap();
assert_eq!(row.status, "rejected");
}
#[tokio::test]
async fn apply_rolls_back_on_reload_validation_failure() {
let tool = build_tool_with_reload(
Arc::new(MockApplier::new(fixture_yaml())),
Arc::new(PermissiveDenylist),
vec![],
Arc::new(MockReload::new(Err("bad config".into()))),
)
.await;
let propose = tool
.call(
&agent_context_fixture(),
json!({
"op": "propose",
"key": "model.model",
"value": "claude-opus-4-7",
"justification": "test"
}),
)
.await
.unwrap();
let patch_id = propose["patch_id"].as_str().unwrap().to_string();
tool.correlator
.on_inbound(crate::agent::approval_correlator::InboundApprovalMessage {
channel: "whatsapp".into(),
account_id: "default".into(),
sender_id: "5511".into(),
body: format!("[config-approve patch_id={patch_id}]"),
received_at: 0,
});
let apply = tool
.call(
&agent_context_fixture(),
json!({ "op": "apply", "patch_id": patch_id.clone() }),
)
.await
.unwrap();
assert_eq!(apply["ok"], false);
assert_eq!(apply["kind"], "RolledBack");
assert_eq!(apply["reload_error"], "bad config");
assert_eq!(apply["rolled_back_to_previous"], true);
let row = tool.changes_store.get(&patch_id).await.unwrap().unwrap();
assert_eq!(row.status, "rolled_back");
}
#[tokio::test]
async fn unknown_op_returns_explicit_error() {
let tool = build_tool(
Arc::new(MockApplier::new(fixture_yaml())),
Arc::new(PermissiveDenylist),
vec![],
)
.await;
let res = tool
.call(
&agent_context_fixture(),
json!({ "op": "delete", "key": "model.model" }),
)
.await
.unwrap();
assert_eq!(res["ok"], false);
assert_eq!(res["kind"], "UnknownOp");
}
}