Skip to main content

nexo_auth/
wire.rs

1//! Wire layer — turns `AppConfig` + `google-auth.yaml` into the
2//! credential stores and resolver the runtime needs. Kept in this
3//! crate (not `nexo-config`) so the config crate stays a pure data
4//! shape and never pulls `tokio` / `dashmap`.
5//!
6//! The entry point is [`build_credentials`], called from `main.rs`
7//! during boot. Operators can also call it via `--check-config`.
8
9use 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// Wave 7 — opaque whatsapp cfg slice. Same pattern as telegram /
22// email: minimal shape locally so nexo-auth stays decoupled from
23// `nexo-plugin-whatsapp`. Reads `cfg.plugins.entries["whatsapp"]`.
24#[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// Wave 6 — opaque telegram cfg slice. Same pattern as email Wave 5:
53// minimal shape locally so nexo-auth stays decoupled from
54// `nexo-plugin-telegram`. Reads `cfg.plugins.entries["telegram"]`.
55#[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// Wave 5 — opaque email cfg slice. nexo_config no longer carries
109// typed `EmailPluginConfig` (Phase 93 framework decoupling); we
110// deserialize a minimal account-only shape from
111// `cfg.plugins.entries["email"]` here. Plugin crate retains the
112// full typed config; this auth path only needs (instance, address)
113// for credential file loading.
114#[derive(Debug, Clone, serde::Deserialize)]
115struct EmailCredAccount {
116    pub instance: String,
117    pub address: String,
118    // Catch-all so plugin-specific account fields (imap/smtp/folders/
119    // filters/provider/bootstrap_limit) don't fail the deserialize.
120    #[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/// `cfg.plugins.entries["email"]` can be either a single map (legacy
133/// 0.4.x) or a sequence of tenant maps (0.5.0+). Same untagged shape
134/// the plugin's `EmailPluginShape` uses — duplicated here so nexo-auth
135/// stays decoupled from `nexo-plugin-email`.
136#[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
186/// Bundle returned by [`build_credentials`] — holds every store plus
187/// the resolver. `main.rs` hands this to plugins / tools.
188pub struct CredentialsBundle {
189    /// Phase 93.9.f — flipped to `pub(crate)` now that every
190    /// external caller migrated to the typed accessors
191    /// ([`Self::google_account`], [`Self::whatsapp_account`], …)
192    /// or to the v2 generic path ([`Self::stores_v2`]). External
193    /// code constructs the bundle via [`Self::for_testing`].
194    pub(crate) stores: CredentialStores,
195    pub resolver: Arc<AgentCredentialResolver>,
196    /// Per-`(channel, instance)` circuit breakers shared with plugin
197    /// tools. Created with default config; failure on one account
198    /// never trips another.
199    pub breakers: Arc<crate::breaker::BreakerRegistry>,
200    pub warnings: Vec<String>,
201    /// Phase 93.7 — opt-in plugin-contributed credential stores
202    /// keyed by `manifest.plugin.id`. Empty at boot; populated by
203    /// the init-loop after each plugin's `init(ctx)` succeeds via
204    /// `NexoPlugin::credential_store()`. Phase 93.9.a hides the
205    /// typed `stores` field; .b-.f migrate the remaining runtime
206    /// consumers to walk this map exclusively.
207    pub stores_v2: DashMap<String, Arc<dyn GenericCredentialStore>>,
208}
209
210impl CredentialsBundle {
211    /// Phase 93.9.f — public test-only constructor. External
212    /// crates (`crates/poller`, `crates/setup`, integration
213    /// tests) used to build the bundle directly by initialising
214    /// every field; now that [`Self::stores`] is `pub(crate)`,
215    /// this helper hands back an empty bundle suitable for fixtures
216    /// without exposing the typed `CredentialStores` shape.
217    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    /// Phase 93.9.b — typed-account accessor for the daemon-owned
228    /// Google channel. Plugin extraction is deferred; runtime
229    /// consumers (gmail / google-calendar pollers, setup wizard
230    /// services) use this method instead of touching
231    /// `bundle.stores.google.account(...)` directly. Phase 93.9.f
232    /// flips `stores` to `pub(crate)` once every external caller
233    /// has migrated to these accessors.
234    pub fn google_account(&self, id: &str) -> Option<&crate::google::GoogleAccount> {
235        self.stores.google.account(id)
236    }
237
238    /// Same shape as [`Self::google_account`] but keyed by
239    /// `agent_id` — Google enforces a 1:1 account-to-agent rule, so
240    /// gmail-poller jobs that only know the agent can still recover
241    /// the account.
242    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    /// Phase 93.9.b — daemon-owned WhatsApp typed accessor. The
250    /// subprocess plugin contributes a `RemoteCredentialStore` to
251    /// `stores_v2` but legacy in-process consumers (gauntlet,
252    /// observability surface) still need typed access.
253    pub fn whatsapp_account(&self, instance: &str) -> Option<&crate::whatsapp::WhatsappAccount> {
254        self.stores.whatsapp.account(instance)
255    }
256
257    /// Phase 93.9.b — daemon-owned Telegram typed accessor.
258    pub fn telegram_account(&self, instance: &str) -> Option<&crate::telegram::TelegramAccount> {
259        self.stores.telegram.account(instance)
260    }
261
262    /// Phase 93.9.b — daemon-owned Email typed accessor. Wizard +
263    /// poller migrators in 93.9.c-e replace direct
264    /// `bundle.stores.email.account()` calls with this method.
265    pub fn email_account(&self, instance: &str) -> Option<&crate::email::EmailAccount> {
266        self.stores.email.account(instance)
267    }
268
269    /// Phase 93.9.b — refresh-lock accessor for Google's
270    /// concurrent-refresh guard. Hides the typed
271    /// `stores.google.refresh_lock(...)` call.
272    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    /// Phase 93.9.f — typed `Arc<GoogleCredentialStore>` accessor
280    /// for plugin constructors that need the whole store (not a
281    /// single account). Hides the aggregate `CredentialStores`
282    /// shape from external code while preserving the typed-Arc
283    /// surface daemon-internal plugins still rely on.
284    pub fn google_store(&self) -> Arc<crate::google::GoogleCredentialStore> {
285        Arc::clone(&self.stores.google)
286    }
287
288    /// Phase 93.9.f — typed `Arc<EmailCredentialStore>` accessor.
289    pub fn email_store(&self) -> Arc<crate::email::EmailCredentialStore> {
290        Arc::clone(&self.stores.email)
291    }
292
293    /// Phase 93.9.f — typed `Arc<WhatsappCredentialStore>` accessor.
294    pub fn whatsapp_store(&self) -> Arc<crate::whatsapp::WhatsappCredentialStore> {
295        Arc::clone(&self.stores.whatsapp)
296    }
297
298    /// Phase 93.9.f — typed `Arc<TelegramCredentialStore>` accessor.
299    pub fn telegram_store(&self) -> Arc<crate::telegram::TelegramCredentialStore> {
300        Arc::clone(&self.stores.telegram)
301    }
302
303    /// Account count for `channel`. Reads from `stores_v2` first
304    /// (plugin-contributed `GenericCredentialStore`); falls back to
305    /// the internal typed `CredentialStores` so boot-time consumers
306    /// see correct counts before plugin init lands.
307    ///
308    /// `stores_v2.list()` is async; this accessor blocks via
309    /// [`tokio::task::block_in_place`]. Callers must be on a
310    /// multi-thread Tokio runtime. The daemon's `#[tokio::main]`
311    /// satisfies this; unit tests should use
312    /// `#[tokio::test(flavor = "multi_thread")]`.
313    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
344/// Load optional `google-auth.yaml` from `<dir>/plugins/google-auth.yaml`.
345/// Returns an empty config when the file is absent so the caller does
346/// not have to branch on `None`.
347pub 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
360/// Run the boot gauntlet and build stores + resolver. Every error is
361/// accumulated; a single `anyhow::Error` with a multi-line body is
362/// returned so operators see every misconfiguration at once.
363///
364/// Phase 93.5.b — takes `&PluginsConfig` (instead of three explicit
365/// per-channel parameters) so callers stop knowing the typed
366/// per-channel shape. Internal slicing pulls `whatsapp`, `telegram`,
367/// and `email` from the bundle. `GoogleAuthConfig` stays separate
368/// because it ships from a different YAML file
369/// (`plugins/google-auth.yaml`, loaded by [`load_google_auth`]).
370pub 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    // Wave 7 — whatsapp cfg via opaque entries.
378    let whatsapp = whatsapp_entries(plugins);
379    // Wave 6 — telegram cfg flows through opaque entries; same
380    // pattern as email Wave 5. nexo-config no longer carries typed
381    // telegram. Local minimal shape suffices for credential wiring.
382    let telegram_entries_vec = telegram_entries(plugins);
383    // Wave 5 — opaque email cfg. Flatten across all declared tenants
384    // (multi-tenant credential aggregation). The full plugin config
385    // stays inside `nexo-plugin-email`; we only need account-level
386    // (instance, address) here for secrets loading.
387    let email_accounts_decl = email_accounts_from_entries(plugins);
388    let mut errors: Vec<BuildError> = Vec::new();
389
390    // ── 1. Path claims (session_dir WA + credential files Google) ──
391    // Only labelled instances participate in the per-agent resolver.
392    // Unlabelled (instance=None) accounts keep using the legacy single
393    // outbound topic `plugin.outbound.whatsapp` as back-compat.
394    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    // Google file permission check (client_id / client_secret; token is
411    // optional — setup wizard writes it on first consent).
412    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    // ── 2. Build per-channel stores ──
427    // Skip unlabelled instances — they stay on the legacy outbound
428    // topic and do not appear in the resolver's binding surface.
429    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    // Migrate legacy inline `agents[].google_auth` into the store with
467    // a warning. The account id is the agent id — 1:1 per agent. In
468    // Strict mode the legacy form is an error: forces the
469    // move to google-auth.yaml.
470    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; // already declared explicitly in google-auth.yaml
477        }
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                // Skip the synthetic migration — we want the operator
488                // to fix the YAML, not run on a ghost entry.
489                continue;
490            }
491            StrictLevel::Lenient => {
492                legacy_warnings.push(msg);
493            }
494        }
495        // `google_auth` uses `client_id` / `client_secret` as literal
496        // strings, so emit synthetic in-memory paths. The gmail-poller
497        // legacy path uses files; this synthetic path is marked by the
498        // `inline:` prefix so the store knows to read the value
499        // directly rather than load from disk. (Older consumers
500        // ignore these accounts if the files do not exist.)
501        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    // ── Email accounts: load secrets/email/<inst>.toml for every
512    //    declared instance. Missing files / malformed TOML accumulate
513    //    into `errors` like every other gauntlet branch.
514    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    // Cross-store check: every `OAuth2Google` email account must point
526    // at an existing google_account_id. Strict in both modes — a typo
527    // here would silently fall through to a runtime NotFound.
528    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        // Permission check on TOML files (mode 0o600).
549        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    // Per-store self-check (missing scopes / empty token etc).
570    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    // Counter for dashboards.
589    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    // ── 3. Build resolver inputs from agent configs ──
595    let inputs: Vec<AgentCredentialsInput> = agents.iter().map(agent_to_input).collect();
596
597    // ── 4. If any path / store-level error was collected, stop now ──
598    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    // ── 5. Build resolver (adds MissingInstance / Ambiguous / …) ──
616    match AgentCredentialResolver::build(&inputs, &stores, strict) {
617        Ok(resolver) => {
618            warnings.extend(resolver.warnings().iter().cloned());
619            // Export 0/1 binding gauge for dashboards.
620            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
695/// Hot-reload the credential resolver in-place. Re-reads YAML from
696/// `config_dir`, runs the gauntlet, and atomically swaps the new
697/// bindings into the existing `Arc<AgentCredentialResolver>` held by
698/// every plugin tool. Returns the per-channel account counts and any
699/// warnings the rebuild surfaced.
700pub 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    // Build fresh stores so removed/added accounts surface immediately.
734    // Existing plugin instances keep using their old session_dir until
735    // the daemon restarts; this reload only affects the resolver +
736    // tool-side ACL, which is the V1 invariant we promised.
737    let fresh = build_credentials(
738        &cfg.agents.agents,
739        &cfg.plugins,
740        &google,
741        secrets_dir,
742        strict,
743    )?;
744
745    // Drive the resolver's atomic swap from the freshly-built bindings.
746    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
775/// Internal helper exposed for `reload_resolver`. Mirrors the private
776/// `agent_to_input` defined inside this module.
777pub fn agent_to_input_pub(agent: &AgentConfig) -> crate::resolver::AgentCredentialsInput {
778    agent_to_input(agent)
779}
780
781/// Convenience for `--check-config` / CLI: pretty-print either the
782/// warnings or the accumulated error list to stderr and return an
783/// exit code (0 = clean, 1 = errors, 2 = warnings-only).
784pub 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    /// Wave 7 — opaque whatsapp fixture built as a serde_yaml::Value
877    /// directly. nexo-auth no longer depends on `nexo_config::Whatsapp*`
878    /// typed structs.
879    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    /// Phase 93.9.b — `Bundle::google_account` accessor returns
984    /// the typed account when present and `None` when absent.
985    #[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    /// Phase 93.9.a — `account_count` walks `stores_v2` first
1008    /// and falls back to the internal typed stores for channels
1009    /// with no plugin contribution yet.
1010    #[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    /// Phase 93.7 — `build_credentials` initialises `stores_v2`
1031    /// as an empty `DashMap`. Plugin contributions land later in
1032    /// the init-loop; no daemon-side auto-wrap of typed stores.
1033    #[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}