1use std::collections::HashMap;
10use std::path::Path;
11use std::sync::Arc;
12
13use anyhow::{Context, Result};
14use dashmap::DashMap;
15use nexo_config::types::agents::AgentConfig;
16use nexo_config::types::credentials::{GoogleAccountConfig, GoogleAuthConfig, GoogleAuthFile};
17use nexo_config::types::plugins::PluginsConfig;
18
19use crate::email::{load_email_secrets, EmailAccount, EmailCredentialStore};
20
21#[derive(Debug, Clone, serde::Deserialize)]
25struct WhatsappCredEntry {
26 #[serde(default)]
27 pub instance: Option<String>,
28 #[serde(default)]
29 pub session_dir: String,
30 #[serde(default)]
31 pub media_dir: String,
32 #[serde(default)]
33 pub allow_agents: Vec<String>,
34 #[serde(flatten)]
35 _rest: std::collections::BTreeMap<String, serde_yaml::Value>,
36}
37
38fn whatsapp_entries(plugins: &PluginsConfig) -> Vec<WhatsappCredEntry> {
39 let Some(value) = plugins.entries.get("whatsapp") else {
40 return Vec::new();
41 };
42 let seq: Vec<serde_yaml::Value> = match value {
43 serde_yaml::Value::Sequence(s) => s.clone(),
44 serde_yaml::Value::Mapping(_) => vec![value.clone()],
45 _ => return Vec::new(),
46 };
47 seq.into_iter()
48 .filter_map(|v| serde_yaml::from_value::<WhatsappCredEntry>(v).ok())
49 .collect()
50}
51
52#[derive(Debug, Clone, Default, serde::Deserialize)]
56struct TelegramAllowlistSlice {
57 #[serde(default)]
58 pub chat_ids: Vec<i64>,
59}
60
61#[derive(Debug, Clone, serde::Deserialize)]
62struct TelegramCredEntry {
63 pub token: String,
64 #[serde(default)]
65 pub instance: Option<String>,
66 #[serde(default)]
67 pub allow_agents: Vec<String>,
68 #[serde(default)]
69 pub allowlist: TelegramAllowlistSlice,
70 #[serde(flatten)]
71 _rest: std::collections::BTreeMap<String, serde_yaml::Value>,
72}
73
74#[derive(Debug, Clone, serde::Deserialize)]
75#[serde(untagged)]
76enum TelegramCredShape {
77 Single(TelegramCredEntry),
78 Many(Vec<TelegramCredEntry>),
79}
80
81impl TelegramCredShape {
82 fn into_vec(self) -> Vec<TelegramCredEntry> {
83 match self {
84 Self::Single(t) => vec![t],
85 Self::Many(v) => v,
86 }
87 }
88}
89
90fn telegram_entries(plugins: &PluginsConfig) -> Vec<TelegramCredEntry> {
91 let Some(value) = plugins.entries.get("telegram") else {
92 return Vec::new();
93 };
94 match serde_yaml::from_value::<TelegramCredShape>(value.clone()) {
95 Ok(shape) => shape.into_vec(),
96 Err(e) => {
97 tracing::warn!(
98 target: "credentials.wire",
99 error = %e,
100 "failed to deserialize cfg.plugins.entries[\"telegram\"]; falling back \
101 to no telegram accounts"
102 );
103 Vec::new()
104 }
105 }
106}
107
108#[derive(Debug, Clone, serde::Deserialize)]
115struct EmailCredAccount {
116 pub instance: String,
117 pub address: String,
118 #[serde(flatten)]
121 _rest: std::collections::BTreeMap<String, serde_yaml::Value>,
122}
123
124#[derive(Debug, Clone, serde::Deserialize)]
125struct EmailCredTenant {
126 #[serde(default)]
127 pub accounts: Vec<EmailCredAccount>,
128 #[serde(flatten)]
129 _rest: std::collections::BTreeMap<String, serde_yaml::Value>,
130}
131
132#[derive(Debug, Clone, serde::Deserialize)]
137#[serde(untagged)]
138enum EmailCredShape {
139 Single(EmailCredTenant),
140 Many(Vec<EmailCredTenant>),
141}
142
143impl EmailCredShape {
144 fn flat_accounts(self) -> Vec<EmailCredAccount> {
145 let tenants = match self {
146 Self::Single(t) => vec![t],
147 Self::Many(v) => v,
148 };
149 tenants.into_iter().flat_map(|t| t.accounts).collect()
150 }
151}
152
153fn email_accounts_from_entries(plugins: &PluginsConfig) -> Vec<EmailCredAccount> {
154 let Some(value) = plugins.entries.get("email") else {
155 return Vec::new();
156 };
157 match serde_yaml::from_value::<EmailCredShape>(value.clone()) {
158 Ok(shape) => shape.flat_accounts(),
159 Err(e) => {
160 tracing::warn!(
161 target: "credentials.wire",
162 error = %e,
163 "failed to deserialize cfg.plugins.entries[\"email\"] for credential wiring; \
164 falling back to no email accounts. Plugin will still receive raw entries \
165 via plugin.configure."
166 );
167 Vec::new()
168 }
169 }
170}
171use crate::error::BuildError;
172use crate::gauntlet::{
173 canonicalize_session_dirs, check_duplicate_paths, check_permissions, check_prefix_overlap,
174 format_errors, PathClaim,
175};
176use crate::generic_store::GenericCredentialStore;
177use crate::google::{GoogleAccount, GoogleCredentialStore};
178use crate::handle::{Channel, GOOGLE, TELEGRAM, WHATSAPP};
179use crate::resolver::{
180 AgentCredentialResolver, AgentCredentialsInput, CredentialStores, StrictLevel,
181};
182use crate::store::CredentialStore;
183use crate::telegram::{TelegramAccount, TelegramCredentialStore};
184use crate::whatsapp::{WhatsappAccount, WhatsappCredentialStore};
185
186pub struct CredentialsBundle {
189 pub(crate) stores: CredentialStores,
195 pub resolver: Arc<AgentCredentialResolver>,
196 pub breakers: Arc<crate::breaker::BreakerRegistry>,
200 pub warnings: Vec<String>,
201 pub stores_v2: DashMap<String, Arc<dyn GenericCredentialStore>>,
208}
209
210impl CredentialsBundle {
211 pub fn empty_for_testing() -> Self {
218 Self {
219 stores: CredentialStores::empty(),
220 resolver: Arc::new(AgentCredentialResolver::empty()),
221 breakers: Arc::new(crate::breaker::BreakerRegistry::default()),
222 warnings: Vec::new(),
223 stores_v2: DashMap::new(),
224 }
225 }
226
227 pub fn google_account(&self, id: &str) -> Option<&crate::google::GoogleAccount> {
235 self.stores.google.account(id)
236 }
237
238 pub fn google_account_for_agent(
243 &self,
244 agent_id: &str,
245 ) -> Option<&crate::google::GoogleAccount> {
246 self.stores.google.account_for_agent(agent_id)
247 }
248
249 pub fn whatsapp_account(&self, instance: &str) -> Option<&crate::whatsapp::WhatsappAccount> {
254 self.stores.whatsapp.account(instance)
255 }
256
257 pub fn telegram_account(&self, instance: &str) -> Option<&crate::telegram::TelegramAccount> {
259 self.stores.telegram.account(instance)
260 }
261
262 pub fn email_account(&self, instance: &str) -> Option<&crate::email::EmailAccount> {
266 self.stores.email.account(instance)
267 }
268
269 pub fn google_refresh_lock(
273 &self,
274 handle: &crate::handle::CredentialHandle,
275 ) -> Option<Arc<tokio::sync::Mutex<()>>> {
276 self.stores.google.refresh_lock(handle)
277 }
278
279 pub fn google_store(&self) -> Arc<crate::google::GoogleCredentialStore> {
285 Arc::clone(&self.stores.google)
286 }
287
288 pub fn email_store(&self) -> Arc<crate::email::EmailCredentialStore> {
290 Arc::clone(&self.stores.email)
291 }
292
293 pub fn whatsapp_store(&self) -> Arc<crate::whatsapp::WhatsappCredentialStore> {
295 Arc::clone(&self.stores.whatsapp)
296 }
297
298 pub fn telegram_store(&self) -> Arc<crate::telegram::TelegramCredentialStore> {
300 Arc::clone(&self.stores.telegram)
301 }
302
303 pub fn account_count(&self, channel: Channel) -> usize {
314 if let Some(store) = self.stores_v2.get(channel).map(|e| e.value().clone()) {
315 let list = tokio::task::block_in_place(|| {
316 tokio::runtime::Handle::current().block_on(store.list())
317 });
318 return list.len();
319 }
320 match channel {
321 WHATSAPP => self.stores.whatsapp.list().len(),
322 TELEGRAM => self.stores.telegram.list().len(),
323 GOOGLE => self.stores.google.list().len(),
324 c if c == crate::handle::EMAIL => self.stores.email.list().len(),
325 _ => 0,
326 }
327 }
328}
329
330impl std::fmt::Debug for CredentialsBundle {
331 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
332 f.debug_struct("CredentialsBundle")
333 .field("whatsapp_instances", &self.stores.whatsapp.list().len())
334 .field("telegram_instances", &self.stores.telegram.list().len())
335 .field("google_accounts", &self.stores.google.list().len())
336 .field("email_accounts", &self.stores.email.list().len())
337 .field("stores_v2_count", &self.stores_v2.len())
338 .field("resolver_version", &self.resolver.version())
339 .field("warnings", &self.warnings.len())
340 .finish()
341 }
342}
343
344pub fn load_google_auth(dir: &Path) -> Result<GoogleAuthConfig> {
348 let path = dir.join("plugins").join("google-auth.yaml");
349 if !path.exists() {
350 return Ok(GoogleAuthConfig::default());
351 }
352 let raw = std::fs::read_to_string(&path)
353 .with_context(|| format!("cannot read {}", path.display()))?;
354 let resolved = nexo_config::env::resolve_placeholders(&raw, "google-auth.yaml")?;
355 let file: GoogleAuthFile = serde_yaml::from_str(&resolved)
356 .with_context(|| format!("invalid config in {}", path.display()))?;
357 Ok(file.google_auth)
358}
359
360pub fn build_credentials(
371 agents: &[AgentConfig],
372 plugins: &PluginsConfig,
373 google: &GoogleAuthConfig,
374 secrets_dir: &Path,
375 strict: StrictLevel,
376) -> Result<CredentialsBundle, Vec<BuildError>> {
377 let whatsapp = whatsapp_entries(plugins);
379 let telegram_entries_vec = telegram_entries(plugins);
383 let email_accounts_decl = email_accounts_from_entries(plugins);
388 let mut errors: Vec<BuildError> = Vec::new();
389
390 let session_claims: Vec<PathClaim> = whatsapp
395 .iter()
396 .filter_map(|c| {
397 c.instance.as_ref().map(|ins| PathClaim {
398 channel: WHATSAPP,
399 instance: ins.clone(),
400 path: c.session_dir.clone().into(),
401 })
402 })
403 .collect();
404
405 let (canonical, canon_errs) = canonicalize_session_dirs(&session_claims);
406 errors.extend(canon_errs);
407 errors.extend(check_duplicate_paths(&canonical));
408 errors.extend(check_prefix_overlap(&canonical));
409
410 let mut perm_paths: Vec<(Channel, String, std::path::PathBuf)> = Vec::new();
413 for a in &google.accounts {
414 perm_paths.push((GOOGLE, a.id.clone(), a.client_id_path.clone()));
415 perm_paths.push((GOOGLE, a.id.clone(), a.client_secret_path.clone()));
416 if a.token_path.exists() {
417 perm_paths.push((GOOGLE, a.id.clone(), a.token_path.clone()));
418 }
419 }
420 let perm_errs = check_permissions(&perm_paths);
421 let insecure_count = perm_errs.len() as u64;
422 errors.extend(perm_errs);
423
424 crate::telemetry::set_insecure_paths(insecure_count);
425
426 let wa_accounts: Vec<WhatsappAccount> = whatsapp
430 .iter()
431 .filter_map(|c| {
432 let instance = c.instance.as_ref()?.clone();
433 Some(WhatsappAccount {
434 instance,
435 session_dir: c.session_dir.clone().into(),
436 media_dir: c.media_dir.clone().into(),
437 allow_agents: c.allow_agents.clone(),
438 })
439 })
440 .collect();
441 let tg_accounts: Vec<TelegramAccount> = telegram_entries_vec
442 .iter()
443 .filter_map(|c| {
444 let instance = c.instance.as_ref()?.clone();
445 Some(TelegramAccount {
446 instance,
447 token: c.token.clone(),
448 allow_agents: c.allow_agents.clone(),
449 allowed_chat_ids: c.allowlist.chat_ids.clone(),
450 })
451 })
452 .collect();
453 let mut goog_accounts: Vec<GoogleAccount> = google
454 .accounts
455 .iter()
456 .map(|a: &GoogleAccountConfig| GoogleAccount {
457 id: a.id.clone(),
458 agent_id: a.agent_id.clone(),
459 client_id_path: a.client_id_path.clone(),
460 client_secret_path: a.client_secret_path.clone(),
461 token_path: a.token_path.clone(),
462 scopes: a.scopes.clone(),
463 })
464 .collect();
465
466 let mut legacy_warnings: Vec<String> = Vec::new();
471 for agent in agents {
472 let Some(g) = &agent.google_auth else {
473 continue;
474 };
475 if goog_accounts.iter().any(|a| a.agent_id == agent.id) {
476 continue; }
478 let msg = format!(
479 "agent '{}': inline google_auth is deprecated — migrate to config/plugins/google-auth.yaml (id: {0})",
480 agent.id
481 );
482 match strict {
483 StrictLevel::Strict => {
484 errors.push(BuildError::LegacyInlineGoogleAuth {
485 agent: agent.id.clone(),
486 });
487 continue;
490 }
491 StrictLevel::Lenient => {
492 legacy_warnings.push(msg);
493 }
494 }
495 goog_accounts.push(GoogleAccount {
502 id: agent.id.clone(),
503 agent_id: agent.id.clone(),
504 client_id_path: std::path::PathBuf::from(format!("inline:{}", g.client_id)),
505 client_secret_path: std::path::PathBuf::from(format!("inline:{}", g.client_secret)),
506 token_path: std::path::PathBuf::from(&g.token_file),
507 scopes: g.scopes.clone(),
508 });
509 }
510
511 let (email_accounts, email_warnings, email_errors) = if email_accounts_decl.is_empty() {
515 (Vec::<EmailAccount>::new(), Vec::new(), Vec::new())
516 } else {
517 let declared: Vec<(String, String)> = email_accounts_decl
518 .iter()
519 .map(|a| (a.instance.clone(), a.address.clone()))
520 .collect();
521 load_email_secrets(secrets_dir, &declared)
522 };
523 errors.extend(email_errors);
524
525 if !email_accounts_decl.is_empty() {
529 let google_ids: std::collections::HashSet<&str> =
530 goog_accounts.iter().map(|a| a.id.as_str()).collect();
531 for acct in &email_accounts {
532 if let crate::email::EmailAuth::OAuth2Google {
533 google_account_id, ..
534 } = &acct.auth
535 {
536 if !google_ids.contains(google_account_id.as_str()) {
537 errors.push(BuildError::Credential {
538 channel: crate::handle::EMAIL,
539 instance: acct.instance.clone(),
540 source: crate::error::CredentialError::OrphanedGoogleRef {
541 account: acct.instance.clone(),
542 google_account_id: google_account_id.clone(),
543 },
544 });
545 }
546 }
547 }
548 let mut email_perm_paths: Vec<(Channel, String, std::path::PathBuf)> = Vec::new();
550 for acct in &email_accounts_decl {
551 let p = secrets_dir
552 .join("email")
553 .join(format!("{}.toml", acct.instance));
554 if p.exists() {
555 email_perm_paths.push((crate::handle::EMAIL, acct.instance.clone(), p));
556 }
557 }
558 let email_perm_errs = check_permissions(&email_perm_paths);
559 errors.extend(email_perm_errs);
560 }
561
562 let stores = CredentialStores {
563 whatsapp: Arc::new(WhatsappCredentialStore::new(wa_accounts.clone())),
564 telegram: Arc::new(TelegramCredentialStore::new(tg_accounts.clone())),
565 google: Arc::new(GoogleCredentialStore::new(goog_accounts.clone())),
566 email: Arc::new(EmailCredentialStore::new(email_accounts.clone())),
567 };
568
569 let wa_report = stores.whatsapp.validate();
571 let tg_report = stores.telegram.validate();
572 let g_report = stores.google.validate();
573 let e_report = stores.email.validate();
574 errors.extend(wa_report.errors);
575 errors.extend(tg_report.errors);
576 errors.extend(g_report.errors);
577 errors.extend(e_report.errors);
578 let mut warnings: Vec<String> = wa_report
579 .warnings
580 .into_iter()
581 .chain(tg_report.warnings)
582 .chain(g_report.warnings)
583 .chain(e_report.warnings)
584 .chain(email_warnings)
585 .chain(legacy_warnings)
586 .collect();
587
588 crate::telemetry::set_accounts_total(WHATSAPP, wa_accounts.len() as u64);
590 crate::telemetry::set_accounts_total(TELEGRAM, tg_accounts.len() as u64);
591 crate::telemetry::set_accounts_total(GOOGLE, goog_accounts.len() as u64);
592 crate::telemetry::set_accounts_total(crate::handle::EMAIL, email_accounts.len() as u64);
593
594 let inputs: Vec<AgentCredentialsInput> = agents.iter().map(agent_to_input).collect();
596
597 if !errors.is_empty() {
599 for e in &errors {
600 let kind = match e {
601 BuildError::DuplicatePath { .. } => "duplicate_path",
602 BuildError::PathPrefixOverlap { .. } => "prefix_overlap",
603 BuildError::MissingInstance { .. } => "missing_instance",
604 BuildError::AmbiguousOutbound { .. } => "ambiguous_outbound",
605 BuildError::AllowAgentsExcludes { .. } => "allow_agents_excludes",
606 BuildError::AsymmetricBinding { .. } => "asymmetric_binding",
607 BuildError::Credential { .. } => "credential_io",
608 BuildError::LegacyInlineGoogleAuth { .. } => "legacy_inline_google_auth",
609 };
610 crate::telemetry::inc_boot_error(kind);
611 }
612 return Err(errors);
613 }
614
615 match AgentCredentialResolver::build(&inputs, &stores, strict) {
617 Ok(resolver) => {
618 warnings.extend(resolver.warnings().iter().cloned());
619 for agent in agents {
621 for channel in [WHATSAPP, TELEGRAM, GOOGLE, crate::handle::EMAIL] {
622 let bound = resolver.resolve(&agent.id, channel).is_ok();
623 crate::telemetry::set_binding(channel, &agent.id, bound);
624 }
625 }
626 Ok(CredentialsBundle {
627 stores,
628 resolver: Arc::new(resolver),
629 breakers: Arc::new(crate::breaker::BreakerRegistry::default()),
630 warnings,
631 stores_v2: DashMap::new(),
632 })
633 }
634 Err(errs) => {
635 for e in &errs {
636 let kind = match e {
637 BuildError::MissingInstance { .. } => "missing_instance",
638 BuildError::AmbiguousOutbound { .. } => "ambiguous_outbound",
639 BuildError::AllowAgentsExcludes { .. } => "allow_agents_excludes",
640 BuildError::AsymmetricBinding { .. } => "asymmetric_binding",
641 BuildError::Credential { .. } => "credential_io",
642 _ => "other",
643 };
644 crate::telemetry::inc_boot_error(kind);
645 }
646 Err(errs)
647 }
648 }
649}
650
651fn agent_to_input(agent: &AgentConfig) -> AgentCredentialsInput {
652 let mut outbound: HashMap<Channel, String> = HashMap::new();
653 if let Some(v) = agent.credentials.whatsapp.clone() {
654 outbound.insert(WHATSAPP, v);
655 }
656 if let Some(v) = agent.credentials.telegram.clone() {
657 outbound.insert(TELEGRAM, v);
658 }
659 if let Some(v) = agent.credentials.google.clone() {
660 outbound.insert(GOOGLE, v);
661 }
662
663 let mut inbound: HashMap<Channel, Vec<String>> = HashMap::new();
664 for binding in &agent.inbound_bindings {
665 let channel: Channel = match binding.plugin.as_str() {
666 "whatsapp" => WHATSAPP,
667 "telegram" => TELEGRAM,
668 _ => continue,
669 };
670 if let Some(ins) = &binding.instance {
671 inbound.entry(channel).or_default().push(ins.clone());
672 }
673 }
674
675 let asymmetric_raw = agent.credentials.asymmetric_flags();
676 let mut asymmetric: HashMap<Channel, bool> = HashMap::new();
677 for (k, v) in asymmetric_raw {
678 let channel: Channel = match k.as_str() {
679 "whatsapp" => WHATSAPP,
680 "telegram" => TELEGRAM,
681 "google" => GOOGLE,
682 _ => continue,
683 };
684 asymmetric.insert(channel, v);
685 }
686
687 AgentCredentialsInput {
688 agent_id: agent.id.clone(),
689 outbound,
690 inbound,
691 asymmetric_allowed: asymmetric,
692 }
693}
694
695pub fn reload_resolver(
701 config_dir: &Path,
702 secrets_dir: &Path,
703 bundle: &CredentialsBundle,
704 strict: StrictLevel,
705) -> Result<ReloadOutcome, Vec<BuildError>> {
706 let cfg = match nexo_config::AppConfig::load(config_dir) {
707 Ok(c) => c,
708 Err(e) => {
709 return Err(vec![BuildError::Credential {
710 channel: crate::handle::WHATSAPP,
711 instance: "<config>".into(),
712 source: crate::error::CredentialError::Unreadable {
713 path: config_dir.to_path_buf(),
714 source: std::io::Error::other(e.to_string()),
715 },
716 }])
717 }
718 };
719 let google = match load_google_auth(config_dir) {
720 Ok(g) => g,
721 Err(e) => {
722 return Err(vec![BuildError::Credential {
723 channel: crate::handle::GOOGLE,
724 instance: "<google-auth.yaml>".into(),
725 source: crate::error::CredentialError::Unreadable {
726 path: config_dir.join("plugins/google-auth.yaml"),
727 source: std::io::Error::other(e.to_string()),
728 },
729 }])
730 }
731 };
732
733 let fresh = build_credentials(
738 &cfg.agents.agents,
739 &cfg.plugins,
740 &google,
741 secrets_dir,
742 strict,
743 )?;
744
745 let inputs: Vec<crate::resolver::AgentCredentialsInput> = cfg
747 .agents
748 .agents
749 .iter()
750 .map(crate::wire::agent_to_input_pub)
751 .collect();
752 bundle.resolver.rebuild(&inputs, &fresh.stores, strict)?;
753
754 use crate::store::CredentialStore;
755 Ok(ReloadOutcome {
756 accounts_wa: fresh.stores.whatsapp.list().len(),
757 accounts_tg: fresh.stores.telegram.list().len(),
758 accounts_google: fresh.stores.google.list().len(),
759 accounts_email: fresh.stores.email.list().len(),
760 warnings: fresh.warnings,
761 version: bundle.resolver.version(),
762 })
763}
764
765#[derive(Debug, serde::Serialize)]
766pub struct ReloadOutcome {
767 pub accounts_wa: usize,
768 pub accounts_tg: usize,
769 pub accounts_google: usize,
770 pub accounts_email: usize,
771 pub warnings: Vec<String>,
772 pub version: u64,
773}
774
775pub fn agent_to_input_pub(agent: &AgentConfig) -> crate::resolver::AgentCredentialsInput {
778 agent_to_input(agent)
779}
780
781pub fn print_report(bundle: &Result<CredentialsBundle, Vec<BuildError>>) -> i32 {
785 match bundle {
786 Ok(b) if b.warnings.is_empty() => {
787 eprintln!("credentials: OK");
788 0
789 }
790 Ok(b) => {
791 eprintln!("credentials: OK with {} warning(s):", b.warnings.len());
792 for w in &b.warnings {
793 eprintln!(" - {w}");
794 }
795 2
796 }
797 Err(errs) => {
798 eprintln!("credentials: FAILED with {} error(s):", errs.len());
799 eprint!("{}", format_errors(errs));
800 1
801 }
802 }
803}
804
805#[cfg(test)]
806mod tests {
807 use super::*;
808 use nexo_config::types::agents::{
809 AgentConfig, HeartbeatConfig, ModelConfig, OutboundAllowlistConfig,
810 };
811 use nexo_config::types::credentials::AgentCredentialsConfig;
812 use tempfile::TempDir;
813
814 fn minimal_agent(id: &str, wa_cred: Option<&str>) -> AgentConfig {
815 let mut creds = AgentCredentialsConfig::default();
816 if let Some(v) = wa_cred {
817 creds.whatsapp = Some(v.to_string());
818 }
819 AgentConfig {
820 id: id.into(),
821 model: ModelConfig {
822 provider: "stub".into(),
823 model: "stub".into(),
824 },
825 plugins: vec![],
826 heartbeat: HeartbeatConfig::default(),
827 config: Default::default(),
828 system_prompt: String::new(),
829 workspace: String::new(),
830 skills: vec![],
831 skills_dir: "./skills".into(),
832 transcripts_dir: String::new(),
833 dreaming: Default::default(),
834 workspace_git: Default::default(),
835 tool_rate_limits: None,
836 tool_args_validation: None,
837 extra_docs: vec![],
838 inbound_bindings: vec![],
839 allowed_tools: vec![],
840 sender_rate_limit: None,
841 allowed_delegates: vec![],
842 accept_delegates_from: vec![],
843 description: String::new(),
844 google_auth: None,
845 outbound_allowlist: OutboundAllowlistConfig::default(),
846 credentials: creds,
847 language: None,
848 locale_prompts: Default::default(),
849 skill_overrides: Default::default(),
850 link_understanding: serde_json::Value::Null,
851 web_search: serde_json::Value::Null,
852 pairing_policy: serde_json::Value::Null,
853 context_optimization: None,
854 dispatch_policy: Default::default(),
855 plan_mode: Default::default(),
856 remote_triggers: Vec::new(),
857 lsp: nexo_config::types::lsp::LspPolicy::default(),
858 config_tool: nexo_config::types::config_tool::ConfigToolPolicy::default(),
859 team: nexo_config::types::team::TeamPolicy::default(),
860 proactive: Default::default(),
861 repl: Default::default(),
862 auto_dream: None,
863 assistant_mode: None,
864 away_summary: None,
865 brief: None,
866 channels: None,
867 auto_approve: false,
868 extract_memories: None,
869 event_subscribers: Vec::new(),
870 tenant_id: None,
871 extensions_config: std::collections::BTreeMap::new(),
872 active: true,
873 }
874 }
875
876 fn wa_cfg(instance: Option<&str>, dir: &Path, allow: &[&str]) -> serde_yaml::Value {
880 let mut map = serde_yaml::Mapping::new();
881 map.insert(
882 serde_yaml::Value::String("enabled".into()),
883 serde_yaml::Value::Bool(true),
884 );
885 map.insert(
886 serde_yaml::Value::String("session_dir".into()),
887 serde_yaml::Value::String(dir.to_string_lossy().into_owned()),
888 );
889 map.insert(
890 serde_yaml::Value::String("media_dir".into()),
891 serde_yaml::Value::String(format!("{}/media", dir.display())),
892 );
893 if let Some(inst) = instance {
894 map.insert(
895 serde_yaml::Value::String("instance".into()),
896 serde_yaml::Value::String(inst.into()),
897 );
898 }
899 let allow_seq: Vec<serde_yaml::Value> = allow
900 .iter()
901 .map(|s| serde_yaml::Value::String((*s).into()))
902 .collect();
903 map.insert(
904 serde_yaml::Value::String("allow_agents".into()),
905 serde_yaml::Value::Sequence(allow_seq),
906 );
907 serde_yaml::Value::Mapping(map)
908 }
909
910 fn plugins_with_whatsapp(wa: Vec<serde_yaml::Value>) -> PluginsConfig {
911 let mut entries: std::collections::BTreeMap<String, serde_yaml::Value> =
912 std::collections::BTreeMap::new();
913 if !wa.is_empty() {
914 entries.insert("whatsapp".to_string(), serde_yaml::Value::Sequence(wa));
915 }
916 PluginsConfig {
917 entries,
918 ..PluginsConfig::default()
919 }
920 }
921
922 #[test]
923 fn happy_path_one_agent_one_instance() {
924 let dir = TempDir::new().unwrap();
925 let wa_dir = dir.path().join("ana");
926 std::fs::create_dir_all(&wa_dir).unwrap();
927 let wa = vec![wa_cfg(Some("personal"), &wa_dir, &["ana"])];
928 let agent = minimal_agent("ana", Some("personal"));
929 let bundle = build_credentials(
930 &[agent],
931 &plugins_with_whatsapp(wa),
932 &GoogleAuthConfig::default(),
933 std::path::Path::new("/nonexistent"),
934 StrictLevel::Strict,
935 )
936 .unwrap();
937 assert!(bundle.resolver.resolve("ana", WHATSAPP).is_ok());
938 }
939
940 #[test]
941 fn missing_instance_surfaces_with_available() {
942 let dir = TempDir::new().unwrap();
943 let wa_dir = dir.path().join("work");
944 std::fs::create_dir_all(&wa_dir).unwrap();
945 let wa = vec![wa_cfg(Some("work"), &wa_dir, &[])];
946 let agent = minimal_agent("ana", Some("personal"));
947 let err = build_credentials(
948 &[agent],
949 &plugins_with_whatsapp(wa),
950 &GoogleAuthConfig::default(),
951 std::path::Path::new("/nonexistent"),
952 StrictLevel::Lenient,
953 )
954 .unwrap_err();
955 assert!(err
956 .iter()
957 .any(|e| matches!(e, BuildError::MissingInstance { .. })));
958 }
959
960 #[test]
961 fn duplicate_session_dir_is_caught() {
962 let dir = TempDir::new().unwrap();
963 let wa_dir = dir.path().join("shared");
964 std::fs::create_dir_all(&wa_dir).unwrap();
965 let wa = vec![
966 wa_cfg(Some("a"), &wa_dir, &[]),
967 wa_cfg(Some("b"), &wa_dir, &[]),
968 ];
969 let agent = minimal_agent("ana", Some("a"));
970 let err = build_credentials(
971 &[agent],
972 &plugins_with_whatsapp(wa),
973 &GoogleAuthConfig::default(),
974 std::path::Path::new("/nonexistent"),
975 StrictLevel::Lenient,
976 )
977 .unwrap_err();
978 assert!(err
979 .iter()
980 .any(|e| matches!(e, BuildError::DuplicatePath { .. })));
981 }
982
983 #[test]
986 fn google_account_accessor_returns_known_id() {
987 let dir = TempDir::new().unwrap();
988 let wa_dir = dir.path().join("ana");
989 std::fs::create_dir_all(&wa_dir).unwrap();
990 let wa = vec![wa_cfg(Some("personal"), &wa_dir, &["ana"])];
991 let agent = minimal_agent("ana", Some("personal"));
992 let bundle = build_credentials(
993 &[agent],
994 &plugins_with_whatsapp(wa),
995 &GoogleAuthConfig::default(),
996 std::path::Path::new("/nonexistent"),
997 StrictLevel::Strict,
998 )
999 .unwrap();
1000 assert!(bundle.google_account("nonexistent").is_none());
1001 assert!(bundle.whatsapp_account("personal").is_some());
1002 assert!(bundle.whatsapp_account("ghost").is_none());
1003 assert!(bundle.telegram_account("anything").is_none());
1004 assert!(bundle.email_account("anything").is_none());
1005 }
1006
1007 #[tokio::test(flavor = "multi_thread")]
1011 async fn account_count_falls_back_to_typed_when_v2_empty() {
1012 let dir = TempDir::new().unwrap();
1013 let wa_dir = dir.path().join("ana");
1014 std::fs::create_dir_all(&wa_dir).unwrap();
1015 let wa = vec![wa_cfg(Some("personal"), &wa_dir, &["ana"])];
1016 let agent = minimal_agent("ana", Some("personal"));
1017 let bundle = build_credentials(
1018 &[agent],
1019 &plugins_with_whatsapp(wa),
1020 &GoogleAuthConfig::default(),
1021 std::path::Path::new("/nonexistent"),
1022 StrictLevel::Strict,
1023 )
1024 .unwrap();
1025 assert_eq!(bundle.account_count(WHATSAPP), 1);
1026 assert_eq!(bundle.account_count(TELEGRAM), 0);
1027 assert_eq!(bundle.account_count(GOOGLE), 0);
1028 }
1029
1030 #[test]
1034 fn build_credentials_initialises_empty_stores_v2() {
1035 let dir = TempDir::new().unwrap();
1036 let wa_dir = dir.path().join("ana");
1037 std::fs::create_dir_all(&wa_dir).unwrap();
1038 let wa = vec![wa_cfg(Some("personal"), &wa_dir, &["ana"])];
1039 let agent = minimal_agent("ana", Some("personal"));
1040 let bundle = build_credentials(
1041 &[agent],
1042 &plugins_with_whatsapp(wa),
1043 &GoogleAuthConfig::default(),
1044 std::path::Path::new("/nonexistent"),
1045 StrictLevel::Strict,
1046 )
1047 .unwrap();
1048 assert_eq!(
1049 bundle.stores_v2.len(),
1050 0,
1051 "Phase 93.7: stores_v2 is empty at boot — plugin contributions land via NexoPlugin::credential_store()",
1052 );
1053 }
1054}