1use std::collections::HashMap;
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::sync::Arc;
8
9use arc_swap::ArcSwap;
10
11use crate::email::EmailCredentialStore;
12use crate::error::{BuildError, ResolveError};
13use crate::google::GoogleCredentialStore;
14use crate::handle::{AgentId, Channel, CredentialHandle, EMAIL, GOOGLE, TELEGRAM, WHATSAPP};
15use crate::store::CredentialStore;
16use crate::telegram::TelegramCredentialStore;
17use crate::whatsapp::WhatsappCredentialStore;
18
19#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
20pub enum StrictLevel {
21 #[default]
25 Lenient,
26 Strict,
29}
30
31#[derive(Debug, Clone)]
36pub struct AgentCredentialsInput {
37 pub agent_id: String,
38 pub outbound: HashMap<Channel, String>,
40 pub inbound: HashMap<Channel, Vec<String>>,
43 pub asymmetric_allowed: HashMap<Channel, bool>,
45}
46
47#[derive(Clone)]
48pub struct CredentialStores {
49 pub whatsapp: Arc<WhatsappCredentialStore>,
50 pub telegram: Arc<TelegramCredentialStore>,
51 pub google: Arc<GoogleCredentialStore>,
52 pub email: Arc<EmailCredentialStore>,
53}
54
55impl CredentialStores {
56 pub fn empty() -> Self {
57 Self {
58 whatsapp: Arc::new(WhatsappCredentialStore::empty()),
59 telegram: Arc::new(TelegramCredentialStore::empty()),
60 google: Arc::new(GoogleCredentialStore::empty()),
61 email: Arc::new(EmailCredentialStore::empty()),
62 }
63 }
64}
65
66#[derive(Debug)]
71pub struct AgentCredentialResolver {
72 bindings: ArcSwap<HashMap<AgentId, HashMap<Channel, CredentialHandle>>>,
73 warnings: ArcSwap<Vec<String>>,
74 strict: ArcSwap<StrictLevel>,
75 version: AtomicU64,
76}
77
78impl AgentCredentialResolver {
79 pub fn empty() -> Self {
80 Self {
81 bindings: ArcSwap::from_pointee(HashMap::new()),
82 warnings: ArcSwap::from_pointee(Vec::new()),
83 strict: ArcSwap::from_pointee(StrictLevel::default()),
84 version: AtomicU64::new(0),
85 }
86 }
87
88 pub fn resolve(
92 &self,
93 agent_id: &str,
94 channel: Channel,
95 ) -> Result<CredentialHandle, ResolveError> {
96 self.bindings
97 .load()
98 .get(agent_id)
99 .and_then(|m| m.get(channel))
100 .cloned()
101 .ok_or(ResolveError::Unbound {
102 agent: agent_id.to_string(),
103 channel,
104 })
105 }
106
107 pub fn version(&self) -> u64 {
108 self.version.load(Ordering::Relaxed)
109 }
110
111 pub fn warnings(&self) -> Vec<String> {
112 self.warnings.load().as_ref().clone()
113 }
114
115 pub fn strict(&self) -> StrictLevel {
116 **self.strict.load()
117 }
118
119 pub fn replace_state(
124 &self,
125 new_bindings: HashMap<AgentId, HashMap<Channel, CredentialHandle>>,
126 new_warnings: Vec<String>,
127 new_strict: StrictLevel,
128 ) {
129 self.bindings.store(Arc::new(new_bindings));
130 self.warnings.store(Arc::new(new_warnings));
131 self.strict.store(Arc::new(new_strict));
132 self.version.fetch_add(1, Ordering::Relaxed);
133 }
134
135 pub fn build(
140 agents: &[AgentCredentialsInput],
141 stores: &CredentialStores,
142 strict: StrictLevel,
143 ) -> Result<Self, Vec<BuildError>> {
144 let mut errors: Vec<BuildError> = Vec::new();
145 let mut warnings: Vec<String> = Vec::new();
146 let mut bindings: HashMap<AgentId, HashMap<Channel, CredentialHandle>> = HashMap::new();
147
148 for agent in agents {
149 let mut per_channel: HashMap<Channel, CredentialHandle> = HashMap::new();
150 for channel in [WHATSAPP, TELEGRAM, GOOGLE, EMAIL] {
151 let outbound = agent.outbound.get(channel).cloned();
152 let inbound = agent.inbound.get(channel).cloned().unwrap_or_default();
153 let asymmetric_ok = *agent.asymmetric_allowed.get(channel).unwrap_or(&false);
154
155 let account_id = match outbound {
158 Some(a) => Some(a),
159 None => match inbound.len() {
160 0 => None,
161 1 => Some(inbound[0].clone()),
162 _ => {
163 errors.push(BuildError::AmbiguousOutbound {
164 channel,
165 agent: agent.agent_id.clone(),
166 instances: inbound.clone(),
167 });
168 continue;
169 }
170 },
171 };
172
173 let Some(account_id) = account_id else {
174 continue;
175 };
176
177 let available = store_list(stores, channel);
179 if !available.iter().any(|a| a == &account_id) {
180 errors.push(BuildError::MissingInstance {
181 channel,
182 agent: agent.agent_id.clone(),
183 account: account_id.clone(),
184 available,
185 });
186 continue;
187 }
188
189 let allow = store_allow_agents(stores, channel, &account_id);
191 if !allow.is_empty() && !allow.iter().any(|a| a == &agent.agent_id) {
192 errors.push(BuildError::AllowAgentsExcludes {
193 channel,
194 instance: account_id.clone(),
195 agent: agent.agent_id.clone(),
196 });
197 continue;
198 }
199
200 if !inbound.is_empty()
202 && !inbound.iter().any(|i| i == &account_id)
203 && !asymmetric_ok
204 {
205 let msg = BuildError::AsymmetricBinding {
206 channel,
207 agent: agent.agent_id.clone(),
208 outbound: account_id.clone(),
209 inbound: inbound.join(","),
210 };
211 match strict {
212 StrictLevel::Strict => errors.push(msg),
213 StrictLevel::Lenient => warnings.push(msg.to_string()),
214 }
215 }
216
217 match store_issue(stores, channel, &account_id, &agent.agent_id) {
220 Ok(handle) => {
221 per_channel.insert(channel, handle);
222 }
223 Err(source) => {
224 errors.push(BuildError::Credential {
225 channel,
226 instance: account_id.clone(),
227 source,
228 });
229 }
230 }
231 }
232 if !per_channel.is_empty() {
233 bindings.insert(Arc::from(agent.agent_id.as_str()), per_channel);
234 }
235 }
236
237 if !errors.is_empty() {
238 return Err(errors);
239 }
240
241 Ok(Self {
242 bindings: ArcSwap::from_pointee(bindings),
243 warnings: ArcSwap::from_pointee(warnings),
244 strict: ArcSwap::from_pointee(strict),
245 version: AtomicU64::new(1),
246 })
247 }
248
249 pub fn rebuild(
253 &self,
254 agents: &[AgentCredentialsInput],
255 stores: &CredentialStores,
256 strict: StrictLevel,
257 ) -> Result<(), Vec<BuildError>> {
258 let fresh = Self::build(agents, stores, strict)?;
259 let new_bindings = fresh.bindings.load_full();
261 let new_warnings = fresh.warnings.load_full();
262 let new_strict = **fresh.strict.load();
263 self.replace_state((*new_bindings).clone(), (*new_warnings).clone(), new_strict);
264 Ok(())
265 }
266
267 #[doc(hidden)]
270 pub fn from_raw(bindings: HashMap<AgentId, HashMap<Channel, CredentialHandle>>) -> Self {
271 Self {
272 bindings: ArcSwap::from_pointee(bindings),
273 warnings: ArcSwap::from_pointee(Vec::new()),
274 strict: ArcSwap::from_pointee(StrictLevel::default()),
275 version: AtomicU64::new(1),
276 }
277 }
278}
279
280fn store_list(stores: &CredentialStores, channel: Channel) -> Vec<String> {
281 match channel {
282 WHATSAPP => stores.whatsapp.list(),
283 TELEGRAM => stores.telegram.list(),
284 GOOGLE => stores.google.list(),
285 EMAIL => stores.email.list(),
286 _ => Vec::new(),
287 }
288}
289
290fn store_allow_agents(
291 stores: &CredentialStores,
292 channel: Channel,
293 account_id: &str,
294) -> Vec<String> {
295 match channel {
296 WHATSAPP => stores.whatsapp.allow_agents(account_id),
297 TELEGRAM => stores.telegram.allow_agents(account_id),
298 GOOGLE => stores.google.allow_agents(account_id),
299 EMAIL => stores.email.allow_agents(account_id),
300 _ => Vec::new(),
301 }
302}
303
304fn store_issue(
305 stores: &CredentialStores,
306 channel: Channel,
307 account_id: &str,
308 agent_id: &str,
309) -> Result<CredentialHandle, crate::error::CredentialError> {
310 match channel {
311 WHATSAPP => stores.whatsapp.issue(account_id, agent_id),
312 TELEGRAM => stores.telegram.issue(account_id, agent_id),
313 GOOGLE => stores.google.issue(account_id, agent_id),
314 EMAIL => stores.email.issue(account_id, agent_id),
315 _ => Err(crate::error::CredentialError::NotFound {
316 channel,
317 account: account_id.to_string(),
318 }),
319 }
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325 use crate::google::GoogleAccount;
326 use crate::telegram::TelegramAccount;
327 use crate::whatsapp::WhatsappAccount;
328 use std::path::PathBuf;
329
330 fn wa(instance: &str, allow: &[&str]) -> WhatsappAccount {
331 WhatsappAccount {
332 instance: instance.into(),
333 session_dir: PathBuf::from(format!("/tmp/wa-{instance}")),
334 media_dir: PathBuf::from(format!("/tmp/wa-{instance}/media")),
335 allow_agents: allow.iter().map(|s| s.to_string()).collect(),
336 }
337 }
338
339 fn tg(instance: &str, allow: &[&str]) -> TelegramAccount {
340 TelegramAccount {
341 instance: instance.into(),
342 token: "t".into(),
343 allow_agents: allow.iter().map(|s| s.to_string()).collect(),
344 allowed_chat_ids: vec![],
345 }
346 }
347
348 fn ga(id: &str, agent: &str) -> GoogleAccount {
349 GoogleAccount {
350 id: id.into(),
351 agent_id: agent.into(),
352 client_id_path: PathBuf::from("/tmp/cid"),
353 client_secret_path: PathBuf::from("/tmp/csec"),
354 token_path: PathBuf::from("/tmp/tok"),
355 scopes: vec![],
356 }
357 }
358
359 fn stores(
360 wa_list: Vec<WhatsappAccount>,
361 tg_list: Vec<TelegramAccount>,
362 g_list: Vec<GoogleAccount>,
363 ) -> CredentialStores {
364 CredentialStores {
365 whatsapp: Arc::new(WhatsappCredentialStore::new(wa_list)),
366 telegram: Arc::new(TelegramCredentialStore::new(tg_list)),
367 google: Arc::new(GoogleCredentialStore::new(g_list)),
368 email: Arc::new(EmailCredentialStore::empty()),
369 }
370 }
371
372 fn input(
373 id: &str,
374 out: &[(Channel, &str)],
375 inb: &[(Channel, &[&str])],
376 ) -> AgentCredentialsInput {
377 let mut outbound = HashMap::new();
378 for (c, a) in out {
379 outbound.insert(*c, a.to_string());
380 }
381 let mut inbound = HashMap::new();
382 for (c, ins) in inb {
383 inbound.insert(*c, ins.iter().map(|s| s.to_string()).collect());
384 }
385 AgentCredentialsInput {
386 agent_id: id.into(),
387 outbound,
388 inbound,
389 asymmetric_allowed: HashMap::new(),
390 }
391 }
392
393 #[test]
394 fn happy_path_binds_all_three_channels() {
395 let s = stores(
396 vec![wa("personal", &["ana"])],
397 vec![tg("ana_bot", &["ana"])],
398 vec![ga("ana@x", "ana")],
399 );
400 let inp = input(
401 "ana",
402 &[
403 (WHATSAPP, "personal"),
404 (TELEGRAM, "ana_bot"),
405 (GOOGLE, "ana@x"),
406 ],
407 &[],
408 );
409 let r = AgentCredentialResolver::build(&[inp], &s, StrictLevel::Strict).unwrap();
410 assert!(r.resolve("ana", WHATSAPP).is_ok());
411 assert!(r.resolve("ana", TELEGRAM).is_ok());
412 assert!(r.resolve("ana", GOOGLE).is_ok());
413 }
414
415 #[test]
416 fn missing_instance_rejected_with_available_list() {
417 let s = stores(vec![wa("work", &[])], vec![], vec![]);
418 let inp = input("ana", &[(WHATSAPP, "personal")], &[]);
419 let err = AgentCredentialResolver::build(&[inp], &s, StrictLevel::Lenient).unwrap_err();
420 assert_eq!(err.len(), 1);
421 match &err[0] {
422 BuildError::MissingInstance {
423 agent,
424 account,
425 available,
426 ..
427 } => {
428 assert_eq!(agent, "ana");
429 assert_eq!(account, "personal");
430 assert_eq!(available, &vec!["work".to_string()]);
431 }
432 other => panic!("unexpected: {other:?}"),
433 }
434 }
435
436 #[test]
437 fn ambiguous_inbound_rejected() {
438 let s = stores(vec![wa("a", &[]), wa("b", &[])], vec![], vec![]);
439 let inp = input("ana", &[], &[(WHATSAPP, &["a", "b"])]);
440 let err = AgentCredentialResolver::build(&[inp], &s, StrictLevel::Lenient).unwrap_err();
441 assert!(matches!(err[0], BuildError::AmbiguousOutbound { .. }));
442 }
443
444 #[test]
445 fn single_inbound_infers_outbound() {
446 let s = stores(vec![wa("personal", &[])], vec![], vec![]);
447 let inp = input("ana", &[], &[(WHATSAPP, &["personal"])]);
448 let r = AgentCredentialResolver::build(&[inp], &s, StrictLevel::Strict).unwrap();
449 assert_eq!(
450 r.resolve("ana", WHATSAPP).unwrap().account_id_raw(),
451 "personal"
452 );
453 }
454
455 #[test]
456 fn allow_agents_excludes_agent() {
457 let s = stores(vec![wa("work", &["kate"])], vec![], vec![]);
458 let inp = input("ana", &[(WHATSAPP, "work")], &[]);
459 let err = AgentCredentialResolver::build(&[inp], &s, StrictLevel::Lenient).unwrap_err();
460 assert!(matches!(err[0], BuildError::AllowAgentsExcludes { .. }));
461 }
462
463 #[test]
464 fn asymmetric_binding_warns_in_lenient() {
465 let s = stores(vec![wa("a", &[]), wa("b", &[])], vec![], vec![]);
466 let inp = input("ana", &[(WHATSAPP, "a")], &[(WHATSAPP, &["b"])]);
467 let r = AgentCredentialResolver::build(&[inp], &s, StrictLevel::Lenient).unwrap();
468 assert_eq!(r.warnings().len(), 1);
469 }
470
471 #[test]
472 fn asymmetric_binding_errors_in_strict() {
473 let s = stores(vec![wa("a", &[]), wa("b", &[])], vec![], vec![]);
474 let inp = input("ana", &[(WHATSAPP, "a")], &[(WHATSAPP, &["b"])]);
475 let err = AgentCredentialResolver::build(&[inp], &s, StrictLevel::Strict).unwrap_err();
476 assert!(matches!(err[0], BuildError::AsymmetricBinding { .. }));
477 }
478
479 #[test]
480 fn boot_reports_all_errors_in_one_pass() {
481 let s = stores(
482 vec![wa("work", &["kate"])],
483 vec![], vec![],
485 );
486 let inp = input("ana", &[(WHATSAPP, "work"), (TELEGRAM, "nope")], &[]);
487 let err = AgentCredentialResolver::build(&[inp], &s, StrictLevel::Lenient).unwrap_err();
488 assert_eq!(err.len(), 2, "both errors should surface: {err:#?}");
489 }
490
491 #[test]
492 fn google_1to1_rule_enforced_via_store_issue() {
493 let s = stores(vec![], vec![], vec![ga("ana@x", "ana")]);
494 let inp = input("kate", &[(GOOGLE, "ana@x")], &[]);
495 let err = AgentCredentialResolver::build(&[inp], &s, StrictLevel::Lenient).unwrap_err();
496 assert!(matches!(err[0], BuildError::AllowAgentsExcludes { .. }));
499 }
500
501 #[test]
502 fn no_bindings_when_config_empty() {
503 let s = stores(vec![], vec![], vec![]);
504 let r = AgentCredentialResolver::build(&[], &s, StrictLevel::Strict).unwrap();
505 assert!(r.resolve("ana", WHATSAPP).is_err());
506 }
507}