From 49f348868b3e455a249e424dd38f31757b058549 Mon Sep 17 00:00:00 2001 From: Jonathan Hendler Date: Fri, 20 Mar 2026 00:13:49 -0700 Subject: [PATCH 1/5] Wave 1 (JACS core) -- 3 tasks complete: - Task 001 -- Added config_dir: Option field to Config struct (/Users/jonathan.hendler/personal/JACS/jacs/src/config/mod.rs). Field is #[serde(skip)], set automatically by Config::from_file(), used by load_by_config to resolve storage paths correctly. 6 new tests. - Task 002 -- Added Agent::from_config(config: Config, password: Option<&str>) constructor (/Users/jonathan.hendler/personal/JACS/jacs/src/agent/mod.rs). Named from_config (not load) to avoid collision with existing Agent::load(&str). Extracted shared calculate_storage_root_and_normalize helper to eliminate ~100 lines of duplication. 4 new tests. - Task 003 -- Deprecated load_config_12factor, load_config_12factor_optional, load_config_file_only with #[deprecated(since="0.9.8")] (/Users/jonathan.hendler/personal/JACS/jacs/src/config/mod.rs). Added #[allow(deprecated)] to all internal callers. Wave 2 (JACS callers) -- 4 tasks complete: - Task 004 -- Migrated jacs-cli (/Users/jonathan.hendler/personal/JACS/jacs-cli/src/main.rs). Created load_agent_from_config helper. Replaced all 7 load_agent() calls and config read subcommand. Zero deprecated function usage. - Task 005 -- Migrated binding-core (/Users/jonathan.hendler/personal/JACS/binding-core/src/lib.rs). load(), load_file_only(), load_with_info() now use Agent::from_config. Password passed directly, not via env var. - Task 006 -- Blocked (depends on Task 009 removing last caller from haisdk). - Task 007 -- Complete (transitive via Task 005, no code changes needed). --- binding-core/src/lib.rs | 61 ++++++---- jacs-cli/src/main.rs | 73 ++++++++--- jacs/src/agent/mod.rs | 233 ++++++++++++++++++++++++++++++++++-- jacs/src/audit/mod.rs | 1 + jacs/src/config/mod.rs | 59 ++++++++- jacs/src/lib.rs | 1 + jacs/src/simple/advanced.rs | 1 + jacs/tests/config_tests.rs | 190 ++++++++++++++++++++++++++++- 8 files changed, 570 insertions(+), 49 deletions(-) diff --git a/binding-core/src/lib.rs b/binding-core/src/lib.rs index b5410dbf..3d7a638b 100644 --- a/binding-core/src/lib.rs +++ b/binding-core/src/lib.rs @@ -373,14 +373,14 @@ impl AgentWrapper { } /// Load agent configuration from a file path. + /// + /// Uses `Config::from_file` + `apply_env_overrides` + `Agent::from_config` + /// to avoid deprecated `load_by_config` and env var side-channels. pub fn load(&self, config_path: String) -> BindingResult { - self.with_private_key_password(|| { - let mut agent = self.lock()?; - agent.load_by_config(config_path).map_err(|e| { - BindingCoreError::agent_load(format!("Failed to load agent: {}", e)) - })?; - Ok("Agent loaded".to_string()) - }) + let password = self.configured_private_key_password()?; + let new_agent = self.load_agent_from_config(&config_path, true, password.as_deref())?; + *self.lock()? = new_agent; + Ok("Agent loaded".to_string()) } /// Load agent configuration from file only, **without** applying env/jenv @@ -388,28 +388,43 @@ impl AgentWrapper { /// caller constructs a pristine config file and does not want ambient JACS_* /// environment variables to pollute it (Issue 008). pub fn load_file_only(&self, config_path: String) -> BindingResult { - let mut agent = self.lock()?; - agent - .load_by_config_file_only(config_path) - .map_err(|e| BindingCoreError::agent_load(format!("Failed to load agent: {}", e)))?; + let new_agent = self.load_agent_from_config(&config_path, false, None)?; + *self.lock()? = new_agent; Ok("Agent loaded (file-only)".to_string()) } /// Load agent configuration and return canonical loaded-agent metadata. pub fn load_with_info(&self, config_path: String) -> BindingResult { let resolved_config_path = resolve_existing_config_path(&config_path)?; - self.with_private_key_password(|| { - let mut agent = self.lock()?; - agent - .load_by_config(resolved_config_path.clone()) - .map_err(|e| { - BindingCoreError::agent_load(format!("Failed to load agent: {}", e)) - })?; - let info = jacs::simple::build_loaded_agent_info(&agent, &resolved_config_path) - .map_err(|e| { - BindingCoreError::agent_load(format!("Failed to load agent: {}", e)) - })?; - serialize_agent_info(&info) + let password = self.configured_private_key_password()?; + let new_agent = + self.load_agent_from_config(&resolved_config_path, true, password.as_deref())?; + let info = jacs::simple::build_loaded_agent_info(&new_agent, &resolved_config_path) + .map_err(|e| { + BindingCoreError::agent_load(format!("Failed to load agent: {}", e)) + })?; + *self.lock()? = new_agent; + serialize_agent_info(&info) + } + + /// Internal helper: load an agent from config using the new pattern. + /// + /// * `apply_env` - Whether to call `config.apply_env_overrides()` (false for file-only) + /// * `password` - Optional password to pass directly to Agent::from_config + fn load_agent_from_config( + &self, + config_path: &str, + apply_env: bool, + password: Option<&str>, + ) -> BindingResult { + let mut config = Config::from_file(config_path).map_err(|e| { + BindingCoreError::agent_load(format!("Failed to load config: {}", e)) + })?; + if apply_env { + config.apply_env_overrides(); + } + Agent::from_config(config, password).map_err(|e| { + BindingCoreError::agent_load(format!("Failed to load agent: {}", e)) }) } diff --git a/jacs-cli/src/main.rs b/jacs-cli/src/main.rs index 75c5ab2a..5b56a5a6 100644 --- a/jacs-cli/src/main.rs +++ b/jacs-cli/src/main.rs @@ -11,11 +11,9 @@ use jacs::cli_utils::document::{ check_agreement, create_agreement, create_documents, extract_documents, sign_documents, update_documents, verify_documents, }; -use jacs::config::load_config_12factor_optional; // use jacs::create_task; // unused use jacs::dns::bootstrap as dns_bootstrap; use jacs::shutdown::{ShutdownGuard, install_signal_handler}; -use jacs::{load_agent, load_agent_with_dns_policy}; use rpassword::read_password; use std::env; @@ -192,14 +190,52 @@ fn load_agent_with_cli_dns_policy( ) -> Result> { let (dns_validate, dns_required, dns_strict) = resolve_dns_policy_overrides(ignore_dns, require_strict, require_dns, non_strict); - Ok(load_agent_with_dns_policy( - agent_file, + Ok(load_agent_from_config( + agent_file.as_deref(), dns_validate, dns_required, dns_strict, )?) } +/// Load an agent using the new Config + Agent::from_config pattern. +/// +/// Replaces the deprecated `load_agent` / `load_agent_with_dns_policy` calls. +/// Password is resolved from env var (set by `ensure_cli_private_key_password`). +fn load_agent_from_config( + agent_file: Option<&str>, + dns_validate: Option, + dns_required: Option, + dns_strict: Option, +) -> Result { + let config_path = agent_file + .map(String::from) + .unwrap_or_else(|| { + std::env::var("JACS_CONFIG") + .ok() + .filter(|v| !v.trim().is_empty()) + .unwrap_or_else(|| "./jacs.config.json".to_string()) + }); + + let mut config = jacs::config::Config::from_file(&config_path)?; + config.apply_env_overrides(); + + let mut agent = Agent::from_config(config, None)?; + + // Apply DNS policy overrides from CLI flags + if let Some(validate) = dns_validate { + agent.set_dns_validate(validate); + } + if let Some(required) = dns_required { + agent.set_dns_required(required); + } + if let Some(strict) = dns_strict { + agent.set_dns_strict(strict); + } + + Ok(agent) +} + fn wrap_quickstart_error_with_password_help( context: &str, err: impl std::fmt::Display, @@ -1115,8 +1151,17 @@ pub fn main() -> Result<(), Box> { handle_config_create()?; } Some(("read", _read_matches)) => { - let config = load_config_12factor_optional(Some("./jacs.config.json"))?; - println!("{}", config); + let config_path = "./jacs.config.json"; + match jacs::config::Config::from_file(config_path) { + Ok(mut config) => { + config.apply_env_overrides(); + println!("{}", config); + } + Err(e) => { + eprintln!("Could not load config from '{}': {}", config_path, e); + process::exit(1); + } + } } _ => println!("please enter subcommand see jacs config --help"), }, @@ -1340,7 +1385,7 @@ pub fn main() -> Result<(), Box> { // Some(("task", task_matches)) => match task_matches.subcommand() { // Some(("create", create_matches)) => { // let agentfile = create_matches.get_one::("agent-file"); - // let mut agent: Agent = load_agent(agentfile.cloned()).expect("REASON"); + // let mut agent: Agent = load_agent_from_config(agentfile.map(|s| s.as_str()), None, None, None).expect("REASON"); // let name = create_matches.get_one::("name").expect("REASON"); // let description = create_matches // .get_one::("description") @@ -1366,7 +1411,7 @@ pub fn main() -> Result<(), Box> { .map(|s| s.as_str()); let embed: Option = create_matches.get_one::("embed").copied(); - let mut agent: Agent = load_agent(agentfile.cloned()).expect("REASON"); + let mut agent: Agent = load_agent_from_config(agentfile.map(|s| s.as_str()), None, None, None).expect("REASON"); let _attachment_links = agent.parse_attachement_arg(attachments); let _ = create_documents( @@ -1395,7 +1440,7 @@ pub fn main() -> Result<(), Box> { .map(|s| s.as_str()); let embed: Option = create_matches.get_one::("embed").copied(); - let mut agent: Agent = load_agent(agentfile.cloned()).expect("REASON"); + let mut agent: Agent = load_agent_from_config(agentfile.map(|s| s.as_str()), None, None, None).expect("REASON"); let attachment_links = agent.parse_attachement_arg(attachments); update_documents( @@ -1414,7 +1459,7 @@ pub fn main() -> Result<(), Box> { let directory = create_matches.get_one::("directory"); let _verbose = *create_matches.get_one::("verbose").unwrap_or(&false); let agentfile = create_matches.get_one::("agent-file"); - let mut agent: Agent = load_agent(agentfile.cloned()).expect("REASON"); + let mut agent: Agent = load_agent_from_config(agentfile.map(|s| s.as_str()), None, None, None).expect("REASON"); let schema = create_matches.get_one::("schema"); let _no_save = *create_matches.get_one::("no-save").unwrap_or(&false); @@ -1425,7 +1470,7 @@ pub fn main() -> Result<(), Box> { let filename = create_matches.get_one::("filename"); let directory = create_matches.get_one::("directory"); let agentfile = create_matches.get_one::("agent-file"); - let mut agent: Agent = load_agent(agentfile.cloned()).expect("REASON"); + let mut agent: Agent = load_agent_from_config(agentfile.map(|s| s.as_str()), None, None, None).expect("REASON"); let schema = create_matches.get_one::("schema"); // Use updated set_file_list with storage @@ -1447,7 +1492,7 @@ pub fn main() -> Result<(), Box> { .map(|s| s.to_string()) .collect(); - let mut agent: Agent = load_agent(agentfile.cloned()).expect("REASON"); + let mut agent: Agent = load_agent_from_config(agentfile.map(|s| s.as_str()), None, None, None).expect("REASON"); // Use updated set_file_list with storage let _ = create_agreement(&mut agent, agentids, filename, schema, no_save, directory); @@ -1458,7 +1503,7 @@ pub fn main() -> Result<(), Box> { let directory = verify_matches.get_one::("directory"); let _verbose = *verify_matches.get_one::("verbose").unwrap_or(&false); let agentfile = verify_matches.get_one::("agent-file"); - let mut agent: Agent = load_agent(agentfile.cloned()).expect("REASON"); + let mut agent: Agent = load_agent_from_config(agentfile.map(|s| s.as_str()), None, None, None).expect("REASON"); let schema = verify_matches.get_one::("schema"); // Use updated set_file_list with storage verify_documents(&mut agent, schema, filename, directory)?; @@ -1469,7 +1514,7 @@ pub fn main() -> Result<(), Box> { let directory = extract_matches.get_one::("directory"); let _verbose = *extract_matches.get_one::("verbose").unwrap_or(&false); let agentfile = extract_matches.get_one::("agent-file"); - let mut agent: Agent = load_agent(agentfile.cloned()).expect("REASON"); + let mut agent: Agent = load_agent_from_config(agentfile.map(|s| s.as_str()), None, None, None).expect("REASON"); let schema = extract_matches.get_one::("schema"); // Use updated set_file_list with storage let _files: Vec = default_set_file_list(filename, directory, None) diff --git a/jacs/src/agent/mod.rs b/jacs/src/agent/mod.rs index 182e725d..ac7eee85 100644 --- a/jacs/src/agent/mod.rs +++ b/jacs/src/agent/mod.rs @@ -327,6 +327,214 @@ impl Agent { }) } + /// Create and load an agent from a pre-built Config and optional password. + /// + /// This is the canonical one-call agent loading API. Callers compose their + /// own config (via `Config::from_file()` + optional `apply_env_overrides()`) + /// and pass the password directly -- no env var side-channels needed. + /// + /// # Arguments + /// * `config` - A pre-built Config (not re-read from file) + /// * `password` - Optional private key password. If None, falls back to + /// `JACS_PRIVATE_KEY_PASSWORD` env var inside keystore operations. + /// + /// # Example + /// ```rust,ignore + /// let mut config = Config::from_file("jacs.config.json")?; + /// config.apply_env_overrides(); + /// let agent = Agent::from_config(config, Some("my-password"))?; + /// ``` + pub fn from_config(mut config: Config, password: Option<&str>) -> Result { + let schema = Schema::new("v1", "v1", "v1")?; + let document_schemas_map = Arc::new(Mutex::new(HashMap::new())); + let key_paths = Some(Self::key_paths_from_config(&config)); + + // Calculate storage root and normalize config directories + let (storage_root, normalized_config) = + Self::calculate_storage_root_and_normalize(config, "Agent::load")?; + config = normalized_config; + + let storage_type: String = config + .jacs_default_storage() + .as_deref() + .unwrap_or("fs") + .to_string(); + let file_storage_type = if matches!(storage_type.as_str(), "rusqlite" | "sqlite") { + "fs".to_string() + } else { + storage_type.clone() + }; + + let storage = MultiStorage::_new(file_storage_type, storage_root).map_err(|e| { + format!( + "Agent::from_config failed: Could not initialize storage type '{}': {}", + storage_type, e + ) + })?; + + let lookup_id: String = config + .jacs_agent_id_and_version() + .as_deref() + .unwrap_or("") + .to_string(); + + let mut agent = Self { + schema, + value: None, + config: Some(config), + storage, + document_schemas: document_schemas_map, + id: None, + version: None, + key_algorithm: None, + public_key: None, + private_key: None, + key_store: None, + ephemeral: false, + dns_strict: false, + dns_validate_enabled: None, + dns_required: None, + key_paths, + password: password.map(String::from), + #[cfg(feature = "attestation")] + adapters: crate::attestation::adapters::default_adapters(), + }; + + // Refresh key_paths from the normalized config + agent.refresh_key_paths_from_config(); + + if !lookup_id.is_empty() { + let agent_string = agent.fs_agent_load(&lookup_id).map_err(|e| { + format!( + "Agent::from_config failed: Could not load agent '{}': {}", + lookup_id, e + ) + })?; + agent.load(&agent_string).map_err(|e| { + let err_msg = format!( + "Agent::from_config failed: Agent '{}' validation or key loading failed: {}", + lookup_id, e + ); + JacsError::Internal { message: err_msg } + })?; + } + + Ok(agent) + } + + /// Calculate storage root from config and normalize directory paths. + /// + /// Returns `(storage_root, normalized_config)`. The config is modified + /// in place to normalize relative/absolute directory paths. + fn calculate_storage_root_and_normalize( + config: Config, + caller: &str, + ) -> Result<(std::path::PathBuf, Config), JacsError> { + let storage_type: String = config + .jacs_default_storage() + .as_deref() + .unwrap_or("") + .to_string(); + let uses_filesystem_paths = matches!(storage_type.as_str(), "fs" | "rusqlite" | "sqlite"); + if !uses_filesystem_paths { + return Ok((std::env::current_dir()?, config)); + } + + let config_dir = config + .config_dir() + .unwrap_or_else(|| std::path::Path::new(".")); + let config_dir_absolute = if config_dir.is_absolute() { + config_dir.to_path_buf() + } else { + std::env::current_dir()?.join(config_dir) + }; + + let normalize_path = |p: &std::path::Path| -> std::path::PathBuf { + let mut normalized = std::path::PathBuf::new(); + for component in p.components() { + match component { + std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + normalized.pop(); + } + other => normalized.push(other.as_os_str()), + } + } + normalized + }; + + let mut config_value = to_value(&config).map_err(|e| { + format!( + "{} failed: Could not serialize configuration: {}", + caller, e + ) + })?; + let mut has_external_absolute = false; + for field in ["jacs_data_directory", "jacs_key_directory"] { + if let Some(dir) = config_value.get(field).and_then(|v| v.as_str()) { + let dir_path = std::path::Path::new(dir); + if dir_path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return Err(format!( + "{} failed: Config field '{}' contains unsafe parent-directory segment ('..'): '{}'", + caller, field, dir + ) + .into()); + } + if dir_path.is_absolute() { + let normalized_abs = normalize_path(dir_path); + if let Ok(relative_tail) = normalized_abs.strip_prefix(&config_dir_absolute) { + let relative = relative_tail + .to_string_lossy() + .trim_start_matches('/') + .to_string(); + if relative.is_empty() { + has_external_absolute = true; + config_value[field] = + json!(normalized_abs.to_string_lossy().to_string()); + } else { + config_value[field] = json!(relative); + } + } else { + has_external_absolute = true; + config_value[field] = json!(normalized_abs.to_string_lossy().to_string()); + } + } else { + let normalized_rel = normalize_path(dir_path); + config_value[field] = json!(normalized_rel.to_string_lossy().to_string()); + } + } + } + + let storage_root = if has_external_absolute { + for field in ["jacs_data_directory", "jacs_key_directory"] { + if let Some(dir) = config_value.get(field).and_then(|v| v.as_str()) { + let dir_path = std::path::Path::new(dir); + if !dir_path.is_absolute() { + let abs = normalize_path(&config_dir_absolute.join(dir_path)); + config_value[field] = json!(abs.to_string_lossy().to_string()); + } + } + } + std::path::PathBuf::from("/") + } else { + config_dir_absolute + }; + + let mut normalized_config: Config = serde_json::from_value(config_value).map_err(|e| { + format!( + "{} failed: Could not normalize filesystem directories in config: {}", + caller, e + ) + })?; + // Preserve config_dir since serde(skip) drops it during round-trip + normalized_config.set_config_dir(config.config_dir().map(std::path::PathBuf::from)); + + Ok((storage_root, normalized_config)) + } + /// Create an ephemeral agent with in-memory keys and storage. /// No config file, no directories, no environment variables needed. pub fn ephemeral(algorithm: &str) -> Result { @@ -535,10 +743,15 @@ impl Agent { .to_string(); let uses_filesystem_paths = matches!(storage_type.as_str(), "fs" | "rusqlite" | "sqlite"); let storage_root = if uses_filesystem_paths { - let config_dir = std::path::Path::new(&path) - .parent() - .filter(|p| !p.as_os_str().is_empty()) - .unwrap_or_else(|| std::path::Path::new(".")); + // Prefer config.config_dir (set by Config::from_file) over re-deriving + // from the path argument. This fixes Bug 2 where callers absolutize paths + // and push them through env vars, causing storage_root = "/". + let config_dir = config.config_dir().unwrap_or_else(|| { + std::path::Path::new(&path) + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or_else(|| std::path::Path::new(".")) + }); let config_dir_absolute = if config_dir.is_absolute() { config_dir.to_path_buf() } else { @@ -698,10 +911,14 @@ impl Agent { .to_string(); let uses_filesystem_paths = matches!(storage_type.as_str(), "fs" | "rusqlite" | "sqlite"); let storage_root = if uses_filesystem_paths { - let config_dir = std::path::Path::new(&path) - .parent() - .filter(|p| !p.as_os_str().is_empty()) - .unwrap_or_else(|| std::path::Path::new(".")); + // Prefer config.config_dir (set by Config::from_file) over re-deriving + // from the path argument. + let config_dir = config.config_dir().unwrap_or_else(|| { + std::path::Path::new(&path) + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or_else(|| std::path::Path::new(".")) + }); let config_dir_absolute = if config_dir.is_absolute() { config_dir.to_path_buf() } else { diff --git a/jacs/src/audit/mod.rs b/jacs/src/audit/mod.rs index 5e8e3f5e..642f72f1 100644 --- a/jacs/src/audit/mod.rs +++ b/jacs/src/audit/mod.rs @@ -180,6 +180,7 @@ pub fn audit(options: AuditOptions) -> Result { }; // Load config: on failure push risk and continue with defaults + #[allow(deprecated)] let config = match crate::config::load_config_12factor(options.config_path.as_deref()) { Ok(c) => c, Err(e) => { diff --git a/jacs/src/config/mod.rs b/jacs/src/config/mod.rs index fb291372..5bf5a247 100644 --- a/jacs/src/config/mod.rs +++ b/jacs/src/config/mod.rs @@ -241,6 +241,11 @@ pub struct Config { #[getset(get = "pub")] #[serde(default, skip_serializing_if = "Option::is_none")] jacs_database_connect_timeout_secs: Option, + /// Directory containing the config file. Set automatically by `Config::from_file()`. + /// Used by `load_by_config` to calculate `storage_root` without re-deriving from path. + /// Not serialized — this is runtime-only metadata. + #[serde(skip)] + config_dir: Option, } fn default_schema() -> String { @@ -336,6 +341,7 @@ impl Default for Config { jacs_database_max_connections: None, jacs_database_min_connections: None, jacs_database_connect_timeout_secs: None, + config_dir: None, } } } @@ -495,6 +501,7 @@ impl ConfigBuilder { jacs_database_max_connections: None, jacs_database_min_connections: None, jacs_database_connect_timeout_secs: None, + config_dir: None, } } } @@ -559,6 +566,7 @@ impl Config { jacs_database_max_connections: None, jacs_database_min_connections: None, jacs_database_connect_timeout_secs: None, + config_dir: None, } } @@ -572,6 +580,25 @@ impl Config { .map_err(|e| JacsError::ConfigError(e.to_string())) } + /// Returns the directory containing the config file, if set. + /// + /// This is set automatically by `Config::from_file()` to the parent directory + /// of the config file path. It is used by `load_by_config` to correctly + /// calculate `storage_root` without requiring the caller to pass the original + /// path or set environment variables as a side-channel. + pub fn config_dir(&self) -> Option<&std::path::Path> { + self.config_dir.as_deref() + } + + /// Sets the config directory explicitly. + /// + /// Normally set automatically by `Config::from_file()`. Use this when + /// constructing a Config programmatically and you need `load_by_config` + /// to resolve storage paths relative to a specific directory. + pub fn set_config_dir(&mut self, dir: Option) { + self.config_dir = dir; + } + fn replace_if_some(target: &mut Option, incoming: Option) { if incoming.is_some() { *target = incoming; @@ -639,6 +666,7 @@ impl Config { jacs_database_max_connections, jacs_database_min_connections, jacs_database_connect_timeout_secs, + config_dir, } = other; Self::replace_if_some(&mut self.jacs_use_security, jacs_use_security); @@ -677,6 +705,8 @@ impl Config { &mut self.jacs_database_connect_timeout_secs, jacs_database_connect_timeout_secs, ); + // config_dir from the incoming config takes precedence if set + Self::replace_if_some(&mut self.config_dir, config_dir); } /// Apply environment variable overrides to this config. @@ -771,11 +801,14 @@ impl Config { jacs_database_max_connections: None, jacs_database_min_connections: None, jacs_database_connect_timeout_secs: None, + config_dir: None, } } /// Load config from a JSON file without applying environment overrides. - /// Use `load_config_12factor` for the recommended 12-Factor compliant loading. + /// + /// This is the recommended way to load a config file. For 12-Factor compliance, + /// call `config.apply_env_overrides()` after loading, then `Agent::from_config(config, password)`. pub fn from_file(path: &str) -> Result { let json_str = fs::read_to_string(path).map_err(|e| { let help = match e.kind() { @@ -801,7 +834,7 @@ impl Config { })?; let validated_value: Value = validate_config(&json_str) .map_err(|e| JacsError::ConfigError(format!("Invalid config at '{}': {}", path, e)))?; - let config: Config = serde_json::from_value(validated_value.clone()).map_err(|e| { + let mut config: Config = serde_json::from_value(validated_value.clone()).map_err(|e| { // This can happen if the JSON structure doesn't match our Config struct JacsError::ConfigError(format!( "Config structure error at '{}': {}. The JSON may have valid syntax but incorrect field types.", @@ -819,6 +852,14 @@ impl Config { ); } + // Set config_dir to the parent directory of the config file path. + // This allows load_by_config to resolve storage paths correctly + // without requiring callers to pass the path or use env var side-channels. + config.config_dir = std::path::Path::new(path) + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .map(std::path::PathBuf::from); + Ok(config) } } @@ -877,6 +918,10 @@ impl fmt::Display for Config { /// // Load with just defaults and env overrides /// let config = load_config_12factor(None)?; /// ``` +#[deprecated( + since = "0.9.8", + note = "Use Config::from_file(path) + config.apply_env_overrides() + Agent::from_config(config, password) instead" +)] pub fn load_config_12factor(config_path: Option<&str>) -> Result { // Step 1: Start with hardcoded defaults let mut config = Config::with_defaults(); @@ -912,6 +957,10 @@ pub fn load_config_12factor(config_path: Option<&str>) -> Result Result { let mut config = Config::with_defaults(); let file_config = Config::from_file(config_path)?; @@ -928,6 +977,10 @@ pub fn load_config_file_only(config_path: &str) -> Result { /// /// # Arguments /// * `config_path` - Optional path to a JSON config file (won't fail if missing) +#[deprecated( + since = "0.9.8", + note = "Use Config::from_file(path) + config.apply_env_overrides() + Agent::from_config(config, password) instead" +)] pub fn load_config_12factor_optional(config_path: Option<&str>) -> Result { // Step 1: Start with hardcoded defaults let mut config = Config::with_defaults(); @@ -1612,6 +1665,7 @@ mod tests { jacs_database_max_connections: None, jacs_database_min_connections: None, jacs_database_connect_timeout_secs: None, + config_dir: None, }; base.merge(override_config); @@ -1692,6 +1746,7 @@ mod tests { jacs_database_max_connections: None, jacs_database_min_connections: None, jacs_database_connect_timeout_secs: None, + config_dir: None, }; config.merge(file_config); diff --git a/jacs/src/lib.rs b/jacs/src/lib.rs index a2a2b090..1ad1d845 100644 --- a/jacs/src/lib.rs +++ b/jacs/src/lib.rs @@ -215,6 +215,7 @@ fn prepare_agent_for_agent_path(agent: &mut Agent, filepath: &str) { }; let config_path_str = config_path.to_string_lossy().to_string(); + #[allow(deprecated)] match crate::config::load_config_12factor_optional(Some(&config_path_str)) { Ok(config) => { debug!( diff --git a/jacs/src/simple/advanced.rs b/jacs/src/simple/advanced.rs index 5166c678..0f9d1aee 100644 --- a/jacs/src/simple/advanced.rs +++ b/jacs/src/simple/advanced.rs @@ -359,6 +359,7 @@ pub fn migrate_agent(config_path: Option<&str>) -> Result Date: Fri, 20 Mar 2026 00:17:22 -0700 Subject: [PATCH 2/5] same as before --- binding-core/src/lib.rs | 14 +++++--------- jacs-cli/src/main.rs | 42 ++++++++++++++++++++++++++--------------- 2 files changed, 32 insertions(+), 24 deletions(-) diff --git a/binding-core/src/lib.rs b/binding-core/src/lib.rs index 3d7a638b..b7df0ad9 100644 --- a/binding-core/src/lib.rs +++ b/binding-core/src/lib.rs @@ -400,9 +400,7 @@ impl AgentWrapper { let new_agent = self.load_agent_from_config(&resolved_config_path, true, password.as_deref())?; let info = jacs::simple::build_loaded_agent_info(&new_agent, &resolved_config_path) - .map_err(|e| { - BindingCoreError::agent_load(format!("Failed to load agent: {}", e)) - })?; + .map_err(|e| BindingCoreError::agent_load(format!("Failed to load agent: {}", e)))?; *self.lock()? = new_agent; serialize_agent_info(&info) } @@ -417,15 +415,13 @@ impl AgentWrapper { apply_env: bool, password: Option<&str>, ) -> BindingResult { - let mut config = Config::from_file(config_path).map_err(|e| { - BindingCoreError::agent_load(format!("Failed to load config: {}", e)) - })?; + let mut config = Config::from_file(config_path) + .map_err(|e| BindingCoreError::agent_load(format!("Failed to load config: {}", e)))?; if apply_env { config.apply_env_overrides(); } - Agent::from_config(config, password).map_err(|e| { - BindingCoreError::agent_load(format!("Failed to load agent: {}", e)) - }) + Agent::from_config(config, password) + .map_err(|e| BindingCoreError::agent_load(format!("Failed to load agent: {}", e))) } /// Re-root the internal file storage at `root`. diff --git a/jacs-cli/src/main.rs b/jacs-cli/src/main.rs index 5b56a5a6..c6e57d38 100644 --- a/jacs-cli/src/main.rs +++ b/jacs-cli/src/main.rs @@ -208,14 +208,12 @@ fn load_agent_from_config( dns_required: Option, dns_strict: Option, ) -> Result { - let config_path = agent_file - .map(String::from) - .unwrap_or_else(|| { - std::env::var("JACS_CONFIG") - .ok() - .filter(|v| !v.trim().is_empty()) - .unwrap_or_else(|| "./jacs.config.json".to_string()) - }); + let config_path = agent_file.map(String::from).unwrap_or_else(|| { + std::env::var("JACS_CONFIG") + .ok() + .filter(|v| !v.trim().is_empty()) + .unwrap_or_else(|| "./jacs.config.json".to_string()) + }); let mut config = jacs::config::Config::from_file(&config_path)?; config.apply_env_overrides(); @@ -1411,7 +1409,9 @@ pub fn main() -> Result<(), Box> { .map(|s| s.as_str()); let embed: Option = create_matches.get_one::("embed").copied(); - let mut agent: Agent = load_agent_from_config(agentfile.map(|s| s.as_str()), None, None, None).expect("REASON"); + let mut agent: Agent = + load_agent_from_config(agentfile.map(|s| s.as_str()), None, None, None) + .expect("REASON"); let _attachment_links = agent.parse_attachement_arg(attachments); let _ = create_documents( @@ -1440,7 +1440,9 @@ pub fn main() -> Result<(), Box> { .map(|s| s.as_str()); let embed: Option = create_matches.get_one::("embed").copied(); - let mut agent: Agent = load_agent_from_config(agentfile.map(|s| s.as_str()), None, None, None).expect("REASON"); + let mut agent: Agent = + load_agent_from_config(agentfile.map(|s| s.as_str()), None, None, None) + .expect("REASON"); let attachment_links = agent.parse_attachement_arg(attachments); update_documents( @@ -1459,7 +1461,9 @@ pub fn main() -> Result<(), Box> { let directory = create_matches.get_one::("directory"); let _verbose = *create_matches.get_one::("verbose").unwrap_or(&false); let agentfile = create_matches.get_one::("agent-file"); - let mut agent: Agent = load_agent_from_config(agentfile.map(|s| s.as_str()), None, None, None).expect("REASON"); + let mut agent: Agent = + load_agent_from_config(agentfile.map(|s| s.as_str()), None, None, None) + .expect("REASON"); let schema = create_matches.get_one::("schema"); let _no_save = *create_matches.get_one::("no-save").unwrap_or(&false); @@ -1470,7 +1474,9 @@ pub fn main() -> Result<(), Box> { let filename = create_matches.get_one::("filename"); let directory = create_matches.get_one::("directory"); let agentfile = create_matches.get_one::("agent-file"); - let mut agent: Agent = load_agent_from_config(agentfile.map(|s| s.as_str()), None, None, None).expect("REASON"); + let mut agent: Agent = + load_agent_from_config(agentfile.map(|s| s.as_str()), None, None, None) + .expect("REASON"); let schema = create_matches.get_one::("schema"); // Use updated set_file_list with storage @@ -1492,7 +1498,9 @@ pub fn main() -> Result<(), Box> { .map(|s| s.to_string()) .collect(); - let mut agent: Agent = load_agent_from_config(agentfile.map(|s| s.as_str()), None, None, None).expect("REASON"); + let mut agent: Agent = + load_agent_from_config(agentfile.map(|s| s.as_str()), None, None, None) + .expect("REASON"); // Use updated set_file_list with storage let _ = create_agreement(&mut agent, agentids, filename, schema, no_save, directory); @@ -1503,7 +1511,9 @@ pub fn main() -> Result<(), Box> { let directory = verify_matches.get_one::("directory"); let _verbose = *verify_matches.get_one::("verbose").unwrap_or(&false); let agentfile = verify_matches.get_one::("agent-file"); - let mut agent: Agent = load_agent_from_config(agentfile.map(|s| s.as_str()), None, None, None).expect("REASON"); + let mut agent: Agent = + load_agent_from_config(agentfile.map(|s| s.as_str()), None, None, None) + .expect("REASON"); let schema = verify_matches.get_one::("schema"); // Use updated set_file_list with storage verify_documents(&mut agent, schema, filename, directory)?; @@ -1514,7 +1524,9 @@ pub fn main() -> Result<(), Box> { let directory = extract_matches.get_one::("directory"); let _verbose = *extract_matches.get_one::("verbose").unwrap_or(&false); let agentfile = extract_matches.get_one::("agent-file"); - let mut agent: Agent = load_agent_from_config(agentfile.map(|s| s.as_str()), None, None, None).expect("REASON"); + let mut agent: Agent = + load_agent_from_config(agentfile.map(|s| s.as_str()), None, None, None) + .expect("REASON"); let schema = extract_matches.get_one::("schema"); // Use updated set_file_list with storage let _files: Vec = default_set_file_list(filename, directory, None) From 45cff13de40235a93b44c0d94639b40d4b32969a Mon Sep 17 00:00:00 2001 From: Jonathan Hendler Date: Fri, 20 Mar 2026 08:24:23 -0700 Subject: [PATCH 3/5] bump --- binding-core/Cargo.toml | 4 +- jacs-cli/Cargo.toml | 6 +- jacs-cli/src/main.rs | 73 ++++++--- jacs-duckdb/Cargo.toml | 2 +- jacs-mcp/Cargo.toml | 6 +- jacs-postgresql/Cargo.toml | 2 +- jacs-redb/Cargo.toml | 2 +- jacs-surrealdb/Cargo.toml | 2 +- jacs/Cargo.toml | 2 +- jacs/src/agent/mod.rs | 308 ++++++------------------------------- jacs/tests/config_tests.rs | 77 +++++++++- jacsgo/lib/Cargo.toml | 2 +- jacsnpm/Cargo.toml | 2 +- jacspy/Cargo.toml | 2 +- 14 files changed, 188 insertions(+), 302 deletions(-) diff --git a/binding-core/Cargo.toml b/binding-core/Cargo.toml index fbb36d0f..5314ff28 100644 --- a/binding-core/Cargo.toml +++ b/binding-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jacs-binding-core" -version = "0.9.7" +version = "0.9.8" edition = "2024" rust-version = "1.93" resolver = "3" @@ -19,7 +19,7 @@ attestation = ["jacs/attestation"] pq-tests = [] [dependencies] -jacs = { version = "0.9.7", path = "../jacs" } +jacs = { version = "0.9.8", path = "../jacs" } serde_json = "1.0" base64 = "0.22.1" serde = { version = "1.0", features = ["derive"] } diff --git a/jacs-cli/Cargo.toml b/jacs-cli/Cargo.toml index fd1d9b61..db4064b5 100644 --- a/jacs-cli/Cargo.toml +++ b/jacs-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jacs-cli" -version = "0.9.7" +version = "0.9.8" edition = "2024" rust-version = "1.93" description = "JACS CLI: command-line interface for JSON AI Communication Standard" @@ -23,8 +23,8 @@ attestation = ["jacs/attestation"] keychain = ["jacs/keychain"] [dependencies] -jacs = { version = "0.9.7", path = "../jacs" } -jacs-mcp = { version = "0.9.7", path = "../jacs-mcp", features = ["mcp", "full-tools"], optional = true } +jacs = { version = "0.9.8", path = "../jacs" } +jacs-mcp = { version = "0.9.8", path = "../jacs-mcp", features = ["mcp", "full-tools"], optional = true } clap = { version = "4.5.4", features = ["derive", "cargo"] } rpassword = "7.3.1" reqwest = { version = "0.13.2", default-features = false, features = ["blocking", "json", "rustls"] } diff --git a/jacs-cli/src/main.rs b/jacs-cli/src/main.rs index c6e57d38..be060e25 100644 --- a/jacs-cli/src/main.rs +++ b/jacs-cli/src/main.rs @@ -96,7 +96,15 @@ fn get_non_empty_env_var(key: &str) -> Result, String> { } } -fn ensure_cli_private_key_password() -> Result<(), String> { +/// Resolve the private key password from CLI sources and return it. +/// +/// Returns `Ok(Some(password))` when a password is found from env var, +/// password file, or legacy file. Returns `Ok(None)` when no CLI-level +/// password is available (the core layer will try the OS keychain). +/// +/// Also sets the `JACS_PRIVATE_KEY_PASSWORD` env var as a side-effect +/// for backward compatibility with code paths that still read it. +fn ensure_cli_private_key_password() -> Result, String> { let env_password = get_non_empty_env_var("JACS_PRIVATE_KEY_PASSWORD")?; let password_file = get_non_empty_env_var(CLI_PASSWORD_FILE_ENV)?; @@ -114,9 +122,9 @@ fn ensure_cli_private_key_password() -> Result<(), String> { } // SAFETY: CLI process is single-threaded for command handling at this point. unsafe { - env::set_var("JACS_PRIVATE_KEY_PASSWORD", password); + env::set_var("JACS_PRIVATE_KEY_PASSWORD", &password); } - return Ok(()); + return Ok(Some(password)); } // 2. Password file (explicit) @@ -124,9 +132,9 @@ fn ensure_cli_private_key_password() -> Result<(), String> { let password = read_password_from_file(Path::new(path.trim()), CLI_PASSWORD_FILE_ENV)?; // SAFETY: CLI process is single-threaded for command handling at this point. unsafe { - env::set_var("JACS_PRIVATE_KEY_PASSWORD", password); + env::set_var("JACS_PRIVATE_KEY_PASSWORD", &password); } - return Ok(()); + return Ok(Some(password)); } // 3. Legacy password file @@ -135,7 +143,7 @@ fn ensure_cli_private_key_password() -> Result<(), String> { let password = read_password_from_file(legacy_path, "legacy password file")?; // SAFETY: CLI process is single-threaded for command handling at this point. unsafe { - env::set_var("JACS_PRIVATE_KEY_PASSWORD", password); + env::set_var("JACS_PRIVATE_KEY_PASSWORD", &password); } eprintln!( "Using legacy password source '{}'. Prefer JACS_PRIVATE_KEY_PASSWORD or {}.", @@ -154,12 +162,12 @@ fn ensure_cli_private_key_password() -> Result<(), String> { ); } } - return Ok(()); + return Ok(Some(password)); } // 4. No CLI-level password found. The core layer's resolve_private_key_password() // will check the OS keychain automatically when encryption/decryption is needed. - Ok(()) + Ok(None) } fn resolve_dns_policy_overrides( @@ -192,6 +200,7 @@ fn load_agent_with_cli_dns_policy( resolve_dns_policy_overrides(ignore_dns, require_strict, require_dns, non_strict); Ok(load_agent_from_config( agent_file.as_deref(), + None, dns_validate, dns_required, dns_strict, @@ -201,9 +210,13 @@ fn load_agent_with_cli_dns_policy( /// Load an agent using the new Config + Agent::from_config pattern. /// /// Replaces the deprecated `load_agent` / `load_agent_with_dns_policy` calls. -/// Password is resolved from env var (set by `ensure_cli_private_key_password`). +/// When `password` is `Some`, it is passed directly to `Agent::from_config`. +/// When `password` is `None`, the CLI password resolution chain is used +/// (env var, password file, legacy file, keychain) and the resolved password +/// is passed explicitly to `Agent::from_config`. fn load_agent_from_config( agent_file: Option<&str>, + password: Option<&str>, dns_validate: Option, dns_required: Option, dns_strict: Option, @@ -218,7 +231,15 @@ fn load_agent_from_config( let mut config = jacs::config::Config::from_file(&config_path)?; config.apply_env_overrides(); - let mut agent = Agent::from_config(config, None)?; + // Resolve password: use provided password, or fall back to CLI password resolution + let resolved_password: Option = if password.is_some() { + password.map(String::from) + } else { + ensure_cli_private_key_password() + .map_err(|e| jacs::error::JacsError::Internal { message: e })? + }; + + let mut agent = Agent::from_config(config, resolved_password.as_deref())?; // Apply DNS policy overrides from CLI flags if let Some(validate) = dns_validate { @@ -1383,7 +1404,7 @@ pub fn main() -> Result<(), Box> { // Some(("task", task_matches)) => match task_matches.subcommand() { // Some(("create", create_matches)) => { // let agentfile = create_matches.get_one::("agent-file"); - // let mut agent: Agent = load_agent_from_config(agentfile.map(|s| s.as_str()), None, None, None).expect("REASON"); + // let mut agent: Agent = load_agent_from_config(agentfile.map(|s| s.as_str()), None, None, None, None).expect("REASON"); // let name = create_matches.get_one::("name").expect("REASON"); // let description = create_matches // .get_one::("description") @@ -1410,7 +1431,7 @@ pub fn main() -> Result<(), Box> { let embed: Option = create_matches.get_one::("embed").copied(); let mut agent: Agent = - load_agent_from_config(agentfile.map(|s| s.as_str()), None, None, None) + load_agent_from_config(agentfile.map(|s| s.as_str()), None, None, None, None) .expect("REASON"); let _attachment_links = agent.parse_attachement_arg(attachments); @@ -1441,7 +1462,7 @@ pub fn main() -> Result<(), Box> { let embed: Option = create_matches.get_one::("embed").copied(); let mut agent: Agent = - load_agent_from_config(agentfile.map(|s| s.as_str()), None, None, None) + load_agent_from_config(agentfile.map(|s| s.as_str()), None, None, None, None) .expect("REASON"); let attachment_links = agent.parse_attachement_arg(attachments); @@ -1462,7 +1483,7 @@ pub fn main() -> Result<(), Box> { let _verbose = *create_matches.get_one::("verbose").unwrap_or(&false); let agentfile = create_matches.get_one::("agent-file"); let mut agent: Agent = - load_agent_from_config(agentfile.map(|s| s.as_str()), None, None, None) + load_agent_from_config(agentfile.map(|s| s.as_str()), None, None, None, None) .expect("REASON"); let schema = create_matches.get_one::("schema"); let _no_save = *create_matches.get_one::("no-save").unwrap_or(&false); @@ -1475,7 +1496,7 @@ pub fn main() -> Result<(), Box> { let directory = create_matches.get_one::("directory"); let agentfile = create_matches.get_one::("agent-file"); let mut agent: Agent = - load_agent_from_config(agentfile.map(|s| s.as_str()), None, None, None) + load_agent_from_config(agentfile.map(|s| s.as_str()), None, None, None, None) .expect("REASON"); let schema = create_matches.get_one::("schema"); @@ -1499,7 +1520,7 @@ pub fn main() -> Result<(), Box> { .collect(); let mut agent: Agent = - load_agent_from_config(agentfile.map(|s| s.as_str()), None, None, None) + load_agent_from_config(agentfile.map(|s| s.as_str()), None, None, None, None) .expect("REASON"); // Use updated set_file_list with storage let _ = @@ -1512,7 +1533,7 @@ pub fn main() -> Result<(), Box> { let _verbose = *verify_matches.get_one::("verbose").unwrap_or(&false); let agentfile = verify_matches.get_one::("agent-file"); let mut agent: Agent = - load_agent_from_config(agentfile.map(|s| s.as_str()), None, None, None) + load_agent_from_config(agentfile.map(|s| s.as_str()), None, None, None, None) .expect("REASON"); let schema = verify_matches.get_one::("schema"); // Use updated set_file_list with storage @@ -1525,7 +1546,7 @@ pub fn main() -> Result<(), Box> { let _verbose = *extract_matches.get_one::("verbose").unwrap_or(&false); let agentfile = extract_matches.get_one::("agent-file"); let mut agent: Agent = - load_agent_from_config(agentfile.map(|s| s.as_str()), None, None, None) + load_agent_from_config(agentfile.map(|s| s.as_str()), None, None, None, None) .expect("REASON"); let schema = extract_matches.get_one::("schema"); // Use updated set_file_list with storage @@ -2694,8 +2715,14 @@ mod tests { std::env::set_var(CLI_PASSWORD_FILE_ENV, &password_file); } - ensure_cli_private_key_password().expect("password bootstrap should succeed"); + let resolved = + ensure_cli_private_key_password().expect("password bootstrap should succeed"); + assert_eq!( + resolved.as_deref(), + Some("TestP@ss123!#"), + "resolved password should match password file content" + ); assert_eq!( std::env::var("JACS_PRIVATE_KEY_PASSWORD").expect("env password"), "TestP@ss123!#" @@ -2722,8 +2749,14 @@ mod tests { std::env::set_var(CLI_PASSWORD_FILE_ENV, &password_file); } - ensure_cli_private_key_password().expect("password bootstrap should succeed"); + let resolved = + ensure_cli_private_key_password().expect("password bootstrap should succeed"); + assert_eq!( + resolved.as_deref(), + Some("TestP@ss123!#"), + "env var should win over password file" + ); assert_eq!( std::env::var("JACS_PRIVATE_KEY_PASSWORD").expect("env password"), "TestP@ss123!#" diff --git a/jacs-duckdb/Cargo.toml b/jacs-duckdb/Cargo.toml index eec2d512..f5d4a95b 100644 --- a/jacs-duckdb/Cargo.toml +++ b/jacs-duckdb/Cargo.toml @@ -13,7 +13,7 @@ keywords = ["cryptography", "json", "duckdb", "storage"] categories = ["database", "data-structures"] [dependencies] -jacs = { version = "0.9.7", path = "../jacs", default-features = false } +jacs = { version = "0.9.8", path = "../jacs", default-features = false } duckdb = { version = "1.4", features = ["bundled", "json"] } serde_json = "1.0" diff --git a/jacs-mcp/Cargo.toml b/jacs-mcp/Cargo.toml index 200b7f58..26c039a6 100644 --- a/jacs-mcp/Cargo.toml +++ b/jacs-mcp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jacs-mcp" -version = "0.9.7" +version = "0.9.8" edition = "2024" rust-version = "1.93" description = "MCP server for JACS: data provenance and cryptographic signing of agent state" @@ -45,8 +45,8 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } rmcp = { version = "0.12", features = ["client", "server", "transport-io", "transport-child-process", "macros"], optional = true } tokio = { version = "1", features = ["rt-multi-thread", "macros", "process", "time"], optional = true } -jacs = { version = "0.9.7", path = "../jacs", default-features = true } -jacs-binding-core = { version = "0.9.7", path = "../binding-core", features = ["a2a"] } +jacs = { version = "0.9.8", path = "../jacs", default-features = true } +jacs-binding-core = { version = "0.9.8", path = "../binding-core", features = ["a2a"] } serde = { version = "1", features = ["derive"] } serde_json = "1" schemars = "1.0" diff --git a/jacs-postgresql/Cargo.toml b/jacs-postgresql/Cargo.toml index bdd9fe2f..e84da755 100644 --- a/jacs-postgresql/Cargo.toml +++ b/jacs-postgresql/Cargo.toml @@ -13,7 +13,7 @@ keywords = ["cryptography", "json", "postgresql", "storage"] categories = ["database", "data-structures"] [dependencies] -jacs = { version = "0.9.7", path = "../jacs", default-features = false } +jacs = { version = "0.9.8", path = "../jacs", default-features = false } sqlx = { version = "0.8.6", default-features = false, features = ["runtime-tokio-rustls", "postgres"] } tokio = { version = "1.0", features = ["rt-multi-thread"] } serde_json = "1.0" diff --git a/jacs-redb/Cargo.toml b/jacs-redb/Cargo.toml index 100c0b60..f097494c 100644 --- a/jacs-redb/Cargo.toml +++ b/jacs-redb/Cargo.toml @@ -13,7 +13,7 @@ categories.workspace = true description = "Redb (pure-Rust embedded KV) storage backend for JACS documents" [dependencies] -jacs = { version = "0.9.7", path = "../jacs", default-features = false } +jacs = { version = "0.9.8", path = "../jacs", default-features = false } redb = "3.1" chrono = "0.4.40" serde_json = "1.0" diff --git a/jacs-surrealdb/Cargo.toml b/jacs-surrealdb/Cargo.toml index a1556ced..901ad6ef 100644 --- a/jacs-surrealdb/Cargo.toml +++ b/jacs-surrealdb/Cargo.toml @@ -13,7 +13,7 @@ keywords = ["cryptography", "json", "surrealdb", "storage"] categories = ["database", "data-structures"] [dependencies] -jacs = { version = "0.9.7", path = "../jacs", default-features = false } +jacs = { version = "0.9.8", path = "../jacs", default-features = false } surrealdb = { version = "3.0.2", default-features = false, features = ["kv-mem"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/jacs/Cargo.toml b/jacs/Cargo.toml index 1bb1a830..591dfd1a 100644 --- a/jacs/Cargo.toml +++ b/jacs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jacs" -version = "0.9.7" +version = "0.9.8" edition = "2024" rust-version = "1.93" resolver = "3" diff --git a/jacs/src/agent/mod.rs b/jacs/src/agent/mod.rs index ac7eee85..66872440 100644 --- a/jacs/src/agent/mod.rs +++ b/jacs/src/agent/mod.rs @@ -347,11 +347,10 @@ impl Agent { pub fn from_config(mut config: Config, password: Option<&str>) -> Result { let schema = Schema::new("v1", "v1", "v1")?; let document_schemas_map = Arc::new(Mutex::new(HashMap::new())); - let key_paths = Some(Self::key_paths_from_config(&config)); // Calculate storage root and normalize config directories let (storage_root, normalized_config) = - Self::calculate_storage_root_and_normalize(config, "Agent::load")?; + Self::calculate_storage_root_and_normalize(config, "Agent::from_config")?; config = normalized_config; let storage_type: String = config @@ -394,13 +393,13 @@ impl Agent { dns_strict: false, dns_validate_enabled: None, dns_required: None, - key_paths, + key_paths: None, password: password.map(String::from), #[cfg(feature = "attestation")] adapters: crate::attestation::adapters::default_adapters(), }; - // Refresh key_paths from the normalized config + // Compute key_paths from the normalized config agent.refresh_key_paths_from_config(); if !lookup_id.is_empty() { @@ -730,159 +729,18 @@ impl Agent { path, e ) })?; - // Clone values needed for error messages to avoid borrow conflicts - let lookup_id: String = config - .jacs_agent_id_and_version() - .as_deref() - .unwrap_or("") - .to_string(); - let storage_type: String = config - .jacs_default_storage() - .as_deref() - .unwrap_or("") - .to_string(); - let uses_filesystem_paths = matches!(storage_type.as_str(), "fs" | "rusqlite" | "sqlite"); - let storage_root = if uses_filesystem_paths { - // Prefer config.config_dir (set by Config::from_file) over re-deriving - // from the path argument. This fixes Bug 2 where callers absolutize paths - // and push them through env vars, causing storage_root = "/". - let config_dir = config.config_dir().unwrap_or_else(|| { - std::path::Path::new(&path) - .parent() - .filter(|p| !p.as_os_str().is_empty()) - .unwrap_or_else(|| std::path::Path::new(".")) - }); - let config_dir_absolute = if config_dir.is_absolute() { - config_dir.to_path_buf() - } else { - std::env::current_dir()?.join(config_dir) - }; - let normalize_path = |p: &std::path::Path| -> std::path::PathBuf { - let mut normalized = std::path::PathBuf::new(); - for component in p.components() { - match component { - std::path::Component::CurDir => {} - std::path::Component::ParentDir => { - normalized.pop(); - } - other => normalized.push(other.as_os_str()), - } - } - normalized - }; - // Normalize configured filesystem directories. - // - Relative directories are treated as config-dir relative. - // - Absolute directories inside the config-dir root are rewritten - // to relative paths so storage can stay rooted at config_dir. - // - Absolute directories outside config_dir require root "/". - let mut config_value = to_value(&config).map_err(|e| { - format!( - "load_by_config failed: Could not serialize configuration from '{}': {}", - path, e - ) - })?; - let mut has_external_absolute = false; - for field in ["jacs_data_directory", "jacs_key_directory"] { - if let Some(dir) = config_value.get(field).and_then(|v| v.as_str()) { - let dir_path = std::path::Path::new(dir); - if dir_path - .components() - .any(|component| matches!(component, std::path::Component::ParentDir)) - { - return Err(format!( - "load_by_config failed: Config field '{}' in '{}' contains unsafe parent-directory segment ('..'): '{}'", - field, path, dir - ) - .into()); - } - if dir_path.is_absolute() { - let normalized_abs = normalize_path(dir_path); - if let Ok(relative_tail) = normalized_abs.strip_prefix(&config_dir_absolute) - { - let relative = relative_tail - .to_string_lossy() - .trim_start_matches('/') - .to_string(); - if relative.is_empty() { - has_external_absolute = true; - config_value[field] = - json!(normalized_abs.to_string_lossy().to_string()); - } else { - config_value[field] = json!(relative); - } - } else { - has_external_absolute = true; - config_value[field] = - json!(normalized_abs.to_string_lossy().to_string()); - } - } else { - let normalized_rel = normalize_path(dir_path); - config_value[field] = json!(normalized_rel.to_string_lossy().to_string()); - } - } - } - - let storage_root = if has_external_absolute { - // When rooting at "/", convert any remaining relative dirs to - // absolute config-dir-based paths so they remain stable. - for field in ["jacs_data_directory", "jacs_key_directory"] { - if let Some(dir) = config_value.get(field).and_then(|v| v.as_str()) { - let dir_path = std::path::Path::new(dir); - if !dir_path.is_absolute() { - let abs = normalize_path(&config_dir_absolute.join(dir_path)); - config_value[field] = json!(abs.to_string_lossy().to_string()); - } - } - } - std::path::PathBuf::from("/") - } else { - config_dir_absolute - }; - - config = serde_json::from_value(config_value).map_err(|e| { - format!( - "load_by_config failed: Could not normalize filesystem directories in config '{}': {}", - path, e - ) - })?; - storage_root - } else { - std::env::current_dir()? - }; - - self.config = Some(config); - // Refresh key_paths from the new config so build_fs_store() uses the - // correct key directory, not stale paths from construction time (Issue 012). - self.refresh_key_paths_from_config(); - let file_storage_type = if matches!(storage_type.as_str(), "rusqlite" | "sqlite") { - "fs".to_string() - } else { - storage_type.clone() - }; - self.storage = MultiStorage::_new(file_storage_type, storage_root).map_err(|e| { - format!( - "load_by_config failed: Could not initialize storage type '{}' (from config '{}'): {}", - storage_type, path, e - ) - })?; - if !lookup_id.is_empty() { - let agent_string = self.fs_agent_load(&lookup_id).map_err(|e| { - format!( - "load_by_config failed: Could not load agent '{}' (specified in config '{}'): {}", - lookup_id, path, e - ) - })?; - self.load(&agent_string).map_err(|e| { - let err_msg = format!( - "load_by_config failed: Agent '{}' validation or key loading failed (config '{}'): {}", - lookup_id, path, e - ); - JacsError::Internal { message: err_msg } - }) - } else { - Ok(()) + // Ensure config_dir is set (fallback to path's parent if Config::from_file + // did not set it, e.g. when loaded via load_config_12factor). + if config.config_dir().is_none() { + let fallback_dir = std::path::Path::new(&path) + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or_else(|| std::path::Path::new(".")); + config.set_config_dir(Some(fallback_dir.to_path_buf())); } + + self.apply_config_and_load(config, "load_by_config", &path) } /// Load agent configuration from a file **without** applying env/jenv overrides. @@ -899,6 +757,31 @@ impl Agent { path, e ) })?; + + // Ensure config_dir is set (fallback to path's parent if Config::from_file + // did not set it, e.g. when loaded via load_config_file_only). + if config.config_dir().is_none() { + let fallback_dir = std::path::Path::new(&path) + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or_else(|| std::path::Path::new(".")); + config.set_config_dir(Some(fallback_dir.to_path_buf())); + } + + self.apply_config_and_load(config, "load_by_config_file_only", &path) + } + + /// Shared helper for `load_by_config` and `load_by_config_file_only`. + /// + /// Takes a pre-loaded `Config` (with `config_dir` already set), calculates the + /// storage root via [`calculate_storage_root_and_normalize`], initializes storage, + /// and loads the agent identity if configured. + fn apply_config_and_load( + &mut self, + config: Config, + caller: &str, + path: &str, + ) -> Result<(), JacsError> { let lookup_id: String = config .jacs_agent_id_and_version() .as_deref() @@ -909,109 +792,12 @@ impl Agent { .as_deref() .unwrap_or("") .to_string(); - let uses_filesystem_paths = matches!(storage_type.as_str(), "fs" | "rusqlite" | "sqlite"); - let storage_root = if uses_filesystem_paths { - // Prefer config.config_dir (set by Config::from_file) over re-deriving - // from the path argument. - let config_dir = config.config_dir().unwrap_or_else(|| { - std::path::Path::new(&path) - .parent() - .filter(|p| !p.as_os_str().is_empty()) - .unwrap_or_else(|| std::path::Path::new(".")) - }); - let config_dir_absolute = if config_dir.is_absolute() { - config_dir.to_path_buf() - } else { - std::env::current_dir()?.join(config_dir) - }; - let normalize_path = |p: &std::path::Path| -> std::path::PathBuf { - let mut normalized = std::path::PathBuf::new(); - for component in p.components() { - match component { - std::path::Component::CurDir => {} - std::path::Component::ParentDir => { - normalized.pop(); - } - other => normalized.push(other.as_os_str()), - } - } - normalized - }; - let mut config_value = to_value(&config).map_err(|e| { - format!( - "load_by_config_file_only failed: Could not serialize configuration from '{}': {}", - path, e - ) - })?; - let mut has_external_absolute = false; - for field in ["jacs_data_directory", "jacs_key_directory"] { - if let Some(dir) = config_value.get(field).and_then(|v| v.as_str()) { - let dir_path = std::path::Path::new(dir); - if dir_path - .components() - .any(|component| matches!(component, std::path::Component::ParentDir)) - { - return Err(format!( - "load_by_config_file_only failed: Config field '{}' in '{}' contains unsafe parent-directory segment ('..'): '{}'", - field, path, dir - ) - .into()); - } - if dir_path.is_absolute() { - let normalized_abs = normalize_path(dir_path); - if let Ok(relative_tail) = normalized_abs.strip_prefix(&config_dir_absolute) - { - let relative = relative_tail - .to_string_lossy() - .trim_start_matches('/') - .to_string(); - if relative.is_empty() { - has_external_absolute = true; - config_value[field] = - json!(normalized_abs.to_string_lossy().to_string()); - } else { - config_value[field] = json!(relative); - } - } else { - has_external_absolute = true; - config_value[field] = - json!(normalized_abs.to_string_lossy().to_string()); - } - } else { - let normalized_rel = normalize_path(dir_path); - config_value[field] = json!(normalized_rel.to_string_lossy().to_string()); - } - } - } - - let storage_root = if has_external_absolute { - for field in ["jacs_data_directory", "jacs_key_directory"] { - if let Some(dir) = config_value.get(field).and_then(|v| v.as_str()) { - let dir_path = std::path::Path::new(dir); - if !dir_path.is_absolute() { - let abs = normalize_path(&config_dir_absolute.join(dir_path)); - config_value[field] = json!(abs.to_string_lossy().to_string()); - } - } - } - std::path::PathBuf::from("/") - } else { - config_dir_absolute - }; - - config = serde_json::from_value(config_value).map_err(|e| { - format!( - "load_by_config_file_only failed: Could not normalize filesystem directories in config '{}': {}", - path, e - ) - })?; - storage_root - } else { - std::env::current_dir()? - }; + let (storage_root, config) = Self::calculate_storage_root_and_normalize(config, caller)?; self.config = Some(config); + // Refresh key_paths from the new config so build_fs_store() uses the + // correct key directory, not stale paths from construction time (Issue 012). self.refresh_key_paths_from_config(); let file_storage_type = if matches!(storage_type.as_str(), "rusqlite" | "sqlite") { "fs".to_string() @@ -1020,21 +806,21 @@ impl Agent { }; self.storage = MultiStorage::_new(file_storage_type, storage_root).map_err(|e| { format!( - "load_by_config_file_only failed: Could not initialize storage type '{}' (from config '{}'): {}", - storage_type, path, e + "{} failed: Could not initialize storage type '{}' (from config '{}'): {}", + caller, storage_type, path, e ) })?; if !lookup_id.is_empty() { let agent_string = self.fs_agent_load(&lookup_id).map_err(|e| { format!( - "load_by_config_file_only failed: Could not load agent '{}' (specified in config '{}'): {}", - lookup_id, path, e + "{} failed: Could not load agent '{}' (specified in config '{}'): {}", + caller, lookup_id, path, e ) })?; self.load(&agent_string).map_err(|e| { let err_msg = format!( - "load_by_config_file_only failed: Agent '{}' validation or key loading failed (config '{}'): {}", - lookup_id, path, e + "{} failed: Agent '{}' validation or key loading failed (config '{}'): {}", + caller, lookup_id, path, e ); JacsError::Internal { message: err_msg } }) diff --git a/jacs/tests/config_tests.rs b/jacs/tests/config_tests.rs index dcb4cdf0..40cf6840 100644 --- a/jacs/tests/config_tests.rs +++ b/jacs/tests/config_tests.rs @@ -305,11 +305,11 @@ fn test_agent_from_config_sets_password() { config.apply_env_overrides(); let agent = Agent::from_config(config, Some("my-test-password")).unwrap(); - // The password should be set on the agent (we can verify this indirectly - // by checking the agent was created successfully without env var) - assert!( - agent.config.is_some(), - "Agent config should be set after from_config" + // Verify the password was actually stored on the agent + assert_eq!( + agent.password(), + Some("my-test-password"), + "Agent password should be set to the value passed to from_config" ); clear_test_env_vars(); @@ -360,3 +360,70 @@ fn test_agent_from_config_none_password_uses_env_fallback() { clear_test_env_vars(); } + +/// Test that config_dir is dropped by serde round-trip (justifying the explicit +/// preservation step in calculate_storage_root_and_normalize). +#[test] +fn test_config_dir_dropped_by_serde_roundtrip() { + let mut config = Config::with_defaults(); + config.set_config_dir(Some(std::path::PathBuf::from("/my/config/dir"))); + assert_eq!( + config.config_dir(), + Some(std::path::Path::new("/my/config/dir")), + "config_dir should be set before round-trip" + ); + + // Serialize and deserialize (simulates the normalization round-trip) + let json = serde_json::to_value(&config).unwrap(); + let roundtripped: Config = serde_json::from_value(json).unwrap(); + + // config_dir should be None after round-trip because of #[serde(skip)] + assert_eq!( + roundtripped.config_dir(), + None, + "config_dir should be None after serde round-trip (serde(skip) drops it)" + ); +} + +/// Test that two agents created with Agent::from_config using different passwords +/// do not interfere with each other (no env var leakage). +#[test] +#[serial] +fn test_concurrent_agents_with_different_passwords() { + clear_test_env_vars(); + utils::set_min_test_env_vars(); + + let config_path = utils::raw_fixture("ring.jacs.config.json"); + + // Create first agent with password "alpha-password" + // apply_env_overrides to use safe test directories (fixture has ".." paths) + let mut config1 = Config::from_file(&config_path.to_string_lossy()).unwrap(); + config1.apply_env_overrides(); + let agent1 = Agent::from_config(config1, Some("alpha-password")).unwrap(); + + // Create second agent with a different password + let mut config2 = Config::from_file(&config_path.to_string_lossy()).unwrap(); + config2.apply_env_overrides(); + let agent2 = Agent::from_config(config2, Some("beta-password")).unwrap(); + + // Verify each agent has its own password (no cross-contamination) + assert_eq!( + agent1.password(), + Some("alpha-password"), + "Agent 1 should retain its password" + ); + assert_eq!( + agent2.password(), + Some("beta-password"), + "Agent 2 should retain its password" + ); + + // Verify they are distinct agents with distinct passwords + assert_ne!( + agent1.password(), + agent2.password(), + "The two agents should have different passwords" + ); + + clear_test_env_vars(); +} diff --git a/jacsgo/lib/Cargo.toml b/jacsgo/lib/Cargo.toml index a3a44c24..a3d517a9 100644 --- a/jacsgo/lib/Cargo.toml +++ b/jacsgo/lib/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jacsgo" -version = "0.9.7" +version = "0.9.8" edition = "2024" rust-version = "1.93" resolver = "3" diff --git a/jacsnpm/Cargo.toml b/jacsnpm/Cargo.toml index 136d6fef..f3780965 100644 --- a/jacsnpm/Cargo.toml +++ b/jacsnpm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jacsnpm" -version = "0.9.7" +version = "0.9.8" edition = "2024" rust-version = "1.93" resolver = "3" diff --git a/jacspy/Cargo.toml b/jacspy/Cargo.toml index 61e4c040..284e37cd 100644 --- a/jacspy/Cargo.toml +++ b/jacspy/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jacspy" -version = "0.9.7" +version = "0.9.8" edition = "2024" rust-version = "1.93" resolver = "3" From 7c4fa66364c413b9dc596259218326bfa6f52311 Mon Sep 17 00:00:00 2001 From: Jonathan Hendler Date: Fri, 20 Mar 2026 08:45:59 -0700 Subject: [PATCH 4/5] Regenerate jacs-mcp contract snapshot for 0.9.8 --- jacs-mcp/contract/jacs-mcp-contract.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jacs-mcp/contract/jacs-mcp-contract.json b/jacs-mcp/contract/jacs-mcp-contract.json index 5a800d5d..3e59523e 100644 --- a/jacs-mcp/contract/jacs-mcp-contract.json +++ b/jacs-mcp/contract/jacs-mcp-contract.json @@ -3,7 +3,7 @@ "server": { "name": "jacs-mcp", "title": "JACS MCP Server", - "version": "0.9.7", + "version": "0.9.8", "website_url": "https://humanassisted.github.io/JACS/", "instructions": "This MCP server provides data provenance and cryptographic signing for agent state files and agent-to-agent messaging. Agent state tools: jacs_sign_state (sign files), jacs_verify_state (verify integrity), jacs_load_state (load with verification), jacs_update_state (update and re-sign), jacs_list_state (list signed docs), jacs_adopt_state (adopt external files). Memory tools: jacs_memory_save (save a memory), jacs_memory_recall (search memories by query), jacs_memory_list (list all memories), jacs_memory_forget (soft-delete a memory), jacs_memory_update (update an existing memory). Messaging tools: jacs_message_send (create and sign a message), jacs_message_update (update and re-sign a message), jacs_message_agree (co-sign/agree to a message), jacs_message_receive (verify and extract a received message). Agent management: jacs_create_agent (create new agent with keys), jacs_reencrypt_key (rotate private key password). A2A artifacts: jacs_wrap_a2a_artifact (sign artifact with provenance), jacs_verify_a2a_artifact (verify wrapped artifact), jacs_assess_a2a_agent (assess remote agent trust level). A2A discovery: jacs_export_agent_card (export Agent Card), jacs_generate_well_known (generate .well-known documents), jacs_export_agent (export full agent JSON). Trust store: jacs_trust_agent (add agent to trust store), jacs_untrust_agent (remove from trust store, requires JACS_MCP_ALLOW_UNTRUST=true), jacs_list_trusted_agents (list all trusted agent IDs), jacs_is_trusted (check if agent is trusted), jacs_get_trusted_agent (get trusted agent JSON). Attestation: jacs_attest_create (create signed attestation with claims), jacs_attest_verify (verify attestation, optionally with evidence checks), jacs_attest_lift (lift signed document into attestation), jacs_attest_export_dsse (export attestation as DSSE envelope). Security: jacs_audit (read-only security audit and health checks). Audit trail: jacs_audit_log (record events as signed audit entries), jacs_audit_query (search audit trail by action, target, time range), jacs_audit_export (export audit trail as signed bundle). Search: jacs_search (unified search across all signed documents)." }, From 52881b0efb86e430d79a3eae39ab3fa2f3dca862 Mon Sep 17 00:00:00 2001 From: Jonathan Hendler Date: Fri, 20 Mar 2026 09:06:02 -0700 Subject: [PATCH 5/5] Fix --agent-file flag being misused as config path override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit load_agent_from_config accepted agent_file as its first parameter but used it as a config path. When -a /path/to/agent.json was passed, it tried to parse the agent JSON as a jacs.config.json and failed. Replaced with load_agent() — no parameters, resolves config from JACS_CONFIG env / ./jacs.config.json, resolves password from CLI chain. load_agent_with_cli_dns_policy wraps it with DNS flag overrides. The --agent-file flag variables are kept (prefixed with _) for future use when agent-file loading is properly implemented as a post-init step. --- jacs-cli/src/main.rs | 112 ++++++++++++++++--------------------------- 1 file changed, 40 insertions(+), 72 deletions(-) diff --git a/jacs-cli/src/main.rs b/jacs-cli/src/main.rs index be060e25..d5cbd6bf 100644 --- a/jacs-cli/src/main.rs +++ b/jacs-cli/src/main.rs @@ -190,7 +190,6 @@ fn resolve_dns_policy_overrides( } fn load_agent_with_cli_dns_policy( - agent_file: Option, ignore_dns: bool, require_strict: bool, require_dns: bool, @@ -198,61 +197,32 @@ fn load_agent_with_cli_dns_policy( ) -> Result> { let (dns_validate, dns_required, dns_strict) = resolve_dns_policy_overrides(ignore_dns, require_strict, require_dns, non_strict); - Ok(load_agent_from_config( - agent_file.as_deref(), - None, - dns_validate, - dns_required, - dns_strict, - )?) + let mut agent = load_agent()?; + if let Some(v) = dns_validate { agent.set_dns_validate(v); } + if let Some(v) = dns_required { agent.set_dns_required(v); } + if let Some(v) = dns_strict { agent.set_dns_strict(v); } + Ok(agent) } /// Load an agent using the new Config + Agent::from_config pattern. /// /// Replaces the deprecated `load_agent` / `load_agent_with_dns_policy` calls. -/// When `password` is `Some`, it is passed directly to `Agent::from_config`. -/// When `password` is `None`, the CLI password resolution chain is used -/// (env var, password file, legacy file, keychain) and the resolved password -/// is passed explicitly to `Agent::from_config`. -fn load_agent_from_config( - agent_file: Option<&str>, - password: Option<&str>, - dns_validate: Option, - dns_required: Option, - dns_strict: Option, -) -> Result { - let config_path = agent_file.map(String::from).unwrap_or_else(|| { - std::env::var("JACS_CONFIG") - .ok() - .filter(|v| !v.trim().is_empty()) - .unwrap_or_else(|| "./jacs.config.json".to_string()) - }); - - let mut config = jacs::config::Config::from_file(&config_path)?; - config.apply_env_overrides(); - - // Resolve password: use provided password, or fall back to CLI password resolution - let resolved_password: Option = if password.is_some() { - password.map(String::from) - } else { - ensure_cli_private_key_password() - .map_err(|e| jacs::error::JacsError::Internal { message: e })? - }; - - let mut agent = Agent::from_config(config, resolved_password.as_deref())?; - - // Apply DNS policy overrides from CLI flags - if let Some(validate) = dns_validate { - agent.set_dns_validate(validate); - } - if let Some(required) = dns_required { - agent.set_dns_required(required); - } - if let Some(strict) = dns_strict { - agent.set_dns_strict(strict); - } +/// Resolve the JACS config path from JACS_CONFIG env var or default. +fn resolve_config_path() -> String { + std::env::var("JACS_CONFIG") + .ok() + .filter(|v| !v.trim().is_empty()) + .unwrap_or_else(|| "./jacs.config.json".to_string()) +} - Ok(agent) +/// Load a JACS agent from the default config path with password from the +/// CLI resolution chain (env var, password file, keychain, prompt). +fn load_agent() -> Result { + let mut config = jacs::config::Config::from_file(&resolve_config_path())?; + config.apply_env_overrides(); + let password = ensure_cli_private_key_password() + .map_err(|e| jacs::error::JacsError::Internal { message: e })?; + Agent::from_config(config, password.as_deref()) } fn wrap_quickstart_error_with_password_help( @@ -1199,7 +1169,7 @@ pub fn main() -> Result<(), Box> { .unwrap_or("plain"); // Load agent from optional path, supporting non-strict DNS for propagation - let agent_file = sub_m.get_one::("agent-file").cloned(); + let _agent_file = sub_m.get_one::("agent-file").cloned(); let non_strict = *sub_m.get_one::("no-dns").unwrap_or(&false); let ignore_dns = *sub_m.get_one::("ignore-dns").unwrap_or(&false); let require_strict = *sub_m @@ -1207,13 +1177,12 @@ pub fn main() -> Result<(), Box> { .unwrap_or(&false); let require_dns = *sub_m.get_one::("require-dns").unwrap_or(&false); let agent: Agent = load_agent_with_cli_dns_policy( - agent_file, ignore_dns, require_strict, require_dns, non_strict, ) - .expect("Provide --agent-file or ensure config points to a readable agent"); + .expect("Failed to load agent from config"); let agent_id = agent_id_arg.unwrap_or_else(|| agent.get_id().unwrap_or_default()); let pk = agent.get_public_key().expect("public key"); let digest = match enc { @@ -1276,7 +1245,7 @@ pub fn main() -> Result<(), Box> { handle_agent_create(filename, create_keys)?; } Some(("verify", verify_matches)) => { - let agentfile = verify_matches.get_one::("agent-file"); + let _agentfile = verify_matches.get_one::("agent-file"); let non_strict = *verify_matches.get_one::("no-dns").unwrap_or(&false); let require_dns = *verify_matches .get_one::("require-dns") @@ -1288,13 +1257,12 @@ pub fn main() -> Result<(), Box> { .get_one::("ignore-dns") .unwrap_or(&false); let mut agent: Agent = load_agent_with_cli_dns_policy( - agentfile.cloned(), ignore_dns, require_strict, require_dns, non_strict, ) - .expect("agent file"); + .expect("Failed to load agent from config"); agent .verify_self_signature() .expect("signature verification"); @@ -1403,8 +1371,8 @@ pub fn main() -> Result<(), Box> { // Some(("task", task_matches)) => match task_matches.subcommand() { // Some(("create", create_matches)) => { - // let agentfile = create_matches.get_one::("agent-file"); - // let mut agent: Agent = load_agent_from_config(agentfile.map(|s| s.as_str()), None, None, None, None).expect("REASON"); + // let _agentfile = create_matches.get_one::("agent-file"); + // let mut agent: Agent = load_agent().expect("REASON"); // let name = create_matches.get_one::("name").expect("REASON"); // let description = create_matches // .get_one::("description") @@ -1423,7 +1391,7 @@ pub fn main() -> Result<(), Box> { let directory = create_matches.get_one::("directory"); let _verbose = *create_matches.get_one::("verbose").unwrap_or(&false); let no_save = *create_matches.get_one::("no-save").unwrap_or(&false); - let agentfile = create_matches.get_one::("agent-file"); + let _agentfile = create_matches.get_one::("agent-file"); let schema = create_matches.get_one::("schema"); let attachments = create_matches .get_one::("attach") @@ -1431,7 +1399,7 @@ pub fn main() -> Result<(), Box> { let embed: Option = create_matches.get_one::("embed").copied(); let mut agent: Agent = - load_agent_from_config(agentfile.map(|s| s.as_str()), None, None, None, None) + load_agent() .expect("REASON"); let _attachment_links = agent.parse_attachement_arg(attachments); @@ -1454,7 +1422,7 @@ pub fn main() -> Result<(), Box> { let outputfilename = create_matches.get_one::("output"); let _verbose = *create_matches.get_one::("verbose").unwrap_or(&false); let no_save = *create_matches.get_one::("no-save").unwrap_or(&false); - let agentfile = create_matches.get_one::("agent-file"); + let _agentfile = create_matches.get_one::("agent-file"); let schema = create_matches.get_one::("schema"); let attachments = create_matches .get_one::("attach") @@ -1462,7 +1430,7 @@ pub fn main() -> Result<(), Box> { let embed: Option = create_matches.get_one::("embed").copied(); let mut agent: Agent = - load_agent_from_config(agentfile.map(|s| s.as_str()), None, None, None, None) + load_agent() .expect("REASON"); let attachment_links = agent.parse_attachement_arg(attachments); @@ -1481,9 +1449,9 @@ pub fn main() -> Result<(), Box> { let filename = create_matches.get_one::("filename"); let directory = create_matches.get_one::("directory"); let _verbose = *create_matches.get_one::("verbose").unwrap_or(&false); - let agentfile = create_matches.get_one::("agent-file"); + let _agentfile = create_matches.get_one::("agent-file"); let mut agent: Agent = - load_agent_from_config(agentfile.map(|s| s.as_str()), None, None, None, None) + load_agent() .expect("REASON"); let schema = create_matches.get_one::("schema"); let _no_save = *create_matches.get_one::("no-save").unwrap_or(&false); @@ -1494,9 +1462,9 @@ pub fn main() -> Result<(), Box> { Some(("check-agreement", create_matches)) => { let filename = create_matches.get_one::("filename"); let directory = create_matches.get_one::("directory"); - let agentfile = create_matches.get_one::("agent-file"); + let _agentfile = create_matches.get_one::("agent-file"); let mut agent: Agent = - load_agent_from_config(agentfile.map(|s| s.as_str()), None, None, None, None) + load_agent() .expect("REASON"); let schema = create_matches.get_one::("schema"); @@ -1509,7 +1477,7 @@ pub fn main() -> Result<(), Box> { let filename = create_matches.get_one::("filename"); let directory = create_matches.get_one::("directory"); let _verbose = *create_matches.get_one::("verbose").unwrap_or(&false); - let agentfile = create_matches.get_one::("agent-file"); + let _agentfile = create_matches.get_one::("agent-file"); let schema = create_matches.get_one::("schema"); let no_save = *create_matches.get_one::("no-save").unwrap_or(&false); @@ -1520,7 +1488,7 @@ pub fn main() -> Result<(), Box> { .collect(); let mut agent: Agent = - load_agent_from_config(agentfile.map(|s| s.as_str()), None, None, None, None) + load_agent() .expect("REASON"); // Use updated set_file_list with storage let _ = @@ -1531,9 +1499,9 @@ pub fn main() -> Result<(), Box> { let filename = verify_matches.get_one::("filename"); let directory = verify_matches.get_one::("directory"); let _verbose = *verify_matches.get_one::("verbose").unwrap_or(&false); - let agentfile = verify_matches.get_one::("agent-file"); + let _agentfile = verify_matches.get_one::("agent-file"); let mut agent: Agent = - load_agent_from_config(agentfile.map(|s| s.as_str()), None, None, None, None) + load_agent() .expect("REASON"); let schema = verify_matches.get_one::("schema"); // Use updated set_file_list with storage @@ -1544,9 +1512,9 @@ pub fn main() -> Result<(), Box> { let filename = extract_matches.get_one::("filename"); let directory = extract_matches.get_one::("directory"); let _verbose = *extract_matches.get_one::("verbose").unwrap_or(&false); - let agentfile = extract_matches.get_one::("agent-file"); + let _agentfile = extract_matches.get_one::("agent-file"); let mut agent: Agent = - load_agent_from_config(agentfile.map(|s| s.as_str()), None, None, None, None) + load_agent() .expect("REASON"); let schema = extract_matches.get_one::("schema"); // Use updated set_file_list with storage