Skip to main content

mail4agent_messenger_shell/
resolve.rs

1//! The five places a session id used to come from, as sources of ONE resolver chain
2//! (`m4a_agent::ResolverChain`). Each source only names the session (and, when it knows it, the
3//! tier-1 local session id); identity, nick and credentials are the client's identity store's.
4//! The chain refuses two sources that name different sessions for one process.
5
6use std::path::{Path, PathBuf};
7
8use m4a_agent::{AgentError, ResolvedSession, SessionResolver};
9
10fn found(session_id: &str, local: &Option<String>, source: &'static str) -> Option<ResolvedSession> {
11    Some(ResolvedSession { session_id: session_id.to_string(), local_session: local.clone(), source })
12}
13
14macro_rules! with_local {
15    ($t:ident) => {
16        impl $t {
17            /// The tier-1 (local mail node) session id of the same session, when the caller knows it.
18            pub fn with_local(mut self, local_session: impl Into<String>) -> Self {
19                self.local = Some(local_session.into());
20                self
21            }
22        }
23    };
24}
25
26/// 1. A host that already assigned the id (`M4A_SESSION_ID`, the node CLI, a web host).
27pub struct ConfiguredResolver {
28    pub session_id: Option<String>,
29    local: Option<String>,
30}
31impl ConfiguredResolver {
32    pub fn new(session_id: Option<String>) -> Self {
33        Self { session_id: session_id.filter(|s| !s.is_empty()), local: None }
34    }
35}
36with_local!(ConfiguredResolver);
37impl SessionResolver for ConfiguredResolver {
38    fn name(&self) -> &'static str {
39        "configured"
40    }
41    fn resolve(&self) -> Result<Option<ResolvedSession>, AgentError> {
42        Ok(self.session_id.as_deref().and_then(|s| found(s, &self.local, "configured")))
43    }
44}
45
46/// 2. A Grok Bot agent: the agent id is the mail session id unless `agent=session` aliases say
47/// otherwise (the same rule the machine client applies).
48pub struct AgentsDirResolver {
49    agents_dir: PathBuf,
50    agent_id: String,
51    aliases: Vec<(String, String)>,
52    local: Option<String>,
53}
54impl AgentsDirResolver {
55    pub fn new(agents_dir: impl Into<PathBuf>, agent_id: impl Into<String>, session_ids_env: &str) -> Self {
56        Self { agents_dir: agents_dir.into(), agent_id: agent_id.into(), aliases: crate::machine::parse_session_ids(session_ids_env), local: None }
57    }
58}
59with_local!(AgentsDirResolver);
60impl SessionResolver for AgentsDirResolver {
61    fn name(&self) -> &'static str {
62        "agents-dir"
63    }
64    fn resolve(&self) -> Result<Option<ResolvedSession>, AgentError> {
65        let mut all = match crate::machine::load_agents_dir(&self.agents_dir) {
66            Ok(a) => a,
67            Err(_) => return Ok(None),
68        };
69        all.retain(|s| s.agent_id.as_deref() == Some(self.agent_id.as_str()));
70        crate::machine::apply_session_ids(&mut all, &self.aliases);
71        Ok(all.first().and_then(|s| found(&s.session_id, &self.local, "agents-dir")))
72    }
73}
74
75/// 3. A Grok CLI session: the row of `active_sessions.json` whose working directory is `cwd`.
76#[cfg(feature = "wake-grok")]
77pub struct GrokActiveResolver {
78    index_text: String,
79    sessions_root: PathBuf,
80    cwd: String,
81    local: Option<String>,
82}
83#[cfg(feature = "wake-grok")]
84impl GrokActiveResolver {
85    pub fn new(index_text: impl Into<String>, sessions_root: impl Into<PathBuf>, cwd: impl Into<String>) -> Self {
86        Self { index_text: index_text.into(), sessions_root: sessions_root.into(), cwd: cwd.into(), local: None }
87    }
88}
89#[cfg(feature = "wake-grok")]
90with_local!(GrokActiveResolver);
91#[cfg(feature = "wake-grok")]
92impl SessionResolver for GrokActiveResolver {
93    fn name(&self) -> &'static str {
94        "grok-active"
95    }
96    fn resolve(&self) -> Result<Option<ResolvedSession>, AgentError> {
97        let heard = crate::grok_listen::hear(&self.index_text, &self.sessions_root).map_err(|e| AgentError::Identity(e.to_string()))?;
98        let mut rows = heard.iter().filter(|h| h.cwd == self.cwd);
99        let first = rows.next();
100        if rows.next().is_some() {
101            return Err(AgentError::Identity("two live Grok sessions share this working directory".into()));
102        }
103        Ok(first.and_then(|h| found(&h.session_id, &self.local, "grok-active")))
104    }
105}
106
107/// 4. A provider session that registered itself (hook or `m4a-inbox register`).
108pub struct ProviderRecordResolver {
109    store_root: PathBuf,
110    provider: String,
111    vendor_session: String,
112    local: Option<String>,
113}
114impl ProviderRecordResolver {
115    pub fn new(store_root: &Path, provider: &str, vendor_session: &str) -> Self {
116        Self { store_root: store_root.to_path_buf(), provider: provider.into(), vendor_session: vendor_session.into(), local: None }
117    }
118}
119with_local!(ProviderRecordResolver);
120impl SessionResolver for ProviderRecordResolver {
121    fn name(&self) -> &'static str {
122        "provider-record"
123    }
124    fn resolve(&self) -> Result<Option<ResolvedSession>, AgentError> {
125        let live = crate::provider::registry::live_sessions(&self.store_root);
126        Ok(live.iter().find(|s| s.kind.provider.id() == self.provider && s.session_id == self.vendor_session).and_then(|s| found(&s.session_id, &self.local, "provider-record")))
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use m4a_agent::{BackendKind, IdentityStore, MemoryVault, ResolverChain};
134    use std::sync::Arc;
135
136    #[test]
137    fn the_sources_agree_through_one_chain_and_link_the_local_session() {
138        let root = tempfile::tempdir().unwrap();
139        let agents = root.path().join("agents");
140        std::fs::create_dir_all(agents.join("bot-7")).unwrap();
141        std::fs::write(agents.join("bot-7/profile.json"), r#"{"name":"Alice"}"#).unwrap();
142        // An alias moves the mail session id off the agent id, as the machine client does.
143        let chain = ResolverChain::new()
144            .with(ConfiguredResolver::new(None))
145            .with(AgentsDirResolver::new(&agents, "bot-7", "bot-7=mail-7").with_local("local-7"))
146            .with(ConfiguredResolver::new(Some("mail-7".into())));
147        let r = chain.resolve().unwrap();
148        assert_eq!((r.session_id.as_str(), r.local_session.as_deref(), r.source), ("mail-7", Some("local-7"), "agents-dir"));
149        let ids = IdentityStore::new(Arc::new(MemoryVault::new()));
150        let id = chain.identity(&ids, BackendKind::Server, "https://p.example").unwrap();
151        assert_eq!((id.session_id.as_str(), id.local_session.as_deref()), ("mail-7", Some("local-7")));
152        // Unknown agent: the source does not know, the others decide.
153        assert!(ResolverChain::new().with(AgentsDirResolver::new(&agents, "nobody", "")).resolve().is_err());
154        // A second source naming ANOTHER session for the same process is refused.
155        let clash = ResolverChain::new().with(AgentsDirResolver::new(&agents, "bot-7", "")).with(ConfiguredResolver::new(Some("mail-7".into())));
156        assert!(clash.resolve().is_err(), "agent id bot-7 vs configured mail-7");
157    }
158}