1use crate::cli::CliOutput;
8use crate::cli::governance::{GovernanceOutcome, enforce as enforce_governance};
9use crate::models::ConfidenceSource;
10use crate::{config, db, identity, models, validate};
11use anyhow::Result;
12use chrono::{Duration, Utc};
13use clap::Args;
14use models::Tier;
15use std::path::Path;
16
17#[derive(Args)]
20pub struct StoreArgs {
21 #[arg(long, short, default_value = "mid")]
27 pub tier: String,
28 #[arg(long, short)]
29 pub namespace: Option<String>,
30 #[arg(long, short = 'T', allow_hyphen_values = true)]
31 pub title: String,
32 #[arg(long, short, allow_hyphen_values = true)]
34 pub content: String,
35 #[arg(long, default_value = "")]
36 pub tags: String,
37 #[arg(long, short, default_value_t = 5)]
38 pub priority: i32,
39 #[arg(long)]
43 pub confidence: Option<f64>,
44 #[arg(long, short = 'S', default_value = "cli")]
46 pub source: String,
47 #[arg(long)]
49 pub expires_at: Option<String>,
50 #[arg(long)]
52 pub ttl_secs: Option<i64>,
53 #[arg(long)]
57 pub scope: Option<String>,
58 #[arg(long)]
63 pub kind: Option<String>,
64 #[arg(long)]
69 pub citations: Option<String>,
70 #[arg(long)]
74 pub source_uri: Option<String>,
75 #[arg(long)]
79 pub source_span: Option<String>,
80 #[arg(long)]
83 pub entity_id: Option<String>,
84 #[arg(long)]
92 pub sign: bool,
93}
94
95pub(crate) fn resolve_content<F>(spec: &str, stdin_reader: F) -> Result<String>
101where
102 F: FnOnce() -> Result<String>,
103{
104 if spec == "-" {
105 stdin_reader()
106 } else {
107 Ok(spec.to_string())
108 }
109}
110
111fn read_stdin_to_string() -> Result<String> {
113 use std::io::Read as _;
114 let mut buf = String::new();
115 std::io::stdin().read_to_string(&mut buf)?;
116 Ok(buf)
117}
118
119#[allow(clippy::too_many_lines)]
123pub fn run(
124 db_path: &Path,
125 args: StoreArgs,
126 json_out: bool,
127 app_config: &config::AppConfig,
128 cli_agent_id: Option<&str>,
129 out: &mut CliOutput<'_>,
130) -> Result<()> {
131 let conn = db::open(db_path)?;
132 let resolved_ttl = app_config.effective_ttl();
133 let _ = db::gc_if_needed(&conn, app_config.effective_archive_on_gc());
134 let tier = Tier::from_str(&args.tier)
135 .ok_or_else(|| anyhow::anyhow!("invalid tier: {} (use short, mid, long)", args.tier))?;
136 let namespace = crate::cli::helpers::resolve_namespace(args.namespace);
139 let confidence = args.confidence.unwrap_or(models::DEFAULT_CONFIDENCE);
141 let content = resolve_content(&args.content, read_stdin_to_string)?;
142 let tags: Vec<String> = args
143 .tags
144 .split(',')
145 .map(|s| s.trim().to_string())
146 .filter(|s| !s.is_empty())
147 .collect();
148
149 validate::validate_title(&args.title)?;
151 validate::validate_content(&content)?;
152 validate::validate_namespace(&namespace)?;
153 validate::validate_source(&args.source)?;
154 validate::validate_tags(&tags)?;
155 validate::validate_priority(args.priority)?;
156 validate::validate_confidence(confidence)?;
157 validate::validate_expires_at(args.expires_at.as_deref())?;
158 validate::validate_ttl_secs(args.ttl_secs)?;
159
160 let now = Utc::now();
161 let expires_at = args.expires_at.or_else(|| {
162 args.ttl_secs
163 .or(resolved_ttl.ttl_for_tier(&tier))
164 .map(|s| (now + Duration::seconds(s)).to_rfc3339())
165 });
166 let agent_id = identity::resolve_agent_id(cli_agent_id, None)?;
167 let mut metadata = models::default_metadata();
168 if let Some(obj) = metadata.as_object_mut() {
169 obj.insert(
170 "agent_id".to_string(),
171 serde_json::Value::String(agent_id.clone()),
172 );
173 }
174 if let Some(ref s) = args.scope {
175 validate::validate_scope(s)?;
176 if let Some(obj) = metadata.as_object_mut() {
177 obj.insert("scope".to_string(), serde_json::Value::String(s.clone()));
178 }
179 }
180
181 let memory_kind = match args.kind.as_deref() {
186 None => crate::models::MemoryKind::Observation,
187 Some(s) => crate::models::MemoryKind::from_str(s).ok_or_else(|| {
188 anyhow::anyhow!(
189 "invalid --kind '{s}' (expected one of: observation, reflection, persona, \
190 concept, entity, claim, relation, event, conversation, decision)"
191 )
192 })?,
193 };
194 let citations: Vec<crate::models::Citation> = match args.citations.as_deref() {
195 None => Vec::new(),
196 Some(s) => {
197 let parsed: Vec<crate::models::Citation> = serde_json::from_str(s)
198 .map_err(|e| anyhow::anyhow!("invalid --citations JSON: {e}"))?;
199 for c in &parsed {
200 validate::validate_citation(c)
201 .map_err(|e| anyhow::anyhow!("invalid --citations entry: {e}"))?;
202 }
203 parsed
204 }
205 };
206 let source_uri = match args.source_uri.as_deref() {
207 None => None,
208 Some(s) => {
209 validate::validate_source_uri(s)
210 .map_err(|e| anyhow::anyhow!("invalid --source-uri: {e}"))?;
211 Some(s.to_string())
212 }
213 };
214 let source_span: Option<crate::models::SourceSpan> = match args.source_span.as_deref() {
215 None => None,
216 Some(s) => {
217 let parsed: crate::models::SourceSpan = serde_json::from_str(s)
218 .map_err(|e| anyhow::anyhow!("invalid --source-span JSON: {e}"))?;
219 validate::validate_source_span(&parsed)
220 .map_err(|e| anyhow::anyhow!("invalid --source-span: {e}"))?;
221 Some(parsed)
222 }
223 };
224
225 let mut mem = models::Memory {
226 id: uuid::Uuid::new_v4().to_string(),
227 tier,
228 namespace,
229 title: args.title,
230 content,
231 tags,
232 priority: args.priority.clamp(1, 10),
233 confidence: confidence.clamp(0.0, 1.0),
234 source: args.source,
235 access_count: 0,
236 created_at: now.to_rfc3339(),
237 updated_at: now.to_rfc3339(),
238 last_accessed_at: None,
239 expires_at,
240 metadata,
241 reflection_depth: 0,
242 memory_kind,
243 entity_id: args.entity_id,
244 persona_version: None,
245 citations,
246 source_uri,
247 source_span,
248 confidence_source: if args.confidence.is_some() {
251 ConfidenceSource::CallerProvided
252 } else {
253 ConfidenceSource::Default
254 },
255 confidence_signals: None,
256 confidence_decayed_at: None,
257 version: 1,
258 };
259
260 let signature: Option<Vec<u8>> = if args.sign {
268 let dir = identity::keypair::default_key_dir()?;
269 let kp = identity::keypair::load(&agent_id, &dir).map_err(|e| {
270 anyhow::anyhow!("--sign requires a local keypair for agent '{agent_id}': {e:#}")
271 })?;
272 Some(identity::attest::sign_memory_write(&kp, &mem, &agent_id)?)
273 } else {
274 None
275 };
276 if args.sign || identity::attest::require_agent_attestation_enabled() {
277 identity::attest::stamp_attestation_sync(&conn, &mut mem, &agent_id, signature.as_deref())?;
278 }
279
280 {
284 use models::GovernedAction;
285 let payload = serde_json::to_value(&mem).unwrap_or_default();
286 match enforce_governance(
287 &conn,
288 GovernedAction::Store,
289 &mem.namespace,
290 &agent_id,
291 None,
292 None,
293 &payload,
294 json_out,
295 out,
296 )? {
297 GovernanceOutcome::Allow => {}
298 GovernanceOutcome::Deny => {
299 std::process::exit(1);
300 }
301 GovernanceOutcome::Pending => {
302 return Ok(());
303 }
304 }
305 }
306 let contradictions =
307 db::find_contradictions(&conn, &mem.title, &mem.namespace).unwrap_or_default();
308 let actual_id = db::insert(&conn, &mem)?;
309
310 crate::audit::emit(crate::audit::EventBuilder::new(
312 crate::audit::AuditAction::Store,
313 crate::audit::actor(
314 agent_id.clone(),
315 cli_agent_id.map_or(crate::audit::synthesis_sources::DEFAULT_FALLBACK, |_| {
316 crate::audit::synthesis_sources::EXPLICIT
317 }),
318 args.scope.clone(),
319 ),
320 crate::audit::target_memory(
321 actual_id.clone(),
322 mem.namespace.clone(),
323 Some(mem.title.clone()),
324 Some(mem.tier.to_string()),
325 args.scope.clone(),
326 ),
327 ));
328 let filtered: Vec<&String> = contradictions
329 .iter()
330 .filter(|c| c.id != mem.id && c.id != actual_id)
331 .map(|c| &c.id)
332 .collect();
333 if json_out {
334 let mut j = serde_json::to_value(&mem)?;
335 j["id"] = serde_json::json!(actual_id);
336 let filtered: Vec<&String> = contradictions
337 .iter()
338 .filter(|c| c.id != actual_id)
339 .map(|c| &c.id)
340 .collect();
341 if !filtered.is_empty() {
342 j["potential_contradictions"] = serde_json::json!(filtered);
343 }
344 writeln!(out.stdout, "{}", serde_json::to_string(&j)?)?;
345 } else {
346 writeln!(
347 out.stdout,
348 "stored: {} [{}] (ns={})",
349 actual_id, mem.tier, mem.namespace
350 )?;
351 if !filtered.is_empty() {
352 writeln!(
353 out.stderr,
354 "warning: {} similar memories found in same namespace (potential contradictions)",
355 filtered.len()
356 )?;
357 }
358 }
359 Ok(())
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365 use crate::cli::test_utils::TestEnv;
366
367 fn default_args() -> StoreArgs {
368 StoreArgs {
369 tier: Tier::Mid.as_str().to_string(),
370 namespace: Some("test-ns".to_string()),
371 title: "test title".to_string(),
372 content: "test content".to_string(),
373 tags: String::new(),
374 priority: 5,
375 confidence: None,
376 source: "cli".to_string(),
377 expires_at: None,
378 ttl_secs: None,
379 scope: None,
380 kind: None,
382 citations: None,
383 source_uri: None,
384 source_span: None,
385 entity_id: None,
386 sign: false,
387 }
388 }
389
390 #[test]
391 fn test_resolve_content_literal() {
392 let out = resolve_content("hello", || panic!("should not call stdin"));
393 assert_eq!(out.unwrap(), "hello");
394 }
395
396 #[test]
397 fn test_resolve_content_stdin_dash() {
398 let out = resolve_content("-", || Ok("piped content".to_string()));
399 assert_eq!(out.unwrap(), "piped content");
400 }
401
402 #[test]
403 fn test_store_happy_path_text_output() {
404 let _lock = locked_env();
405 let mut env = TestEnv::fresh();
406 let db = env.db_path.clone();
407 let cfg = config::AppConfig::default();
408 let args = default_args();
409 {
410 let mut out = env.output();
411 run(&db, args, false, &cfg, Some("test-agent"), &mut out).unwrap();
412 }
413 let stdout = env.stdout_str();
414 assert!(stdout.starts_with("stored: "), "got: {stdout}");
415 assert!(stdout.contains("[mid]"));
416 assert!(stdout.contains("ns=test-ns"));
417 }
418
419 #[test]
420 fn test_store_json_output() {
421 let _lock = locked_env();
422 let mut env = TestEnv::fresh();
423 let db = env.db_path.clone();
424 let cfg = config::AppConfig::default();
425 let args = default_args();
426 {
427 let mut out = env.output();
428 run(&db, args, true, &cfg, Some("test-agent"), &mut out).unwrap();
429 }
430 let stdout = env.stdout_str();
431 let v: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
432 assert!(v["id"].is_string());
433 assert_eq!(v["title"].as_str().unwrap(), "test title");
434 assert_eq!(v["tier"].as_str().unwrap(), Tier::Mid.as_str());
435 assert_eq!(v["namespace"].as_str().unwrap(), "test-ns");
436 }
437
438 #[test]
439 fn test_store_stdin_content() {
440 let payload = "from stdin reader";
443 let resolved = resolve_content("-", || Ok(payload.to_string())).unwrap();
444 assert_eq!(resolved, payload);
445 }
446
447 #[test]
448 fn test_store_explicit_expires_at_overrides_tier() {
449 let _lock = locked_env();
450 let mut env = TestEnv::fresh();
451 let db = env.db_path.clone();
452 let cfg = config::AppConfig::default();
453 let mut args = default_args();
454 let custom_expiry = "2099-01-01T00:00:00+00:00".to_string();
455 args.expires_at = Some(custom_expiry.clone());
456 {
457 let mut out = env.output();
458 run(&db, args, true, &cfg, Some("test-agent"), &mut out).unwrap();
459 }
460 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
461 let exp = v["expires_at"].as_str().unwrap();
462 assert!(exp.starts_with("2099-01-01"), "got: {exp}");
463 }
464
465 #[test]
466 fn test_store_ttl_secs_overrides_tier() {
467 let _lock = locked_env();
468 let mut env = TestEnv::fresh();
469 let db = env.db_path.clone();
470 let cfg = config::AppConfig::default();
471 let mut args = default_args();
472 args.ttl_secs = Some(60);
473 {
474 let mut out = env.output();
475 run(&db, args, true, &cfg, Some("test-agent"), &mut out).unwrap();
476 }
477 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
478 assert!(v["expires_at"].is_string());
480 }
481
482 #[test]
483 fn test_store_with_scope_in_metadata() {
484 let _lock = locked_env();
485 let mut env = TestEnv::fresh();
486 let db = env.db_path.clone();
487 let cfg = config::AppConfig::default();
488 let mut args = default_args();
489 args.scope = Some("team".to_string());
490 {
491 let mut out = env.output();
492 run(&db, args, true, &cfg, Some("test-agent"), &mut out).unwrap();
493 }
494 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
495 assert_eq!(v["metadata"]["scope"].as_str().unwrap(), "team");
496 }
497
498 #[test]
499 fn test_store_invalid_tier_validation_error() {
500 let _lock = locked_env();
501 let mut env = TestEnv::fresh();
502 let db = env.db_path.clone();
503 let cfg = config::AppConfig::default();
504 let mut args = default_args();
505 args.tier = "ginormous".to_string();
506 let mut out = env.output();
507 let res = run(&db, args, false, &cfg, Some("test-agent"), &mut out);
508 let err = res.unwrap_err();
509 assert!(err.to_string().contains("invalid tier"));
510 }
511
512 #[test]
513 fn test_store_invalid_priority_validation_error() {
514 let _lock = locked_env();
515 let mut env = TestEnv::fresh();
516 let db = env.db_path.clone();
517 let cfg = config::AppConfig::default();
518 let mut args = default_args();
519 args.priority = 99;
520 let mut out = env.output();
521 let res = run(&db, args, false, &cfg, Some("test-agent"), &mut out);
522 assert!(res.is_err());
524 }
525
526 #[test]
527 fn test_store_contradiction_warning_in_stderr() {
528 let _lock = locked_env();
529 let mut env = TestEnv::fresh();
530 let db = env.db_path.clone();
531 let cfg = config::AppConfig::default();
532 let _ = crate::cli::test_utils::seed_memory(
542 &db,
543 "test-ns",
544 "kubernetes deployment guide",
545 "first content",
546 );
547 let mut args = default_args();
548 args.title = "kubernetes deployment notes".to_string();
549 args.content = "second content".to_string();
550 {
551 let mut out = env.output();
552 run(&db, args, false, &cfg, Some("test-agent"), &mut out).unwrap();
553 }
554 assert!(env.stdout_str().contains("stored: "));
556 let stderr = env.stderr_str();
559 assert!(
560 stderr.contains("potential contradictions"),
561 "expected contradiction warning on stderr, got: {stderr}"
562 );
563 }
564
565 #[test]
566 fn test_store_governance_pending_writes_pending_status() {
567 let _lock = locked_env();
568 let mut env = TestEnv::fresh();
574 let db = env.db_path.clone();
575 let cfg = config::AppConfig::default();
576 let args = default_args();
577 let mut out = env.output();
578 let res = run(&db, args, true, &cfg, Some("test-agent"), &mut out);
579 drop(out);
580 assert!(res.is_ok());
581 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
583 assert!(v["id"].is_string());
584 }
585
586 #[test]
587 fn test_store_tag_parsing() {
588 let _lock = locked_env();
589 let mut env = TestEnv::fresh();
590 let db = env.db_path.clone();
591 let cfg = config::AppConfig::default();
592 let mut args = default_args();
593 args.tags = "a, b, , c".to_string();
594 {
595 let mut out = env.output();
596 run(&db, args, true, &cfg, Some("test-agent"), &mut out).unwrap();
597 }
598 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
599 let tags = v["tags"].as_array().unwrap();
600 let strs: Vec<&str> = tags.iter().map(|t| t.as_str().unwrap()).collect();
601 assert_eq!(strs, vec!["a", "b", "c"]);
602 }
603
604 #[test]
607 fn test_store_form4_form6_flags_valid_roundtrip() {
608 let _lock = locked_env();
609 let mut env = TestEnv::fresh();
612 let db = env.db_path.clone();
613 let cfg = config::AppConfig::default();
614 let mut args = default_args();
615 args.kind = Some("reflection".to_string());
616 args.citations = Some(
617 r#"[{"uri":"uri:https://example.com/a","accessed_at":"2026-05-31T00:00:00Z"}]"#
618 .to_string(),
619 );
620 args.source_uri = Some("uri:https://example.com/src".to_string());
621 args.source_span = Some(r#"{"start":0,"end":5}"#.to_string());
622 args.entity_id = Some("ent-123".to_string());
623 {
624 let mut out = env.output();
625 run(&db, args, true, &cfg, Some("test-agent"), &mut out).unwrap();
626 }
627 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
628 assert_eq!(v["memory_kind"].as_str().unwrap(), "reflection");
629 assert_eq!(
630 v["source_uri"].as_str().unwrap(),
631 "uri:https://example.com/src"
632 );
633 assert_eq!(v["entity_id"].as_str().unwrap(), "ent-123");
634 assert_eq!(v["citations"].as_array().unwrap().len(), 1);
635 assert_eq!(v["source_span"]["end"].as_u64().unwrap(), 5);
636 }
637
638 #[test]
639 fn test_store_invalid_kind_errors() {
640 let _lock = locked_env();
641 let mut env = TestEnv::fresh();
642 let db = env.db_path.clone();
643 let cfg = config::AppConfig::default();
644 let mut args = default_args();
645 args.kind = Some("ginormous".to_string());
646 let mut out = env.output();
647 let err = run(&db, args, false, &cfg, Some("test-agent"), &mut out).unwrap_err();
648 assert!(err.to_string().contains("invalid --kind"), "got: {err}");
649 }
650
651 #[test]
652 fn test_store_invalid_citations_json_errors() {
653 let _lock = locked_env();
654 let mut env = TestEnv::fresh();
655 let db = env.db_path.clone();
656 let cfg = config::AppConfig::default();
657 let mut args = default_args();
658 args.citations = Some("not-json".to_string());
659 let mut out = env.output();
660 let err = run(&db, args, false, &cfg, Some("test-agent"), &mut out).unwrap_err();
661 assert!(
662 err.to_string().contains("invalid --citations JSON"),
663 "got: {err}"
664 );
665 }
666
667 #[test]
668 fn test_store_invalid_citations_entry_errors() {
669 let _lock = locked_env();
670 let mut env = TestEnv::fresh();
673 let db = env.db_path.clone();
674 let cfg = config::AppConfig::default();
675 let mut args = default_args();
676 args.citations =
677 Some(r#"[{"uri":"example.com","accessed_at":"2026-05-31T00:00:00Z"}]"#.to_string());
678 let mut out = env.output();
679 let err = run(&db, args, false, &cfg, Some("test-agent"), &mut out).unwrap_err();
680 assert!(
681 err.to_string().contains("invalid --citations entry"),
682 "got: {err}"
683 );
684 }
685
686 #[test]
687 fn test_store_invalid_source_uri_errors() {
688 let _lock = locked_env();
689 let mut env = TestEnv::fresh();
690 let db = env.db_path.clone();
691 let cfg = config::AppConfig::default();
692 let mut args = default_args();
693 args.source_uri = Some("bareword-no-scheme".to_string());
694 let mut out = env.output();
695 let err = run(&db, args, false, &cfg, Some("test-agent"), &mut out).unwrap_err();
696 assert!(
697 err.to_string().contains("invalid --source-uri"),
698 "got: {err}"
699 );
700 }
701
702 #[test]
703 fn test_store_invalid_source_span_json_errors() {
704 let _lock = locked_env();
705 let mut env = TestEnv::fresh();
706 let db = env.db_path.clone();
707 let cfg = config::AppConfig::default();
708 let mut args = default_args();
709 args.source_span = Some("not-json".to_string());
710 let mut out = env.output();
711 let err = run(&db, args, false, &cfg, Some("test-agent"), &mut out).unwrap_err();
712 assert!(
713 err.to_string().contains("invalid --source-span JSON"),
714 "got: {err}"
715 );
716 }
717
718 #[test]
719 fn test_store_invalid_source_span_range_errors() {
720 let _lock = locked_env();
721 let mut env = TestEnv::fresh();
723 let db = env.db_path.clone();
724 let cfg = config::AppConfig::default();
725 let mut args = default_args();
726 args.source_span = Some(r#"{"start":5,"end":5}"#.to_string());
727 let mut out = env.output();
728 let err = run(&db, args, false, &cfg, Some("test-agent"), &mut out).unwrap_err();
729 assert!(
730 err.to_string().contains("invalid --source-span"),
731 "got: {err}"
732 );
733 }
734
735 fn env_lock() -> &'static std::sync::Mutex<()> {
751 crate::identity::keypair::key_dir_env_lock()
752 }
753
754 fn locked_env() -> std::sync::MutexGuard<'static, ()> {
760 env_lock()
761 .lock()
762 .unwrap_or_else(std::sync::PoisonError::into_inner)
763 }
764
765 struct EnvVarGuard {
767 key: &'static str,
768 prev: Option<std::ffi::OsString>,
769 }
770 impl EnvVarGuard {
771 fn set(key: &'static str, val: &std::ffi::OsStr) -> Self {
772 let prev = std::env::var_os(key);
773 unsafe { std::env::set_var(key, val) };
774 Self { key, prev }
775 }
776 fn clear(key: &'static str) -> Self {
777 let prev = std::env::var_os(key);
778 unsafe { std::env::remove_var(key) };
779 Self { key, prev }
780 }
781 }
782 impl Drop for EnvVarGuard {
783 fn drop(&mut self) {
784 match &self.prev {
785 Some(v) => unsafe { std::env::set_var(self.key, v) },
786 None => unsafe { std::env::remove_var(self.key) },
787 }
788 }
789 }
790
791 #[test]
792 fn test_store_sign_with_bound_key_stamps_agent_attested() {
793 let _lock = locked_env();
794 let key_dir = tempfile::tempdir().unwrap();
795 let _kd = EnvVarGuard::set("AI_MEMORY_KEY_DIR", key_dir.path().as_os_str());
796 let _req = EnvVarGuard::clear("AI_MEMORY_REQUIRE_AGENT_ATTESTATION");
797
798 let kp = crate::identity::keypair::generate("test-agent").unwrap();
800 crate::identity::keypair::save(&kp, key_dir.path()).unwrap();
801
802 let mut env = TestEnv::fresh();
803 let db = env.db_path.clone();
804 {
807 let conn = db::open(&db).unwrap();
808 db::register_agent(&conn, "test-agent", "ai:claude-opus-4.7", &[]).unwrap();
809 db::bind_agent_pubkey(&conn, "test-agent", &kp.public_base64()).unwrap();
810 }
811
812 let cfg = config::AppConfig::default();
813 let mut args = default_args();
814 args.sign = true;
815 {
816 let mut out = env.output();
817 run(&db, args, true, &cfg, Some("test-agent"), &mut out).unwrap();
818 }
819 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
820 assert_eq!(
821 v["metadata"]["attest_level"].as_str().unwrap(),
822 "agent_attested"
823 );
824 }
825
826 #[test]
827 fn test_store_sign_without_local_keypair_errors() {
828 let _lock = locked_env();
829 let key_dir = tempfile::tempdir().unwrap();
831 let _kd = EnvVarGuard::set("AI_MEMORY_KEY_DIR", key_dir.path().as_os_str());
832 let _req = EnvVarGuard::clear("AI_MEMORY_REQUIRE_AGENT_ATTESTATION");
833
834 let mut env = TestEnv::fresh();
835 let db = env.db_path.clone();
836 let cfg = config::AppConfig::default();
837 let mut args = default_args();
838 args.sign = true;
839 let mut out = env.output();
840 let err = run(&db, args, false, &cfg, Some("test-agent"), &mut out).unwrap_err();
841 assert!(
842 err.to_string().contains("--sign requires a local keypair"),
843 "got: {err}"
844 );
845 }
846
847 #[test]
866 fn env_var_guard_restores_previous_value_on_drop() {
867 let _lock = locked_env();
868 let prior = tempfile::tempdir().unwrap();
869 unsafe { std::env::set_var("AI_MEMORY_KEY_DIR", prior.path().as_os_str()) };
870 {
871 let other = tempfile::tempdir().unwrap();
872 let _g = EnvVarGuard::set("AI_MEMORY_KEY_DIR", other.path().as_os_str());
873 assert_eq!(
874 std::env::var_os("AI_MEMORY_KEY_DIR").as_deref(),
875 Some(other.path().as_os_str())
876 );
877 }
879 assert_eq!(
880 std::env::var_os("AI_MEMORY_KEY_DIR").as_deref(),
881 Some(prior.path().as_os_str())
882 );
883 unsafe { std::env::remove_var("AI_MEMORY_KEY_DIR") };
884 }
885}