1use crate::cli::CliOutput;
8use crate::cli::helpers::id_short;
9use crate::models::field_names;
10use crate::{db, identity, validate};
11use anyhow::Result;
12use clap::{Args, Subcommand};
13use std::path::Path;
14
15#[derive(Args)]
16pub struct AgentsArgs {
17 #[command(subcommand)]
18 pub action: Option<AgentsAction>,
19}
20
21#[derive(Subcommand)]
22pub enum AgentsAction {
23 List,
25 Register {
27 #[arg(long)]
29 agent_id: String,
30 #[arg(long)]
35 agent_type: String,
36 #[arg(long, default_value = "")]
38 capabilities: String,
39 },
40 BindKey {
44 #[arg(long)]
46 agent_id: String,
47 #[arg(long)]
50 pubkey: String,
51 },
52 RevokeKey {
56 #[arg(long)]
58 agent_id: String,
59 },
60}
61
62#[derive(Args)]
63pub struct PendingArgs {
64 #[command(subcommand)]
65 pub action: PendingAction,
66}
67
68#[derive(Subcommand)]
69pub enum PendingAction {
70 List {
72 #[arg(long)]
73 status: Option<String>,
74 #[arg(long, default_value_t = 100)]
75 limit: usize,
76 },
77 Approve { id: String },
79 Reject { id: String },
81}
82
83pub fn run_agents(
85 db_path: &Path,
86 args: AgentsArgs,
87 json_out: bool,
88 out: &mut CliOutput<'_>,
89) -> Result<()> {
90 let conn = db::open(db_path)?;
91 match args.action.unwrap_or(AgentsAction::List) {
92 AgentsAction::List => {
93 let agents = db::list_agents(&conn)?;
94 if json_out {
95 writeln!(
96 out.stdout,
97 "{}",
98 serde_json::json!({"count": agents.len(), "agents": agents})
99 )?;
100 } else if agents.is_empty() {
101 writeln!(out.stdout, "no registered agents")?;
102 } else {
103 for a in &agents {
104 let caps = if a.capabilities.is_empty() {
105 String::new()
106 } else {
107 format!(" [{}]", a.capabilities.join(","))
108 };
109 writeln!(
110 out.stdout,
111 "{} type={} registered={} last_seen={}{}",
112 a.agent_id, a.agent_type, a.registered_at, a.last_seen_at, caps
113 )?;
114 }
115 writeln!(out.stdout, "{} registered agents", agents.len())?;
116 }
117 }
118 AgentsAction::Register {
119 agent_id,
120 agent_type,
121 capabilities,
122 } => {
123 validate::validate_agent_id(&agent_id)?;
124 validate::validate_agent_type(&agent_type)?;
125 let caps: Vec<String> = capabilities
126 .split(',')
127 .map(str::trim)
128 .filter(|s| !s.is_empty())
129 .map(String::from)
130 .collect();
131 validate::validate_capabilities(&caps)?;
132 let id = db::register_agent(&conn, &agent_id, &agent_type, &caps)?;
133 if json_out {
134 writeln!(
135 out.stdout,
136 "{}",
137 serde_json::json!({
138 (field_names::REGISTERED): true,
139 "id": id,
140 "agent_id": agent_id,
141 (field_names::AGENT_TYPE): agent_type,
142 (field_names::CAPABILITIES): caps,
143 })
144 )?;
145 } else {
146 writeln!(
147 out.stdout,
148 "registered {agent_id} (type={agent_type}, capabilities={})",
149 if caps.is_empty() {
150 "-".to_string()
151 } else {
152 caps.join(",")
153 }
154 )?;
155 }
156 }
157 AgentsAction::BindKey { agent_id, pubkey } => {
158 validate::validate_agent_id(&agent_id)?;
159 validate::validate_agent_pubkey_b64(&pubkey)?;
160 let trimmed = pubkey.trim();
161 db::bind_agent_pubkey(&conn, &agent_id, trimmed)?;
162 if json_out {
163 writeln!(
164 out.stdout,
165 "{}",
166 serde_json::json!({
167 "bound": true,
168 "agent_id": agent_id,
169 (field_names::AGENT_PUBKEY): trimmed,
170 })
171 )?;
172 } else {
173 writeln!(out.stdout, "bound pubkey for {agent_id}")?;
174 }
175 }
176 AgentsAction::RevokeKey { agent_id } => {
177 validate::validate_agent_id(&agent_id)?;
178 db::revoke_agent_pubkey(&conn, &agent_id)?;
179 if json_out {
180 writeln!(
181 out.stdout,
182 "{}",
183 serde_json::json!({
184 "revoked": true,
185 "agent_id": agent_id,
186 })
187 )?;
188 } else {
189 writeln!(out.stdout, "revoked pubkey for {agent_id}")?;
190 }
191 }
192 }
193 Ok(())
194}
195
196pub fn run_pending(
198 db_path: &Path,
199 args: PendingArgs,
200 json_out: bool,
201 cli_agent_id: Option<&str>,
202 out: &mut CliOutput<'_>,
203) -> Result<()> {
204 let conn = db::open(db_path)?;
205 match args.action {
206 PendingAction::List { status, limit } => {
207 let items = db::list_pending_actions(&conn, status.as_deref(), limit)?;
208 if json_out {
209 writeln!(
210 out.stdout,
211 "{}",
212 serde_json::json!({"count": items.len(), "pending": items})
213 )?;
214 } else if items.is_empty() {
215 writeln!(out.stdout, "no pending actions")?;
216 } else {
217 for item in &items {
218 writeln!(
219 out.stdout,
220 "[{}] {} ns={} action={} by={} ({})",
221 id_short(&item.id),
222 item.status,
223 item.namespace,
224 item.action_type,
225 item.requested_by,
226 item.requested_at
227 )?;
228 }
229 writeln!(out.stdout, "{} pending action(s)", items.len())?;
230 }
231 }
232 PendingAction::Approve { id } => {
233 use db::ApproveOutcome;
234 validate::validate_id(&id)?;
235 let agent = identity::resolve_agent_id(cli_agent_id, None)?;
236 match db::approve_with_approver_type(&conn, &id, &agent)? {
237 ApproveOutcome::Approved => {
238 let executed = db::execute_pending_action(&conn, &id)?;
239 if json_out {
240 writeln!(
241 out.stdout,
242 "{}",
243 serde_json::json!({
244 "approved": true,
245 "id": id,
246 (field_names::DECIDED_BY): agent,
247 "executed": true,
248 "memory_id": executed,
249 })
250 )?;
251 } else {
252 writeln!(out.stdout, "approved + executed: {id} (by {agent})")?;
253 }
254 }
255 ApproveOutcome::Pending { votes, quorum } => {
256 if json_out {
257 writeln!(
258 out.stdout,
259 "{}",
260 serde_json::json!({
261 "approved": false,
262 "status": "pending",
263 "id": id,
264 "votes": votes,
265 "quorum": quorum,
266 "reason": crate::errors::msg::CONSENSUS_NOT_REACHED,
267 })
268 )?;
269 } else {
270 writeln!(
271 out.stdout,
272 "approval recorded: {id} ({votes}/{quorum} consensus, not yet met)"
273 )?;
274 }
275 }
276 ApproveOutcome::NotFound => {
278 anyhow::bail!(crate::errors::msg::pending_action_not_found(&id));
279 }
280 ApproveOutcome::Rejected(reason) => {
281 writeln!(
282 out.stderr,
283 "{}",
284 crate::errors::msg::approve_rejected(&reason)
285 )?;
286 std::process::exit(1);
287 }
288 }
289 }
290 PendingAction::Reject { id } => {
291 validate::validate_id(&id)?;
292 let agent = identity::resolve_agent_id(cli_agent_id, None)?;
293 let ok = db::decide_pending_action(&conn, &id, false, &agent)?;
294 if !ok {
295 writeln!(
296 out.stderr,
297 "pending action not found or already decided: {id}"
298 )?;
299 std::process::exit(1);
300 }
301 if json_out {
302 writeln!(
303 out.stdout,
304 "{}",
305 serde_json::json!({"rejected": true, "id": id, (field_names::DECIDED_BY): agent})
306 )?;
307 } else {
308 writeln!(out.stdout, "rejected: {id} (by {agent})")?;
309 }
310 }
311 }
312 Ok(())
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318 use crate::cli::test_utils::TestEnv;
319
320 #[test]
321 fn test_agents_list_empty() {
322 let mut env = TestEnv::fresh();
323 let db = env.db_path.clone();
324 let args = AgentsArgs {
325 action: Some(AgentsAction::List),
326 };
327 {
328 let mut out = env.output();
329 run_agents(&db, args, false, &mut out).unwrap();
330 }
331 assert!(env.stdout_str().contains("no registered agents"));
332 }
333
334 #[test]
335 fn test_agents_list_empty_json() {
336 let mut env = TestEnv::fresh();
337 let db = env.db_path.clone();
338 let args = AgentsArgs {
339 action: Some(AgentsAction::List),
340 };
341 {
342 let mut out = env.output();
343 run_agents(&db, args, true, &mut out).unwrap();
344 }
345 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
346 assert_eq!(v["count"].as_u64().unwrap(), 0);
347 }
348
349 #[test]
350 fn test_agents_register_happy_path() {
351 let mut env = TestEnv::fresh();
352 let db = env.db_path.clone();
353 let args = AgentsArgs {
354 action: Some(AgentsAction::Register {
355 agent_id: "agent-1".to_string(),
356 agent_type: "human".to_string(),
357 capabilities: "alpha,beta".to_string(),
358 }),
359 };
360 {
361 let mut out = env.output();
362 run_agents(&db, args, false, &mut out).unwrap();
363 }
364 assert!(env.stdout_str().contains("registered agent-1"));
365 }
366
367 #[test]
368 fn test_agents_register_then_list() {
369 let mut env = TestEnv::fresh();
370 let db = env.db_path.clone();
371 let reg = AgentsArgs {
372 action: Some(AgentsAction::Register {
373 agent_id: "agent-2".to_string(),
374 agent_type: "system".to_string(),
375 capabilities: String::new(),
376 }),
377 };
378 {
379 let mut out = env.output();
380 run_agents(&db, reg, false, &mut out).unwrap();
381 }
382 env.stdout.clear();
383 env.stderr.clear();
384 let list = AgentsArgs {
385 action: Some(AgentsAction::List),
386 };
387 {
388 let mut out = env.output();
389 run_agents(&db, list, false, &mut out).unwrap();
390 }
391 let s = env.stdout_str();
392 assert!(s.contains("agent-2"));
393 assert!(s.contains("type=system"));
394 }
395
396 #[test]
397 fn test_agents_register_invalid_agent_id() {
398 let mut env = TestEnv::fresh();
399 let db = env.db_path.clone();
400 let args = AgentsArgs {
401 action: Some(AgentsAction::Register {
402 agent_id: String::new(), agent_type: "human".to_string(),
404 capabilities: String::new(),
405 }),
406 };
407 let mut out = env.output();
408 let res = run_agents(&db, args, false, &mut out);
409 assert!(res.is_err());
410 }
411
412 #[test]
413 fn test_agents_default_action_is_list() {
414 let mut env = TestEnv::fresh();
415 let db = env.db_path.clone();
416 let args = AgentsArgs { action: None };
417 {
418 let mut out = env.output();
419 run_agents(&db, args, false, &mut out).unwrap();
420 }
421 assert!(env.stdout_str().contains("no registered agents"));
422 }
423
424 #[test]
425 fn test_pending_list_empty() {
426 let mut env = TestEnv::fresh();
427 let db = env.db_path.clone();
428 let args = PendingArgs {
429 action: PendingAction::List {
430 status: None,
431 limit: 100,
432 },
433 };
434 {
435 let mut out = env.output();
436 run_pending(&db, args, false, Some("test-agent"), &mut out).unwrap();
437 }
438 assert!(env.stdout_str().contains("no pending actions"));
439 }
440
441 #[test]
442 fn test_pending_list_empty_json() {
443 let mut env = TestEnv::fresh();
444 let db = env.db_path.clone();
445 let args = PendingArgs {
446 action: PendingAction::List {
447 status: Some("pending".to_string()),
448 limit: 100,
449 },
450 };
451 {
452 let mut out = env.output();
453 run_pending(&db, args, true, Some("test-agent"), &mut out).unwrap();
454 }
455 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
456 assert_eq!(v["count"].as_u64().unwrap(), 0);
457 }
458
459 #[test]
466 fn test_agents_register_json_output() {
467 let mut env = TestEnv::fresh();
470 let db = env.db_path.clone();
471 let args = AgentsArgs {
472 action: Some(AgentsAction::Register {
473 agent_id: "agent-json".to_string(),
474 agent_type: "human".to_string(),
475 capabilities: "x,y,z".to_string(),
476 }),
477 };
478 {
479 let mut out = env.output();
480 run_agents(&db, args, true, &mut out).unwrap();
481 }
482 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
483 assert_eq!(v["registered"].as_bool().unwrap(), true);
484 assert_eq!(v["agent_id"].as_str().unwrap(), "agent-json");
485 assert_eq!(v["agent_type"].as_str().unwrap(), "human");
486 assert_eq!(v["capabilities"].as_array().unwrap().len(), 3);
488 }
489
490 #[test]
491 fn test_agents_register_empty_caps_human_text_dash() {
492 let mut env = TestEnv::fresh();
495 let db = env.db_path.clone();
496 let args = AgentsArgs {
497 action: Some(AgentsAction::Register {
498 agent_id: "agent-no-caps".to_string(),
499 agent_type: "system".to_string(),
500 capabilities: String::new(),
501 }),
502 };
503 {
504 let mut out = env.output();
505 run_agents(&db, args, false, &mut out).unwrap();
506 }
507 assert!(env.stdout_str().contains("capabilities=-"));
509 }
510
511 #[test]
512 fn test_agents_list_with_registered_agent_text_includes_caps() {
513 let mut env = TestEnv::fresh();
517 let db = env.db_path.clone();
518 let reg = AgentsArgs {
519 action: Some(AgentsAction::Register {
520 agent_id: "agent-with-caps".to_string(),
521 agent_type: "ai:claude-opus-4.7".to_string(),
522 capabilities: "alpha,beta".to_string(),
523 }),
524 };
525 {
526 let mut out = env.output();
527 run_agents(&db, reg, false, &mut out).unwrap();
528 }
529 env.stdout.clear();
530 env.stderr.clear();
531 let list = AgentsArgs {
532 action: Some(AgentsAction::List),
533 };
534 {
535 let mut out = env.output();
536 run_agents(&db, list, false, &mut out).unwrap();
537 }
538 let s = env.stdout_str();
539 assert!(s.contains("agent-with-caps"));
540 assert!(s.contains("type=ai:claude-opus-4.7"));
541 assert!(s.contains("[alpha,beta]"));
542 assert!(s.contains("1 registered agents"));
543 }
544
545 #[test]
546 fn test_agents_list_json_with_items() {
547 let mut env = TestEnv::fresh();
550 let db = env.db_path.clone();
551 let reg = AgentsArgs {
552 action: Some(AgentsAction::Register {
553 agent_id: "agent-jsonlist".to_string(),
554 agent_type: "human".to_string(),
555 capabilities: String::new(),
556 }),
557 };
558 {
559 let mut out = env.output();
560 run_agents(&db, reg, false, &mut out).unwrap();
561 }
562 env.stdout.clear();
563 env.stderr.clear();
564 let list = AgentsArgs {
565 action: Some(AgentsAction::List),
566 };
567 {
568 let mut out = env.output();
569 run_agents(&db, list, true, &mut out).unwrap();
570 }
571 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
572 assert_eq!(v["count"].as_u64().unwrap(), 1);
573 assert_eq!(
574 v["agents"][0]["agent_id"].as_str().unwrap(),
575 "agent-jsonlist"
576 );
577 }
578
579 fn seed_pending_action(
585 db_path: &std::path::Path,
586 id: &str,
587 ns: &str,
588 action_type: &str,
589 requested_by: &str,
590 ) {
591 use rusqlite::params;
592 let conn = db::open(db_path).expect("db::open");
593 let now = chrono::Utc::now().to_rfc3339();
594 conn.execute(
595 "INSERT INTO pending_actions \
596 (id, action_type, namespace, payload, requested_by, requested_at, status) \
597 VALUES (?1, ?2, ?3, '{}', ?4, ?5, 'pending')",
598 params![id, action_type, ns, requested_by, now],
599 )
600 .expect("insert pending_actions");
601 }
602
603 #[test]
604 fn test_pending_list_text_with_items() {
605 let mut env = TestEnv::fresh();
607 let db = env.db_path.clone();
608 seed_pending_action(&db, "pa-1", "ns-x", "store", "test-agent");
609 seed_pending_action(&db, "pa-2", "ns-y", "delete", "test-agent");
610 let args = PendingArgs {
611 action: PendingAction::List {
612 status: None,
613 limit: 100,
614 },
615 };
616 {
617 let mut out = env.output();
618 run_pending(&db, args, false, Some("test-agent"), &mut out).unwrap();
619 }
620 let s = env.stdout_str();
621 assert!(s.contains("pa-1") || s.contains("pa-2"));
622 assert!(s.contains("pending action"));
623 }
624
625 #[test]
626 fn test_pending_list_json_with_items() {
627 let mut env = TestEnv::fresh();
628 let db = env.db_path.clone();
629 seed_pending_action(&db, "pa-json-1", "ns-x", "store", "test-agent");
630 let args = PendingArgs {
631 action: PendingAction::List {
632 status: None,
633 limit: 100,
634 },
635 };
636 {
637 let mut out = env.output();
638 run_pending(&db, args, true, Some("test-agent"), &mut out).unwrap();
639 }
640 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
641 assert_eq!(v["count"].as_u64().unwrap(), 1);
642 assert!(v["pending"].is_array());
643 }
644
645 fn seed_delete_pending(db_path: &std::path::Path, pa_id: &str, ns: &str) -> String {
651 use rusqlite::params;
652 let target = seed_memory_local(db_path, ns, &format!("t-{pa_id}"), "c");
653 let conn = db::open(db_path).expect("db::open");
654 let now = chrono::Utc::now().to_rfc3339();
655 conn.execute(
656 "INSERT INTO pending_actions \
657 (id, action_type, memory_id, namespace, payload, requested_by, requested_at, status) \
658 VALUES (?1, 'delete', ?2, ?3, '{}', 'test-agent', ?4, 'pending')",
659 params![pa_id, target, ns, now],
660 )
661 .expect("seed pending");
662 target
663 }
664
665 #[test]
666 fn test_pending_approve_happy_text() {
667 let mut env = TestEnv::fresh();
673 let db = env.db_path.clone();
674 seed_delete_pending(&db, "pa-approve-1", "ns-app");
675 let args = PendingArgs {
676 action: PendingAction::Approve {
677 id: "pa-approve-1".to_string(),
678 },
679 };
680 {
681 let mut out = env.output();
682 run_pending(&db, args, false, Some("test-agent"), &mut out).unwrap();
683 }
684 let s = env.stdout_str();
685 assert!(
686 s.contains("approved + executed: pa-approve-1"),
687 "expected approved+executed line, got: {s}"
688 );
689 }
690
691 #[test]
692 fn test_pending_approve_happy_json() {
693 let mut env = TestEnv::fresh();
694 let db = env.db_path.clone();
695 seed_delete_pending(&db, "pa-approve-json", "ns-app2");
696 let args = PendingArgs {
697 action: PendingAction::Approve {
698 id: "pa-approve-json".to_string(),
699 },
700 };
701 {
702 let mut out = env.output();
703 run_pending(&db, args, true, Some("test-agent"), &mut out).unwrap();
704 }
705 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
706 assert_eq!(v["approved"].as_bool().unwrap(), true);
707 assert_eq!(v["id"].as_str().unwrap(), "pa-approve-json");
708 assert_eq!(v["decided_by"].as_str().unwrap(), "test-agent");
709 }
710
711 #[test]
712 fn test_pending_reject_happy_text() {
713 let mut env = TestEnv::fresh();
715 let db = env.db_path.clone();
716 seed_pending_action(&db, "pa-reject-1", "ns-r", "store", "test-agent");
717 let args = PendingArgs {
718 action: PendingAction::Reject {
719 id: "pa-reject-1".to_string(),
720 },
721 };
722 {
723 let mut out = env.output();
724 run_pending(&db, args, false, Some("test-agent"), &mut out).unwrap();
725 }
726 assert!(env.stdout_str().contains("rejected: pa-reject-1"));
727 }
728
729 #[test]
730 fn test_pending_reject_happy_json() {
731 let mut env = TestEnv::fresh();
732 let db = env.db_path.clone();
733 seed_pending_action(&db, "pa-reject-j", "ns-r", "store", "test-agent");
734 let args = PendingArgs {
735 action: PendingAction::Reject {
736 id: "pa-reject-j".to_string(),
737 },
738 };
739 {
740 let mut out = env.output();
741 run_pending(&db, args, true, Some("test-agent"), &mut out).unwrap();
742 }
743 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
744 assert_eq!(v["rejected"].as_bool().unwrap(), true);
745 assert_eq!(v["id"].as_str().unwrap(), "pa-reject-j");
746 assert_eq!(v["decided_by"].as_str().unwrap(), "test-agent");
747 }
748
749 fn install_consensus_policy(db_path: &std::path::Path, namespace: &str, quorum: u32) {
753 let conn = db::open(db_path).expect("db::open");
754 let policy = serde_json::json!({
755 "write": "approve",
756 "promote": "any",
757 "delete": "owner",
758 "approver": {"consensus": quorum},
759 "inherit": true,
760 });
761 let now = chrono::Utc::now().to_rfc3339();
762 let mut metadata = crate::models::default_metadata();
763 if let Some(obj) = metadata.as_object_mut() {
764 obj.insert(
765 "agent_id".to_string(),
766 serde_json::Value::String("test-agent".to_string()),
767 );
768 obj.insert("governance".to_string(), policy);
769 }
770 let mem = crate::models::Memory {
771 id: uuid::Uuid::new_v4().to_string(),
772 tier: crate::models::Tier::Long,
773 namespace: namespace.to_string(),
774 title: format!("standard:{namespace}"),
775 content: "policy standard".to_string(),
776 tags: vec![],
777 priority: 9,
778 confidence: 1.0,
779 source: "test".to_string(),
780 access_count: 0,
781 created_at: now.clone(),
782 updated_at: now,
783 last_accessed_at: None,
784 expires_at: None,
785 metadata,
786 reflection_depth: 0,
787 memory_kind: crate::models::MemoryKind::Observation,
788 entity_id: None,
789 persona_version: None,
790 citations: Vec::new(),
791 source_uri: None,
792 source_span: None,
793 confidence_source: crate::models::ConfidenceSource::CallerProvided,
794 confidence_signals: None,
795 confidence_decayed_at: None,
796 version: 1,
797 };
798 let id = db::insert(&conn, &mem).expect("db::insert standard");
799 db::set_namespace_standard(&conn, namespace, &id, None).expect("set_namespace_standard");
800 }
801
802 #[test]
803 fn test_pending_approve_consensus_pending_branch() {
804 let mut env = TestEnv::fresh();
811 let db = env.db_path.clone();
812
813 for who in ["voter-a", "voter-b"] {
815 let reg = AgentsArgs {
816 action: Some(AgentsAction::Register {
817 agent_id: who.to_string(),
818 agent_type: "human".to_string(),
819 capabilities: String::new(),
820 }),
821 };
822 let mut out = env.output();
823 run_agents(&db, reg, false, &mut out).expect("register voter");
824 }
825 env.stdout.clear();
826
827 install_consensus_policy(&db, "ns-cons", 2);
830
831 seed_pending_action(&db, "pa-cons-1", "ns-cons", "store", "voter-a");
833
834 let args = PendingArgs {
836 action: PendingAction::Approve {
837 id: "pa-cons-1".to_string(),
838 },
839 };
840 {
841 let mut out = env.output();
842 run_pending(&db, args, false, Some("voter-a"), &mut out).expect("approve voter-a");
843 }
844 assert!(
846 env.stdout_str().contains("approval recorded: pa-cons-1"),
847 "expected `approval recorded` text, got: {}",
848 env.stdout_str()
849 );
850 }
851
852 #[test]
853 fn test_pending_approve_consensus_pending_json() {
854 let mut env = TestEnv::fresh();
856 let db = env.db_path.clone();
857 for who in ["voter-a", "voter-b"] {
858 let reg = AgentsArgs {
859 action: Some(AgentsAction::Register {
860 agent_id: who.to_string(),
861 agent_type: "human".to_string(),
862 capabilities: String::new(),
863 }),
864 };
865 let mut out = env.output();
866 run_agents(&db, reg, false, &mut out).expect("register voter");
867 }
868 env.stdout.clear();
869 install_consensus_policy(&db, "ns-cons-j", 2);
870 seed_pending_action(&db, "pa-cons-j", "ns-cons-j", "store", "voter-a");
871 let args = PendingArgs {
872 action: PendingAction::Approve {
873 id: "pa-cons-j".to_string(),
874 },
875 };
876 {
877 let mut out = env.output();
878 run_pending(&db, args, true, Some("voter-a"), &mut out).expect("approve voter-a");
879 }
880 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
881 assert_eq!(v["approved"].as_bool().unwrap(), false);
882 assert_eq!(v["status"].as_str().unwrap(), "pending");
883 assert_eq!(v["quorum"].as_u64().unwrap(), 2);
884 }
885
886 #[test]
887 fn test_pending_reject_invalid_id_validation_error() {
888 let mut env = TestEnv::fresh();
891 let db = env.db_path.clone();
892 let args = PendingArgs {
893 action: PendingAction::Reject { id: String::new() },
894 };
895 let mut out = env.output();
896 let res = run_pending(&db, args, false, Some("test-agent"), &mut out);
897 assert!(res.is_err());
898 }
899
900 #[test]
901 fn test_pending_approve_invalid_id_validation_error() {
902 let mut env = TestEnv::fresh();
903 let db = env.db_path.clone();
904 let args = PendingArgs {
905 action: PendingAction::Approve { id: String::new() },
906 };
907 let mut out = env.output();
908 let res = run_pending(&db, args, false, Some("test-agent"), &mut out);
909 assert!(res.is_err());
910 }
911
912 fn register_and_key(env: &mut TestEnv, db: &std::path::Path, agent_id: &str) -> String {
917 let reg = AgentsArgs {
918 action: Some(AgentsAction::Register {
919 agent_id: agent_id.to_string(),
920 agent_type: "ai:claude-opus-4.7".to_string(),
921 capabilities: String::new(),
922 }),
923 };
924 {
925 let mut out = env.output();
926 run_agents(db, reg, false, &mut out).expect("register");
927 }
928 env.stdout.clear();
929 env.stderr.clear();
930 crate::identity::keypair::generate(agent_id)
931 .expect("generate keypair")
932 .public_base64()
933 }
934
935 #[test]
936 fn test_agents_bind_key_happy_text() {
937 let mut env = TestEnv::fresh();
938 let db = env.db_path.clone();
939 let pk = register_and_key(&mut env, &db, "ai:curator");
940 let args = AgentsArgs {
941 action: Some(AgentsAction::BindKey {
942 agent_id: "ai:curator".to_string(),
943 pubkey: pk.clone(),
944 }),
945 };
946 {
947 let mut out = env.output();
948 run_agents(&db, args, false, &mut out).unwrap();
949 }
950 assert!(env.stdout_str().contains("bound pubkey for ai:curator"));
951 let conn = db::open(&db).unwrap();
953 assert_eq!(db::agent_pubkey(&conn, "ai:curator").unwrap(), Some(pk));
954 }
955
956 #[test]
957 fn test_agents_bind_key_happy_json() {
958 let mut env = TestEnv::fresh();
959 let db = env.db_path.clone();
960 let pk = register_and_key(&mut env, &db, "ai:curator");
961 let args = AgentsArgs {
962 action: Some(AgentsAction::BindKey {
963 agent_id: "ai:curator".to_string(),
964 pubkey: pk.clone(),
965 }),
966 };
967 {
968 let mut out = env.output();
969 run_agents(&db, args, true, &mut out).unwrap();
970 }
971 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
972 assert_eq!(v["bound"].as_bool().unwrap(), true);
973 assert_eq!(v["agent_id"].as_str().unwrap(), "ai:curator");
974 assert_eq!(v["agent_pubkey"].as_str().unwrap(), pk);
975 }
976
977 #[test]
978 fn test_agents_bind_key_rotates_in_place() {
979 let mut env = TestEnv::fresh();
980 let db = env.db_path.clone();
981 let k1 = register_and_key(&mut env, &db, "ai:curator");
982 let k2 = crate::identity::keypair::generate("ai:curator")
983 .unwrap()
984 .public_base64();
985 assert_ne!(k1, k2);
986 for k in [&k1, &k2] {
987 let args = AgentsArgs {
988 action: Some(AgentsAction::BindKey {
989 agent_id: "ai:curator".to_string(),
990 pubkey: k.clone(),
991 }),
992 };
993 let mut out = env.output();
994 run_agents(&db, args, false, &mut out).unwrap();
995 }
996 let conn = db::open(&db).unwrap();
997 assert_eq!(db::agent_pubkey(&conn, "ai:curator").unwrap(), Some(k2));
998 }
999
1000 #[test]
1001 fn test_agents_bind_key_unregistered_is_rejected() {
1002 let mut env = TestEnv::fresh();
1003 let db = env.db_path.clone();
1004 let pk = crate::identity::keypair::generate("ai:ghost")
1005 .unwrap()
1006 .public_base64();
1007 let args = AgentsArgs {
1008 action: Some(AgentsAction::BindKey {
1009 agent_id: "ai:ghost".to_string(),
1010 pubkey: pk,
1011 }),
1012 };
1013 let mut out = env.output();
1014 let res = run_agents(&db, args, false, &mut out);
1015 assert!(res.is_err());
1016 assert!(res.unwrap_err().to_string().contains("not registered"));
1017 }
1018
1019 #[test]
1020 fn test_agents_bind_key_malformed_pubkey_is_rejected() {
1021 let mut env = TestEnv::fresh();
1022 let db = env.db_path.clone();
1023 register_and_key(&mut env, &db, "ai:curator");
1024 let args = AgentsArgs {
1025 action: Some(AgentsAction::BindKey {
1026 agent_id: "ai:curator".to_string(),
1027 pubkey: "not-a-valid-key".to_string(),
1028 }),
1029 };
1030 let mut out = env.output();
1031 let res = run_agents(&db, args, false, &mut out);
1032 assert!(res.is_err());
1033 }
1034
1035 #[test]
1036 fn test_agents_revoke_key_happy_text() {
1037 let mut env = TestEnv::fresh();
1038 let db = env.db_path.clone();
1039 let pk = register_and_key(&mut env, &db, "ai:curator");
1040 {
1042 let conn = db::open(&db).unwrap();
1043 db::bind_agent_pubkey(&conn, "ai:curator", &pk).unwrap();
1044 }
1045 let args = AgentsArgs {
1046 action: Some(AgentsAction::RevokeKey {
1047 agent_id: "ai:curator".to_string(),
1048 }),
1049 };
1050 {
1051 let mut out = env.output();
1052 run_agents(&db, args, false, &mut out).unwrap();
1053 }
1054 assert!(env.stdout_str().contains("revoked pubkey for ai:curator"));
1055 let conn = db::open(&db).unwrap();
1056 assert_eq!(db::agent_pubkey(&conn, "ai:curator").unwrap(), None);
1057 }
1058
1059 #[test]
1060 fn test_agents_revoke_key_happy_json() {
1061 let mut env = TestEnv::fresh();
1062 let db = env.db_path.clone();
1063 let pk = register_and_key(&mut env, &db, "ai:curator");
1064 {
1065 let conn = db::open(&db).unwrap();
1066 db::bind_agent_pubkey(&conn, "ai:curator", &pk).unwrap();
1067 }
1068 let args = AgentsArgs {
1069 action: Some(AgentsAction::RevokeKey {
1070 agent_id: "ai:curator".to_string(),
1071 }),
1072 };
1073 {
1074 let mut out = env.output();
1075 run_agents(&db, args, true, &mut out).unwrap();
1076 }
1077 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
1078 assert_eq!(v["revoked"].as_bool().unwrap(), true);
1079 assert_eq!(v["agent_id"].as_str().unwrap(), "ai:curator");
1080 }
1081
1082 #[test]
1083 fn test_agents_revoke_key_idempotent_without_bound_key() {
1084 let mut env = TestEnv::fresh();
1085 let db = env.db_path.clone();
1086 register_and_key(&mut env, &db, "ai:curator");
1087 let args = AgentsArgs {
1089 action: Some(AgentsAction::RevokeKey {
1090 agent_id: "ai:curator".to_string(),
1091 }),
1092 };
1093 {
1094 let mut out = env.output();
1095 run_agents(&db, args, false, &mut out).unwrap();
1096 }
1097 assert!(env.stdout_str().contains("revoked pubkey for ai:curator"));
1098 }
1099
1100 #[test]
1101 fn test_agents_revoke_key_unregistered_is_rejected() {
1102 let mut env = TestEnv::fresh();
1103 let db = env.db_path.clone();
1104 let args = AgentsArgs {
1105 action: Some(AgentsAction::RevokeKey {
1106 agent_id: "ai:ghost".to_string(),
1107 }),
1108 };
1109 let mut out = env.output();
1110 let res = run_agents(&db, args, false, &mut out);
1111 assert!(res.is_err());
1112 assert!(res.unwrap_err().to_string().contains("not registered"));
1113 }
1114
1115 fn seed_memory_local(
1118 db_path: &std::path::Path,
1119 ns: &str,
1120 title: &str,
1121 content: &str,
1122 ) -> String {
1123 crate::cli::test_utils::seed_memory(db_path, ns, title, content)
1124 }
1125}