Skip to main content

ai_memory/cli/
agents.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! `cmd_agents` and `cmd_pending` migrations. See `cli::store` for the
5//! design pattern.
6
7use 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 registered agents (default)
24    List,
25    /// Register or refresh an agent
26    Register {
27        /// Agent identifier
28        #[arg(long)]
29        agent_id: String,
30        /// Agent type. Curated values: human, system, ai:claude-opus-4.6,
31        /// ai:claude-opus-4.7, ai:codex-5.4, ai:grok-4.2. Any `ai:<name>`
32        /// form is also accepted (e.g. `ai:gpt-5`, `ai:gemini-2.5`) —
33        /// red-team #235.
34        #[arg(long)]
35        agent_type: String,
36        /// Comma-separated capability tags
37        #[arg(long, default_value = "")]
38        capabilities: String,
39    },
40    /// Bind (or rotate) the Ed25519 public key used to attest an
41    /// agent's signed writes (#626 Layer-3). The agent MUST already be
42    /// registered. Re-binding overwrites the previous key in place.
43    BindKey {
44        /// Agent identifier (must be registered)
45        #[arg(long)]
46        agent_id: String,
47        /// Base64-encoded Ed25519 public key (URL-safe-no-pad or
48        /// standard padding accepted)
49        #[arg(long)]
50        pubkey: String,
51    },
52    /// Revoke the Ed25519 public key bound to an agent (#626 Layer-3).
53    /// The agent reverts to the permissive *claimed* posture until a
54    /// fresh key is bound. Idempotent.
55    RevokeKey {
56        /// Agent identifier (must be registered)
57        #[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 pending actions (optionally filter by status).
71    List {
72        #[arg(long)]
73        status: Option<String>,
74        #[arg(long, default_value_t = 100)]
75        limit: usize,
76    },
77    /// Approve a pending action by id.
78    Approve { id: String },
79    /// Reject a pending action by id.
80    Reject { id: String },
81}
82
83/// `agents` handler.
84pub 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
196/// `pending` handler.
197pub 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                // #1620 — typed not-found (was a Rejected string).
277                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(), // empty -> validation error
403                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    // ---------- E1 coverage uplift: register-json + pending-with-items
460    // + approve happy + reject happy + consensus pending. The
461    // `process::exit` branches (Approve::Rejected, Reject not-found) stay
462    // uncovered intentionally — they call `std::process::exit(1)` which
463    // would terminate the test process.
464
465    #[test]
466    fn test_agents_register_json_output() {
467        // Covers the `if json_out` arm inside Register (lines 112-123)
468        // which is not exercised by `test_agents_register_happy_path`.
469        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        // Capabilities round-trip as a JSON array of length 3.
487        assert_eq!(v["capabilities"].as_array().unwrap().len(), 3);
488    }
489
490    #[test]
491    fn test_agents_register_empty_caps_human_text_dash() {
492        // Hits the `if caps.is_empty()` true branch in the text-output
493        // path (line 128 → "-").
494        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        // The "-" sentinel appears when capabilities is empty.
508        assert!(env.stdout_str().contains("capabilities=-"));
509    }
510
511    #[test]
512    fn test_agents_list_with_registered_agent_text_includes_caps() {
513        // Drives the for-loop body (lines 82-94) — including the
514        // `caps.is_empty() == false` branch where capabilities are
515        // printed `[a,b]`.
516        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        // Drives the JSON branch of list when there *are* agents
548        // (lines 73-78) with a non-empty agents array.
549        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    // ---- Pending list-with-items + decision paths -----------------
580
581    /// Seed one `pending_actions` row directly via SQL. The CLI's
582    /// `Approve` arm reads & writes through `db::*` helpers which
583    /// validate this shape.
584    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        // Hits the for-loop body (lines 161-171) + count footer (line 173).
606        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    /// Seed a `delete`-shaped pending action whose memory_id is a real,
646    /// existing memory. `execute_pending_action`'s delete arm reads
647    /// `pa.memory_id` (the dedicated column, not the payload) and calls
648    /// `db::delete`. With a valid target row, execution succeeds and the
649    /// CLI's Approved arm reaches the "approved + executed" branch.
650    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        // Default namespace policy (no governance row) → approver = Human →
668        // `approve_with_approver_type` writes `Approved` and the CLI's
669        // Approved arm calls `execute_pending_action`. With action_type=delete
670        // and a valid memory_id, the delete arm succeeds and we hit the
671        // "approved + executed" line.
672        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        // Happy `Reject` text path (lines 226-245).
714        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    /// Install a Consensus(2) governance policy on `namespace`. The
750    /// policy lives inside a "standard" memory's metadata; we seed the
751    /// memory then point `namespace_meta` at it.
752    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        // Drives the `ApproveOutcome::Pending { votes, quorum }` arm
805        // (lines 199-219). Path:
806        //   1. Register two agents so they qualify as consensus voters.
807        //   2. Set a namespace standard whose policy demands Consensus(2).
808        //   3. Seed a pending action under that namespace.
809        //   4. Have agent A approve — quorum not met → Pending response.
810        let mut env = TestEnv::fresh();
811        let db = env.db_path.clone();
812
813        // Step 1: register voters.
814        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        // Step 2: install a Consensus(2) policy via the standard
828        // memory + namespace_meta path.
829        install_consensus_policy(&db, "ns-cons", 2);
830
831        // Step 3: seed a pending action.
832        seed_pending_action(&db, "pa-cons-1", "ns-cons", "store", "voter-a");
833
834        // Step 4: voter-a approves.
835        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        // Text branch — "approval recorded".
845        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        // JSON variant of the same path (lines 200-212).
855        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        // validate_id rejects an obviously-invalid id (empty / contains
889        // disallowed chars). The CLI returns the error via `?`.
890        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    // ---- #626 Layer-3 (C5): bind-key / revoke-key CLI commands -----
913
914    /// Register `agent_id` then return a fresh valid base64 Ed25519
915    /// public key for it.
916    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        // The key is now retrievable via db::agent_pubkey.
952        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        // Bind then revoke.
1041        {
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        // No key bound — revoke still succeeds.
1088        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    // Local seed helper — duplicated from cli::test_utils so we can
1116    // bind a specific id without changing the shared signature.
1117    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}