use crate::cli::CliOutput;
use crate::cli::helpers::id_short;
use crate::models::field_names;
use crate::{db, identity, validate};
use anyhow::Result;
use clap::{Args, Subcommand};
use std::path::Path;
#[derive(Args)]
pub struct AgentsArgs {
#[command(subcommand)]
pub action: Option<AgentsAction>,
}
#[derive(Subcommand)]
pub enum AgentsAction {
List,
Register {
#[arg(long)]
agent_id: String,
#[arg(long)]
agent_type: String,
#[arg(long, default_value = "")]
capabilities: String,
},
BindKey {
#[arg(long)]
agent_id: String,
#[arg(long)]
pubkey: String,
},
RevokeKey {
#[arg(long)]
agent_id: String,
},
}
#[derive(Args)]
pub struct PendingArgs {
#[command(subcommand)]
pub action: PendingAction,
}
#[derive(Subcommand)]
pub enum PendingAction {
List {
#[arg(long)]
status: Option<String>,
#[arg(long, default_value_t = 100)]
limit: usize,
},
Approve { id: String },
Reject { id: String },
}
pub fn run_agents(
db_path: &Path,
args: AgentsArgs,
json_out: bool,
out: &mut CliOutput<'_>,
) -> Result<()> {
let conn = db::open(db_path)?;
match args.action.unwrap_or(AgentsAction::List) {
AgentsAction::List => {
let agents = db::list_agents(&conn)?;
if json_out {
writeln!(
out.stdout,
"{}",
serde_json::json!({"count": agents.len(), "agents": agents})
)?;
} else if agents.is_empty() {
writeln!(out.stdout, "no registered agents")?;
} else {
for a in &agents {
let caps = if a.capabilities.is_empty() {
String::new()
} else {
format!(" [{}]", a.capabilities.join(","))
};
writeln!(
out.stdout,
"{} type={} registered={} last_seen={}{}",
a.agent_id, a.agent_type, a.registered_at, a.last_seen_at, caps
)?;
}
writeln!(out.stdout, "{} registered agents", agents.len())?;
}
}
AgentsAction::Register {
agent_id,
agent_type,
capabilities,
} => {
validate::validate_agent_id(&agent_id)?;
validate::validate_agent_type(&agent_type)?;
let caps: Vec<String> = capabilities
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from)
.collect();
validate::validate_capabilities(&caps)?;
let id = db::register_agent(&conn, &agent_id, &agent_type, &caps)?;
if json_out {
writeln!(
out.stdout,
"{}",
serde_json::json!({
(field_names::REGISTERED): true,
"id": id,
"agent_id": agent_id,
(field_names::AGENT_TYPE): agent_type,
(field_names::CAPABILITIES): caps,
})
)?;
} else {
writeln!(
out.stdout,
"registered {agent_id} (type={agent_type}, capabilities={})",
if caps.is_empty() {
"-".to_string()
} else {
caps.join(",")
}
)?;
}
}
AgentsAction::BindKey { agent_id, pubkey } => {
validate::validate_agent_id(&agent_id)?;
validate::validate_agent_pubkey_b64(&pubkey)?;
let trimmed = pubkey.trim();
db::bind_agent_pubkey(&conn, &agent_id, trimmed)?;
if json_out {
writeln!(
out.stdout,
"{}",
serde_json::json!({
"bound": true,
"agent_id": agent_id,
(field_names::AGENT_PUBKEY): trimmed,
})
)?;
} else {
writeln!(out.stdout, "bound pubkey for {agent_id}")?;
}
}
AgentsAction::RevokeKey { agent_id } => {
validate::validate_agent_id(&agent_id)?;
db::revoke_agent_pubkey(&conn, &agent_id)?;
if json_out {
writeln!(
out.stdout,
"{}",
serde_json::json!({
"revoked": true,
"agent_id": agent_id,
})
)?;
} else {
writeln!(out.stdout, "revoked pubkey for {agent_id}")?;
}
}
}
Ok(())
}
pub fn run_pending(
db_path: &Path,
args: PendingArgs,
json_out: bool,
cli_agent_id: Option<&str>,
out: &mut CliOutput<'_>,
) -> Result<()> {
let conn = db::open(db_path)?;
match args.action {
PendingAction::List { status, limit } => {
let items = db::list_pending_actions(&conn, status.as_deref(), limit)?;
if json_out {
writeln!(
out.stdout,
"{}",
serde_json::json!({"count": items.len(), "pending": items})
)?;
} else if items.is_empty() {
writeln!(out.stdout, "no pending actions")?;
} else {
for item in &items {
writeln!(
out.stdout,
"[{}] {} ns={} action={} by={} ({})",
id_short(&item.id),
item.status,
item.namespace,
item.action_type,
item.requested_by,
item.requested_at
)?;
}
writeln!(out.stdout, "{} pending action(s)", items.len())?;
}
}
PendingAction::Approve { id } => {
use db::ApproveOutcome;
validate::validate_id(&id)?;
let agent = identity::resolve_agent_id(cli_agent_id, None)?;
match db::approve_with_approver_type(&conn, &id, &agent)? {
ApproveOutcome::Approved => {
let executed = db::execute_pending_action(&conn, &id)?;
if json_out {
writeln!(
out.stdout,
"{}",
serde_json::json!({
"approved": true,
"id": id,
(field_names::DECIDED_BY): agent,
"executed": true,
"memory_id": executed,
})
)?;
} else {
writeln!(out.stdout, "approved + executed: {id} (by {agent})")?;
}
}
ApproveOutcome::Pending { votes, quorum } => {
if json_out {
writeln!(
out.stdout,
"{}",
serde_json::json!({
"approved": false,
"status": "pending",
"id": id,
"votes": votes,
"quorum": quorum,
"reason": crate::errors::msg::CONSENSUS_NOT_REACHED,
})
)?;
} else {
writeln!(
out.stdout,
"approval recorded: {id} ({votes}/{quorum} consensus, not yet met)"
)?;
}
}
ApproveOutcome::NotFound => {
anyhow::bail!(crate::errors::msg::pending_action_not_found(&id));
}
ApproveOutcome::Rejected(reason) => {
writeln!(
out.stderr,
"{}",
crate::errors::msg::approve_rejected(&reason)
)?;
std::process::exit(1);
}
}
}
PendingAction::Reject { id } => {
validate::validate_id(&id)?;
let agent = identity::resolve_agent_id(cli_agent_id, None)?;
let ok = db::decide_pending_action(&conn, &id, false, &agent)?;
if !ok {
writeln!(
out.stderr,
"pending action not found or already decided: {id}"
)?;
std::process::exit(1);
}
if json_out {
writeln!(
out.stdout,
"{}",
serde_json::json!({"rejected": true, "id": id, (field_names::DECIDED_BY): agent})
)?;
} else {
writeln!(out.stdout, "rejected: {id} (by {agent})")?;
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::test_utils::TestEnv;
#[test]
fn test_agents_list_empty() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let args = AgentsArgs {
action: Some(AgentsAction::List),
};
{
let mut out = env.output();
run_agents(&db, args, false, &mut out).unwrap();
}
assert!(env.stdout_str().contains("no registered agents"));
}
#[test]
fn test_agents_list_empty_json() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let args = AgentsArgs {
action: Some(AgentsAction::List),
};
{
let mut out = env.output();
run_agents(&db, args, true, &mut out).unwrap();
}
let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
assert_eq!(v["count"].as_u64().unwrap(), 0);
}
#[test]
fn test_agents_register_happy_path() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let args = AgentsArgs {
action: Some(AgentsAction::Register {
agent_id: "agent-1".to_string(),
agent_type: "human".to_string(),
capabilities: "alpha,beta".to_string(),
}),
};
{
let mut out = env.output();
run_agents(&db, args, false, &mut out).unwrap();
}
assert!(env.stdout_str().contains("registered agent-1"));
}
#[test]
fn test_agents_register_then_list() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let reg = AgentsArgs {
action: Some(AgentsAction::Register {
agent_id: "agent-2".to_string(),
agent_type: "system".to_string(),
capabilities: String::new(),
}),
};
{
let mut out = env.output();
run_agents(&db, reg, false, &mut out).unwrap();
}
env.stdout.clear();
env.stderr.clear();
let list = AgentsArgs {
action: Some(AgentsAction::List),
};
{
let mut out = env.output();
run_agents(&db, list, false, &mut out).unwrap();
}
let s = env.stdout_str();
assert!(s.contains("agent-2"));
assert!(s.contains("type=system"));
}
#[test]
fn test_agents_register_invalid_agent_id() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let args = AgentsArgs {
action: Some(AgentsAction::Register {
agent_id: String::new(), agent_type: "human".to_string(),
capabilities: String::new(),
}),
};
let mut out = env.output();
let res = run_agents(&db, args, false, &mut out);
assert!(res.is_err());
}
#[test]
fn test_agents_default_action_is_list() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let args = AgentsArgs { action: None };
{
let mut out = env.output();
run_agents(&db, args, false, &mut out).unwrap();
}
assert!(env.stdout_str().contains("no registered agents"));
}
#[test]
fn test_pending_list_empty() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let args = PendingArgs {
action: PendingAction::List {
status: None,
limit: 100,
},
};
{
let mut out = env.output();
run_pending(&db, args, false, Some("test-agent"), &mut out).unwrap();
}
assert!(env.stdout_str().contains("no pending actions"));
}
#[test]
fn test_pending_list_empty_json() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let args = PendingArgs {
action: PendingAction::List {
status: Some("pending".to_string()),
limit: 100,
},
};
{
let mut out = env.output();
run_pending(&db, args, true, Some("test-agent"), &mut out).unwrap();
}
let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
assert_eq!(v["count"].as_u64().unwrap(), 0);
}
#[test]
fn test_agents_register_json_output() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let args = AgentsArgs {
action: Some(AgentsAction::Register {
agent_id: "agent-json".to_string(),
agent_type: "human".to_string(),
capabilities: "x,y,z".to_string(),
}),
};
{
let mut out = env.output();
run_agents(&db, args, true, &mut out).unwrap();
}
let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
assert_eq!(v["registered"].as_bool().unwrap(), true);
assert_eq!(v["agent_id"].as_str().unwrap(), "agent-json");
assert_eq!(v["agent_type"].as_str().unwrap(), "human");
assert_eq!(v["capabilities"].as_array().unwrap().len(), 3);
}
#[test]
fn test_agents_register_empty_caps_human_text_dash() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let args = AgentsArgs {
action: Some(AgentsAction::Register {
agent_id: "agent-no-caps".to_string(),
agent_type: "system".to_string(),
capabilities: String::new(),
}),
};
{
let mut out = env.output();
run_agents(&db, args, false, &mut out).unwrap();
}
assert!(env.stdout_str().contains("capabilities=-"));
}
#[test]
fn test_agents_list_with_registered_agent_text_includes_caps() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let reg = AgentsArgs {
action: Some(AgentsAction::Register {
agent_id: "agent-with-caps".to_string(),
agent_type: "ai:claude-opus-4.7".to_string(),
capabilities: "alpha,beta".to_string(),
}),
};
{
let mut out = env.output();
run_agents(&db, reg, false, &mut out).unwrap();
}
env.stdout.clear();
env.stderr.clear();
let list = AgentsArgs {
action: Some(AgentsAction::List),
};
{
let mut out = env.output();
run_agents(&db, list, false, &mut out).unwrap();
}
let s = env.stdout_str();
assert!(s.contains("agent-with-caps"));
assert!(s.contains("type=ai:claude-opus-4.7"));
assert!(s.contains("[alpha,beta]"));
assert!(s.contains("1 registered agents"));
}
#[test]
fn test_agents_list_json_with_items() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let reg = AgentsArgs {
action: Some(AgentsAction::Register {
agent_id: "agent-jsonlist".to_string(),
agent_type: "human".to_string(),
capabilities: String::new(),
}),
};
{
let mut out = env.output();
run_agents(&db, reg, false, &mut out).unwrap();
}
env.stdout.clear();
env.stderr.clear();
let list = AgentsArgs {
action: Some(AgentsAction::List),
};
{
let mut out = env.output();
run_agents(&db, list, true, &mut out).unwrap();
}
let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
assert_eq!(v["count"].as_u64().unwrap(), 1);
assert_eq!(
v["agents"][0]["agent_id"].as_str().unwrap(),
"agent-jsonlist"
);
}
fn seed_pending_action(
db_path: &std::path::Path,
id: &str,
ns: &str,
action_type: &str,
requested_by: &str,
) {
use rusqlite::params;
let conn = db::open(db_path).expect("db::open");
let now = chrono::Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO pending_actions \
(id, action_type, namespace, payload, requested_by, requested_at, status) \
VALUES (?1, ?2, ?3, '{}', ?4, ?5, 'pending')",
params![id, action_type, ns, requested_by, now],
)
.expect("insert pending_actions");
}
#[test]
fn test_pending_list_text_with_items() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
seed_pending_action(&db, "pa-1", "ns-x", "store", "test-agent");
seed_pending_action(&db, "pa-2", "ns-y", "delete", "test-agent");
let args = PendingArgs {
action: PendingAction::List {
status: None,
limit: 100,
},
};
{
let mut out = env.output();
run_pending(&db, args, false, Some("test-agent"), &mut out).unwrap();
}
let s = env.stdout_str();
assert!(s.contains("pa-1") || s.contains("pa-2"));
assert!(s.contains("pending action"));
}
#[test]
fn test_pending_list_json_with_items() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
seed_pending_action(&db, "pa-json-1", "ns-x", "store", "test-agent");
let args = PendingArgs {
action: PendingAction::List {
status: None,
limit: 100,
},
};
{
let mut out = env.output();
run_pending(&db, args, true, Some("test-agent"), &mut out).unwrap();
}
let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
assert_eq!(v["count"].as_u64().unwrap(), 1);
assert!(v["pending"].is_array());
}
fn seed_delete_pending(db_path: &std::path::Path, pa_id: &str, ns: &str) -> String {
use rusqlite::params;
let target = seed_memory_local(db_path, ns, &format!("t-{pa_id}"), "c");
let conn = db::open(db_path).expect("db::open");
let now = chrono::Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO pending_actions \
(id, action_type, memory_id, namespace, payload, requested_by, requested_at, status) \
VALUES (?1, 'delete', ?2, ?3, '{}', 'test-agent', ?4, 'pending')",
params![pa_id, target, ns, now],
)
.expect("seed pending");
target
}
#[test]
fn test_pending_approve_happy_text() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
seed_delete_pending(&db, "pa-approve-1", "ns-app");
let args = PendingArgs {
action: PendingAction::Approve {
id: "pa-approve-1".to_string(),
},
};
{
let mut out = env.output();
run_pending(&db, args, false, Some("test-agent"), &mut out).unwrap();
}
let s = env.stdout_str();
assert!(
s.contains("approved + executed: pa-approve-1"),
"expected approved+executed line, got: {s}"
);
}
#[test]
fn test_pending_approve_happy_json() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
seed_delete_pending(&db, "pa-approve-json", "ns-app2");
let args = PendingArgs {
action: PendingAction::Approve {
id: "pa-approve-json".to_string(),
},
};
{
let mut out = env.output();
run_pending(&db, args, true, Some("test-agent"), &mut out).unwrap();
}
let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
assert_eq!(v["approved"].as_bool().unwrap(), true);
assert_eq!(v["id"].as_str().unwrap(), "pa-approve-json");
assert_eq!(v["decided_by"].as_str().unwrap(), "test-agent");
}
#[test]
fn test_pending_reject_happy_text() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
seed_pending_action(&db, "pa-reject-1", "ns-r", "store", "test-agent");
let args = PendingArgs {
action: PendingAction::Reject {
id: "pa-reject-1".to_string(),
},
};
{
let mut out = env.output();
run_pending(&db, args, false, Some("test-agent"), &mut out).unwrap();
}
assert!(env.stdout_str().contains("rejected: pa-reject-1"));
}
#[test]
fn test_pending_reject_happy_json() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
seed_pending_action(&db, "pa-reject-j", "ns-r", "store", "test-agent");
let args = PendingArgs {
action: PendingAction::Reject {
id: "pa-reject-j".to_string(),
},
};
{
let mut out = env.output();
run_pending(&db, args, true, Some("test-agent"), &mut out).unwrap();
}
let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
assert_eq!(v["rejected"].as_bool().unwrap(), true);
assert_eq!(v["id"].as_str().unwrap(), "pa-reject-j");
assert_eq!(v["decided_by"].as_str().unwrap(), "test-agent");
}
fn install_consensus_policy(db_path: &std::path::Path, namespace: &str, quorum: u32) {
let conn = db::open(db_path).expect("db::open");
let policy = serde_json::json!({
"write": "approve",
"promote": "any",
"delete": "owner",
"approver": {"consensus": quorum},
"inherit": true,
});
let now = chrono::Utc::now().to_rfc3339();
let mut metadata = crate::models::default_metadata();
if let Some(obj) = metadata.as_object_mut() {
obj.insert(
"agent_id".to_string(),
serde_json::Value::String("test-agent".to_string()),
);
obj.insert("governance".to_string(), policy);
}
let mem = crate::models::Memory {
id: uuid::Uuid::new_v4().to_string(),
tier: crate::models::Tier::Long,
namespace: namespace.to_string(),
title: format!("standard:{namespace}"),
content: "policy standard".to_string(),
tags: vec![],
priority: 9,
confidence: 1.0,
source: "test".to_string(),
access_count: 0,
created_at: now.clone(),
updated_at: now,
last_accessed_at: None,
expires_at: None,
metadata,
reflection_depth: 0,
memory_kind: crate::models::MemoryKind::Observation,
entity_id: None,
persona_version: None,
citations: Vec::new(),
source_uri: None,
source_span: None,
confidence_source: crate::models::ConfidenceSource::CallerProvided,
confidence_signals: None,
confidence_decayed_at: None,
version: 1,
};
let id = db::insert(&conn, &mem).expect("db::insert standard");
db::set_namespace_standard(&conn, namespace, &id, None).expect("set_namespace_standard");
}
#[test]
fn test_pending_approve_consensus_pending_branch() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
for who in ["voter-a", "voter-b"] {
let reg = AgentsArgs {
action: Some(AgentsAction::Register {
agent_id: who.to_string(),
agent_type: "human".to_string(),
capabilities: String::new(),
}),
};
let mut out = env.output();
run_agents(&db, reg, false, &mut out).expect("register voter");
}
env.stdout.clear();
install_consensus_policy(&db, "ns-cons", 2);
seed_pending_action(&db, "pa-cons-1", "ns-cons", "store", "voter-a");
let args = PendingArgs {
action: PendingAction::Approve {
id: "pa-cons-1".to_string(),
},
};
{
let mut out = env.output();
run_pending(&db, args, false, Some("voter-a"), &mut out).expect("approve voter-a");
}
assert!(
env.stdout_str().contains("approval recorded: pa-cons-1"),
"expected `approval recorded` text, got: {}",
env.stdout_str()
);
}
#[test]
fn test_pending_approve_consensus_pending_json() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
for who in ["voter-a", "voter-b"] {
let reg = AgentsArgs {
action: Some(AgentsAction::Register {
agent_id: who.to_string(),
agent_type: "human".to_string(),
capabilities: String::new(),
}),
};
let mut out = env.output();
run_agents(&db, reg, false, &mut out).expect("register voter");
}
env.stdout.clear();
install_consensus_policy(&db, "ns-cons-j", 2);
seed_pending_action(&db, "pa-cons-j", "ns-cons-j", "store", "voter-a");
let args = PendingArgs {
action: PendingAction::Approve {
id: "pa-cons-j".to_string(),
},
};
{
let mut out = env.output();
run_pending(&db, args, true, Some("voter-a"), &mut out).expect("approve voter-a");
}
let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
assert_eq!(v["approved"].as_bool().unwrap(), false);
assert_eq!(v["status"].as_str().unwrap(), "pending");
assert_eq!(v["quorum"].as_u64().unwrap(), 2);
}
#[test]
fn test_pending_reject_invalid_id_validation_error() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let args = PendingArgs {
action: PendingAction::Reject { id: String::new() },
};
let mut out = env.output();
let res = run_pending(&db, args, false, Some("test-agent"), &mut out);
assert!(res.is_err());
}
#[test]
fn test_pending_approve_invalid_id_validation_error() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let args = PendingArgs {
action: PendingAction::Approve { id: String::new() },
};
let mut out = env.output();
let res = run_pending(&db, args, false, Some("test-agent"), &mut out);
assert!(res.is_err());
}
fn register_and_key(env: &mut TestEnv, db: &std::path::Path, agent_id: &str) -> String {
let reg = AgentsArgs {
action: Some(AgentsAction::Register {
agent_id: agent_id.to_string(),
agent_type: "ai:claude-opus-4.7".to_string(),
capabilities: String::new(),
}),
};
{
let mut out = env.output();
run_agents(db, reg, false, &mut out).expect("register");
}
env.stdout.clear();
env.stderr.clear();
crate::identity::keypair::generate(agent_id)
.expect("generate keypair")
.public_base64()
}
#[test]
fn test_agents_bind_key_happy_text() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let pk = register_and_key(&mut env, &db, "ai:curator");
let args = AgentsArgs {
action: Some(AgentsAction::BindKey {
agent_id: "ai:curator".to_string(),
pubkey: pk.clone(),
}),
};
{
let mut out = env.output();
run_agents(&db, args, false, &mut out).unwrap();
}
assert!(env.stdout_str().contains("bound pubkey for ai:curator"));
let conn = db::open(&db).unwrap();
assert_eq!(db::agent_pubkey(&conn, "ai:curator").unwrap(), Some(pk));
}
#[test]
fn test_agents_bind_key_happy_json() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let pk = register_and_key(&mut env, &db, "ai:curator");
let args = AgentsArgs {
action: Some(AgentsAction::BindKey {
agent_id: "ai:curator".to_string(),
pubkey: pk.clone(),
}),
};
{
let mut out = env.output();
run_agents(&db, args, true, &mut out).unwrap();
}
let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
assert_eq!(v["bound"].as_bool().unwrap(), true);
assert_eq!(v["agent_id"].as_str().unwrap(), "ai:curator");
assert_eq!(v["agent_pubkey"].as_str().unwrap(), pk);
}
#[test]
fn test_agents_bind_key_rotates_in_place() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let k1 = register_and_key(&mut env, &db, "ai:curator");
let k2 = crate::identity::keypair::generate("ai:curator")
.unwrap()
.public_base64();
assert_ne!(k1, k2);
for k in [&k1, &k2] {
let args = AgentsArgs {
action: Some(AgentsAction::BindKey {
agent_id: "ai:curator".to_string(),
pubkey: k.clone(),
}),
};
let mut out = env.output();
run_agents(&db, args, false, &mut out).unwrap();
}
let conn = db::open(&db).unwrap();
assert_eq!(db::agent_pubkey(&conn, "ai:curator").unwrap(), Some(k2));
}
#[test]
fn test_agents_bind_key_unregistered_is_rejected() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let pk = crate::identity::keypair::generate("ai:ghost")
.unwrap()
.public_base64();
let args = AgentsArgs {
action: Some(AgentsAction::BindKey {
agent_id: "ai:ghost".to_string(),
pubkey: pk,
}),
};
let mut out = env.output();
let res = run_agents(&db, args, false, &mut out);
assert!(res.is_err());
assert!(res.unwrap_err().to_string().contains("not registered"));
}
#[test]
fn test_agents_bind_key_malformed_pubkey_is_rejected() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
register_and_key(&mut env, &db, "ai:curator");
let args = AgentsArgs {
action: Some(AgentsAction::BindKey {
agent_id: "ai:curator".to_string(),
pubkey: "not-a-valid-key".to_string(),
}),
};
let mut out = env.output();
let res = run_agents(&db, args, false, &mut out);
assert!(res.is_err());
}
#[test]
fn test_agents_revoke_key_happy_text() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let pk = register_and_key(&mut env, &db, "ai:curator");
{
let conn = db::open(&db).unwrap();
db::bind_agent_pubkey(&conn, "ai:curator", &pk).unwrap();
}
let args = AgentsArgs {
action: Some(AgentsAction::RevokeKey {
agent_id: "ai:curator".to_string(),
}),
};
{
let mut out = env.output();
run_agents(&db, args, false, &mut out).unwrap();
}
assert!(env.stdout_str().contains("revoked pubkey for ai:curator"));
let conn = db::open(&db).unwrap();
assert_eq!(db::agent_pubkey(&conn, "ai:curator").unwrap(), None);
}
#[test]
fn test_agents_revoke_key_happy_json() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let pk = register_and_key(&mut env, &db, "ai:curator");
{
let conn = db::open(&db).unwrap();
db::bind_agent_pubkey(&conn, "ai:curator", &pk).unwrap();
}
let args = AgentsArgs {
action: Some(AgentsAction::RevokeKey {
agent_id: "ai:curator".to_string(),
}),
};
{
let mut out = env.output();
run_agents(&db, args, true, &mut out).unwrap();
}
let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
assert_eq!(v["revoked"].as_bool().unwrap(), true);
assert_eq!(v["agent_id"].as_str().unwrap(), "ai:curator");
}
#[test]
fn test_agents_revoke_key_idempotent_without_bound_key() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
register_and_key(&mut env, &db, "ai:curator");
let args = AgentsArgs {
action: Some(AgentsAction::RevokeKey {
agent_id: "ai:curator".to_string(),
}),
};
{
let mut out = env.output();
run_agents(&db, args, false, &mut out).unwrap();
}
assert!(env.stdout_str().contains("revoked pubkey for ai:curator"));
}
#[test]
fn test_agents_revoke_key_unregistered_is_rejected() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let args = AgentsArgs {
action: Some(AgentsAction::RevokeKey {
agent_id: "ai:ghost".to_string(),
}),
};
let mut out = env.output();
let res = run_agents(&db, args, false, &mut out);
assert!(res.is_err());
assert!(res.unwrap_err().to_string().contains("not registered"));
}
fn seed_memory_local(
db_path: &std::path::Path,
ns: &str,
title: &str,
content: &str,
) -> String {
crate::cli::test_utils::seed_memory(db_path, ns, title, content)
}
}