1use std::fs::File;
70use std::collections::{HashMap, HashSet};
71use std::path::{Path, PathBuf};
72use std::sync::Arc;
73use zeroize::Zeroizing;
74
75use crate::ipc::{SendListener, SendStream};
76pub(crate) use crate::local_bus::{ensure_server_name, LocalBus};
77use crate::{
78 clip_public, register_session, DeviceId, OpenedStore,
79 SessionConfig, SessionWake, ShellError, HOMESERVER_URL_ENV, STORE_ROOT_ENV,
80};
81
82pub const SESSIONS_DIR_ENV: &str = "M4A_SESSIONS_DIR";
87
88pub const AGENTS_DIR_ENV: &str = "M4A_AGENTS_DIR";
92
93pub const DEFAULT_AGENTS_DIR: &str = "agent-data/agents";
97
98pub(crate) fn under_home(rel: &str) -> PathBuf {
100 match std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")) {
101 Ok(h) if !h.is_empty() => PathBuf::from(h).join(rel),
102 _ => PathBuf::from(rel),
103 }
104}
105
106pub const LOCAL_BUS_ENV: &str = "M4A_LOCAL_BUS";
112pub const AGENT_RESCAN_SECS_ENV: &str = "M4A_AGENT_RESCAN_SECS";
113
114pub struct HostSession {
119 pub bot_name: String,
121 pub session_id: String,
124 pub agent_id: Option<String>,
127 pub routine_url: Option<String>,
129 pub routine_bearer: Option<String>,
131 pub invite: Option<String>,
134 pub routine_file: Option<PathBuf>,
137 pub tier: m4a_agent::BackendKind,
139 pub store_root: Option<PathBuf>,
142}
143
144impl HostSession {
145 pub fn new(bot_name: impl Into<String>, session_id: impl Into<String>) -> Self {
147 Self {
148 bot_name: bot_name.into(),
149 session_id: session_id.into(),
150 agent_id: None,
151 routine_url: None,
152 routine_bearer: None,
153 invite: None,
154 routine_file: None,
155 tier: m4a_agent::BackendKind::Server,
156 store_root: None,
157 }
158 }
159
160 pub fn with_routine_file(mut self, path: impl Into<PathBuf>) -> Self {
162 self.routine_file = Some(path.into());
163 self
164 }
165
166 pub(crate) fn config(&self, url: &str, store_root: &Path) -> Result<SessionConfig, ShellError> {
167 Ok(SessionConfig::new_identity(url, self.tier, &self.session_id, store_root, self.invite.clone())?.with_nick_request(&self.bot_name))
168 }
169
170 pub fn with_invite(mut self, invite: impl Into<String>) -> Self {
172 self.invite = Some(invite.into());
173 self
174 }
175
176 pub fn with_tier(mut self, tier: m4a_agent::BackendKind) -> Self {
178 self.tier = tier;
179 self
180 }
181
182 pub fn with_routine(mut self, url: impl Into<String>, bearer: Option<String>) -> Self {
184 self.routine_url = Some(url.into());
185 self.routine_bearer = bearer.filter(|token| !token.is_empty());
186 self
187 }
188
189}
190
191impl std::fmt::Debug for HostSession {
192 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
193 f.debug_struct("HostSession")
194 .field("bot_name", &self.bot_name)
195 .field("session_id", &self.session_id)
196 .field("agent_id", &self.agent_id)
197 .field("routine_url", &self.routine_url.as_ref().map(|_| "[set]"))
198 .field(
199 "routine_bearer",
200 &self.routine_bearer.as_ref().map(|_| "[redacted]"),
201 )
202 .finish()
203 }
204}
205
206#[derive(serde::Deserialize)]
207#[serde(deny_unknown_fields)]
208struct SessionFile {
209 bot_name: String,
210 session_id: String,
211 #[serde(default)]
213 agent_id: Option<String>,
214 #[serde(default)]
217 routine_file: Option<String>,
218 #[serde(default)]
220 tier: Option<String>,
221 #[serde(default)]
223 store_root: Option<String>,
224}
225
226pub fn load_session_records(dir: &Path) -> Result<Vec<HostSession>, ShellError> {
230 if !dir.is_dir() {
231 return Err(ShellError::SessionList(
232 "session directory is missing".to_string(),
233 ));
234 }
235 let mut paths = Vec::new();
236 for entry in std::fs::read_dir(dir)? {
237 let entry = entry?;
238 let path = entry.path();
239 if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
240 continue;
241 }
242 paths.push(path);
243 }
244 paths.sort();
245 if paths.is_empty() {
246 return Err(ShellError::SessionList(
247 "session directory has no records".to_string(),
248 ));
249 }
250 let mut out = Vec::with_capacity(paths.len());
251 for path in paths {
252 let text = std::fs::read_to_string(&path)
253 .map_err(|err| ShellError::SessionList(clip_public(err.to_string())))?;
254 let file: SessionFile = serde_json::from_str(&text)
255 .map_err(|err| ShellError::SessionList(clip_public(err.to_string())))?;
256 let mut session = HostSession::new(file.bot_name, file.session_id);
257 session.agent_id = file
258 .agent_id
259 .map(|id| id.trim().to_string())
260 .filter(|id| !id.is_empty());
261 session.tier = match file.tier.or_else(|| std::env::var("M4A_TIER").ok()).as_deref().map(str::trim) {
262 None | Some("") | Some("server") => m4a_agent::BackendKind::Server,
263 Some("matrix") => m4a_agent::BackendKind::Matrix,
264 Some(other) => return Err(ShellError::SessionList(format!("tier {other:?} is not server or matrix"))),
265 };
266 session.routine_file = file.routine_file.map(|p| PathBuf::from(p.trim())).filter(|p| !p.as_os_str().is_empty());
267 session.store_root = file.store_root.map(|p| PathBuf::from(p.trim())).filter(|p| !p.as_os_str().is_empty());
268 out.push(session);
269 }
270 Ok(out)
271}
272
273#[derive(serde::Deserialize)]
274struct AgentProfileFile {
275 name: String,
276}
277
278pub fn load_agents_dir(dir: &Path) -> Result<Vec<HostSession>, ShellError> {
282 if !dir.is_dir() {
283 return Err(ShellError::SessionList(
284 "agents directory is missing".to_string(),
285 ));
286 }
287 let mut ids = Vec::new();
288 for entry in std::fs::read_dir(dir)? {
289 let entry = entry?;
290 if !entry.file_type()?.is_dir() {
291 continue;
292 }
293 let name = entry.file_name();
294 let Some(id) = name.to_str() else {
295 continue;
296 };
297 if id.is_empty() || id.starts_with('.') {
298 continue;
299 }
300 ids.push(id.to_string());
301 }
302 ids.sort();
303 if ids.is_empty() {
304 return Err(ShellError::SessionList(
305 "agents directory has no agents".to_string(),
306 ));
307 }
308 let mut out = Vec::with_capacity(ids.len());
309 for id in ids {
310 let profile_path = dir.join(&id).join("profile.json");
311 if !profile_path.is_file() {
312 continue;
313 }
314 let text = std::fs::read_to_string(&profile_path)
315 .map_err(|err| ShellError::SessionList(clip_public(err.to_string())))?;
316 let profile: AgentProfileFile = serde_json::from_str(&text)
317 .map_err(|err| ShellError::SessionList(clip_public(err.to_string())))?;
318 let bot_name = profile.name.trim().to_string();
319 if bot_name.is_empty() {
320 continue;
321 }
322 let mut session = HostSession::new(bot_name, id.clone());
323 session.agent_id = Some(id);
324 out.push(session);
325 }
326 if out.is_empty() {
327 return Err(ShellError::SessionList(
328 "agents directory has no profiles".to_string(),
329 ));
330 }
331 Ok(out)
332}
333
334fn explicit_agents_dir(mut get: impl FnMut(&str) -> Option<String>) -> Option<PathBuf> {
339 let path = PathBuf::from(get(AGENTS_DIR_ENV)?);
340 path.is_dir().then_some(path)
341}
342
343fn resolve_agents_dir(mut get: impl FnMut(&str) -> Option<String>) -> Option<PathBuf> {
347 if let Some(path) = get(AGENTS_DIR_ENV).filter(|value| !value.is_empty()) {
348 let path = PathBuf::from(path);
349 if path.is_dir() {
350 return Some(path);
351 }
352 return None;
353 }
354 let default = under_home(DEFAULT_AGENTS_DIR);
355 if default.is_dir() {
356 Some(default)
357 } else {
358 None
359 }
360}
361
362fn accounts_base_of(store_root: &Path) -> Option<PathBuf> {
371 let parent = store_root.parent()?;
372 (parent.file_name()? == "accounts").then(|| parent.parent().map(Path::to_path_buf)).flatten()
373}
374
375fn load_web_sessions(
376 dir: &Path,
377 mut get: impl FnMut(&str) -> Option<String>,
378) -> Result<Vec<HostSession>, ShellError> {
379 let mut sessions = load_session_records(dir)?;
380 let base = get(crate::ACCOUNTS_ROOT_ENV).or_else(|| get(STORE_ROOT_ENV)).map(PathBuf::from);
382 if let Some(base) = base {
383 for account in account_sessions(&base) {
384 if !sessions.iter().any(|s| s.session_id == account.session_id || s.bot_name.eq_ignore_ascii_case(&account.bot_name)) {
385 sessions.push(account);
386 }
387 }
388 }
389 let mut sessions = drop_skipped(sessions, &skip_nicks(&mut get));
390 attach_routines_from_env(&mut sessions, &mut get)?;
391 Ok(sessions)
392}
393
394fn load_web_agents(
396 dir: &Path,
397 mut get: impl FnMut(&str) -> Option<String>,
398) -> Result<Vec<HostSession>, ShellError> {
399 let sessions = load_agents_dir(dir)?;
400 let mut sessions = drop_skipped(sessions, &skip_nicks(&mut get));
401 apply_session_ids(
402 &mut sessions,
403 &parse_session_ids(get(SESSION_IDS_ENV).as_deref().unwrap_or("")),
404 );
405 attach_routines_from_env(&mut sessions, &mut get)?;
406 Ok(sessions)
407}
408
409fn attach_routines_from_env(
410 sessions: &mut [HostSession],
411 get: &mut impl FnMut(&str) -> Option<String>,
412) -> Result<(), ShellError> {
413 let store_root = get(STORE_ROOT_ENV).map(PathBuf::from);
414 let Some(path) = get(GATEWAY_FILE_ENV).filter(|value| !value.is_empty()) else {
415 return Ok(());
416 };
417 let token = get(GATEWAY_TOKEN_ENV).filter(|value| !value.is_empty());
418 attach_webhook_routines(
419 sessions,
420 Path::new(&path),
421 token.as_deref(),
422 store_root.as_deref(),
423 )?;
424 Ok(())
425}
426
427fn unopened_agent_sessions(
431 found: Vec<HostSession>,
432 skip: &[String],
433 aliases: &[(String, String)],
434 store_root: &Path,
435 held: &[(PathBuf, Option<String>)],
436) -> Vec<(HostSession, String)> {
437 let mut sessions = drop_skipped(found, skip);
438 apply_session_ids(&mut sessions, aliases);
439 sessions
440 .into_iter()
441 .filter_map(|session| {
442 let nick = routine_name_for(&session)?;
443 let dir = crate::session_store_dir(store_root, &session.session_id);
444 let taken = held.iter().any(|(held_dir, held_nick)| {
445 *held_dir == dir
446 || held_nick
447 .as_deref()
448 .is_some_and(|held| held.eq_ignore_ascii_case(&nick))
449 });
450 (!taken).then_some((session, nick))
451 })
452 .collect()
453}
454
455pub const SESSION_IDS_ENV: &str = "M4A_SESSION_IDS";
460
461pub(crate) fn parse_session_ids(raw: &str) -> Vec<(String, String)> {
462 raw.split(',')
463 .filter_map(|pair| {
464 let (agent, session) = pair.split_once('=')?;
465 let (agent, session) = (agent.trim(), session.trim());
466 (!agent.is_empty() && !session.is_empty())
467 .then(|| (agent.to_string(), session.to_string()))
468 })
469 .collect()
470}
471
472pub(crate) fn apply_session_ids(sessions: &mut [HostSession], aliases: &[(String, String)]) {
473 for session in sessions.iter_mut() {
474 let Some(agent_id) = session.agent_id.as_deref() else {
475 continue;
476 };
477 if let Some((_, alias)) = aliases.iter().find(|(agent, _)| agent == agent_id) {
478 session.session_id = alias.clone();
479 }
480 }
481}
482
483pub const SKIP_NICKS_ENV: &str = "M4A_SKIP_NICKS";
487
488fn skip_nicks(get: &mut impl FnMut(&str) -> Option<String>) -> Vec<String> {
489 parse_skip_nicks(get(SKIP_NICKS_ENV).as_deref().unwrap_or(""))
490}
491
492fn parse_skip_nicks(raw: &str) -> Vec<String> {
493 raw.split(',')
494 .map(|item| item.trim().to_ascii_lowercase())
495 .filter(|item| !item.is_empty())
496 .collect()
497}
498
499fn drop_skipped(sessions: Vec<HostSession>, skip: &[String]) -> Vec<HostSession> {
500 if skip.is_empty() {
501 return sessions;
502 }
503 sessions
504 .into_iter()
505 .filter(|session| match routine_name_for(session) {
506 Some(nick) => !skip.iter().any(|item| item.eq_ignore_ascii_case(&nick)),
507 None => true,
508 })
509 .collect()
510}
511
512#[derive(Debug, Clone, PartialEq, Eq)]
515pub enum WakeStatus {
516 Ready,
518 AwaitingBackend,
522 Failed(String),
524}
525
526#[derive(Debug, Clone, PartialEq, Eq)]
528pub struct RoutineReport {
529 pub agent_id: String,
531 pub nick: String,
533 pub folder_id: String,
536 pub status: WakeStatus,
538}
539
540#[derive(Debug, Clone, Default)]
543pub struct WakeOptions {
544 pub skip_nicks: Vec<String>,
546 pub store_root: Option<PathBuf>,
548 pub profile_note: bool,
551 pub session_ids: Vec<(String, String)>,
553}
554
555impl WakeOptions {
556 pub fn from_lookup(mut get: impl FnMut(&str) -> Option<String>) -> Self {
558 Self {
559 skip_nicks: skip_nicks(&mut get),
560 store_root: get(STORE_ROOT_ENV).map(PathBuf::from),
561 profile_note: get(PROFILE_NOTE_ENV).as_deref() == Some("1"),
562 session_ids: parse_session_ids(get(SESSION_IDS_ENV).as_deref().unwrap_or("")),
563 }
564 }
565}
566
567pub fn ensure_agent_webhook_routines(
575 agents_dir: &Path,
576 gateway_file: &Path,
577 token_override: Option<&str>,
578 options: &WakeOptions,
579) -> Result<Vec<RoutineReport>, ShellError> {
580 let store_root = options.store_root.as_deref();
581 let mut sessions = drop_skipped(load_agents_dir(agents_dir)?, &options.skip_nicks);
582 apply_session_ids(&mut sessions, &options.session_ids);
583 let Some(gate) = open_gateway(gateway_file, token_override)? else {
584 return Err(ShellError::Gateway("gateway file is missing".to_string()));
585 };
586 let mut reports = Vec::new();
587 for session in &sessions {
588 let Some(agent_id) = session.agent_id.as_deref().filter(|id| !id.is_empty()) else {
589 continue;
590 };
591 let Some(nick) = routine_name_for(session) else {
592 continue;
593 };
594 let outcome = ensure_wake(&gate, agent_id, &nick);
595 let folder_id = crate::nick::routine_folder_id(&nick).unwrap_or_default();
596 if let (WakeOutcome::Ready { url, key }, Some(root)) = (&outcome, store_root) {
597 save_wake(root, &session.session_id, &folder_id, url, key);
598 }
599 log_outcome(&nick, &folder_id, &outcome);
600 if options.profile_note && matches!(outcome, WakeOutcome::AwaitingBackend) {
601 note_profile(&gate, agents_dir, agent_id, &nick);
602 }
603 reports.push(RoutineReport {
604 agent_id: agent_id.to_string(),
605 nick,
606 folder_id,
607 status: outcome.status(),
608 });
609 }
610 Ok(reports)
611}
612
613pub fn ensure_agent_webhook_routines_from_env() -> Result<Vec<RoutineReport>, ShellError> {
617 let mut get = |key: &str| std::env::var(key).ok().filter(|v| !v.is_empty());
618 let agents_dir = resolve_agents_dir(&mut get)
619 .ok_or_else(|| ShellError::SessionList("agents directory is missing".to_string()))?;
620 let gateway = get(GATEWAY_FILE_ENV).unwrap_or_else(|| DEFAULT_GATEWAY_FILE.to_string());
621 let token = get(GATEWAY_TOKEN_ENV);
622 let options = WakeOptions::from_lookup(&mut get);
623 ensure_agent_webhook_routines(&agents_dir, Path::new(&gateway), token.as_deref(), &options)
624}
625
626pub const PROFILE_NOTE_ENV: &str = "M4A_BOOTSTRAP_PROFILE_NOTE";
632
633const NOTE_OPEN: &str = "<!-- mail4agent:wake -->";
634const NOTE_CLOSE: &str = "<!-- /mail4agent:wake -->";
635const DESCRIPTION_MAX: usize = 20_000;
637
638fn wake_note(routine: &str) -> String {
641 format!(
642 "{NOTE_OPEN}\nmail4agent wake: keep exactly one routine named \"{routine}\" with a webhook trigger, enabled. If it is missing, create it with UpdateRoutine: name \"{routine}\", trigger webhook, prompt \"A mail4agent room message woke this routine. The webhook JSON has body, from, event_id, and nick. Read that message.\" Do not rename it and do not create a second one.\n{NOTE_CLOSE}"
643 )
644}
645
646fn with_wake_note(description: &str, routine: &str) -> Option<String> {
650 let note = wake_note(routine);
651 if description.contains(¬e) {
652 return None;
653 }
654 if let (Some(start), Some(end)) = (description.find(NOTE_OPEN), description.find(NOTE_CLOSE)) {
655 if end > start {
656 let mut out = String::with_capacity(description.len() + note.len());
657 out.push_str(&description[..start]);
658 out.push_str(¬e);
659 out.push_str(&description[end + NOTE_CLOSE.len()..]);
660 return Some(out);
661 }
662 }
663 let base = description.trim_end();
664 Some(if base.is_empty() {
665 note
666 } else {
667 format!("{base}\n\n{note}")
668 })
669}
670
671fn note_profile(gate: &GatewayConn, agents_dir: &Path, agent_id: &str, nick: &str) {
675 match ensure_profile_note(gate, agents_dir, agent_id, nick) {
676 Ok(true) => eprintln!("mail4agent: wake {nick}: bootstrap note written to the profile"),
677 Ok(false) => {}
678 Err(err) => eprintln!("mail4agent: wake {nick}: bootstrap note not written ({err})"),
679 }
680}
681
682fn ensure_profile_note(
683 gate: &GatewayConn,
684 agents_dir: &Path,
685 agent_id: &str,
686 nick: &str,
687) -> Result<bool, ShellError> {
688 let text = std::fs::read_to_string(agents_dir.join(agent_id).join("profile.json"))
689 .map_err(|_| ShellError::Gateway("profile is unreadable".to_string()))?;
690 let profile: serde_json::Value = serde_json::from_str(&text)
691 .map_err(|_| ShellError::Gateway("profile is unreadable".to_string()))?;
692 let field = |key: &str| profile.get(key).and_then(|value| value.as_str());
693 let Some(name) = field("name").filter(|name| !name.trim().is_empty()) else {
694 return Err(ShellError::Gateway("profile has no name".to_string()));
695 };
696 let routine = crate::nick::routine_folder_id(nick)
697 .ok_or_else(|| ShellError::Gateway("nick has no routine name".to_string()))?;
698 let Some(description) = with_wake_note(field("description").unwrap_or(""), &routine) else {
699 return Ok(false);
700 };
701 if description.chars().count() > DESCRIPTION_MAX {
702 return Err(ShellError::Gateway(
703 "description would be too long".to_string(),
704 ));
705 }
706 let mut body = serde_json::json!({ "name": name, "description": description });
707 for key in ["title", "avatarShape", "avatarColor"] {
708 if let Some(value) = field(key) {
709 body[key] = serde_json::Value::String(value.to_string());
710 }
711 }
712 let (status, _) = gateway_post(
713 gate,
714 "updateAgent",
715 &serde_json::json!({ "id": agent_id, "profile": body }),
716 )?;
717 if status != 200 {
718 return Err(ShellError::Gateway(format!(
719 "gateway profile status {status}"
720 )));
721 }
722 Ok(true)
723}
724
725const DEFAULT_GATEWAY_FILE: &str = "agent-data/gateway.json";
728
729pub const GATEWAY_FILE_ENV: &str = "M4A_GATEWAY_FILE";
733
734pub const GATEWAY_TOKEN_ENV: &str = "M4A_GATEWAY_TOKEN";
737
738const MIRROR_ROUTINE_PROMPT: &str = "mail4agent wake mirror. Disabled on purpose: it only lets the local gateway hand out the webhook key for the routine with this folder id, which the bot creates itself with UpdateRoutine.";
742
743pub const WAKE_KEYCHAIN_FILE: &str = "routine-wake.json";
747
748#[derive(serde::Deserialize)]
749struct GatewayFile {
750 port: u16,
751 scheme: String,
752 #[serde(default)]
753 token: Option<String>,
754}
755
756#[derive(serde::Deserialize)]
757struct AutomationCard {
758 id: String,
759 name: String,
760 #[serde(default)]
761 trigger: Option<serde_json::Value>,
762}
763
764impl AutomationCard {
765 fn is_webhook(&self) -> bool {
768 fn has_webhook(value: &serde_json::Value) -> bool {
769 match value {
770 serde_json::Value::Array(items) => items.iter().any(has_webhook),
771 serde_json::Value::Object(map) => {
772 map.get("type").and_then(|kind| kind.as_str()) == Some("webhook")
773 || map
774 .values()
775 .any(|inner| inner.is_array() && has_webhook(inner))
776 }
777 _ => false,
778 }
779 }
780 self.trigger.as_ref().map(has_webhook).unwrap_or(true)
781 }
782}
783
784#[derive(serde::Deserialize)]
785struct WebhookCredential {
786 url: String,
787 key: Option<String>,
788}
789
790#[derive(serde::Serialize, serde::Deserialize)]
791#[serde(deny_unknown_fields)]
792struct StoredWake {
793 folder_id: String,
794 url: String,
795 key: String,
796}
797
798pub(crate) fn routine_name_for(session: &HostSession) -> Option<String> {
803 crate::nick::nick_from_display_name(session.bot_name.trim()).ok()
804}
805
806struct GatewayConn {
807 client: reqwest::blocking::Client,
808 base: String,
809 token: String,
810}
811
812fn open_gateway(
813 gateway_file: &Path,
814 token_override: Option<&str>,
815) -> Result<Option<GatewayConn>, ShellError> {
816 if !gateway_file.is_file() {
817 return Ok(None);
818 }
819 let text = std::fs::read_to_string(gateway_file)
820 .map_err(|_| ShellError::Gateway("gateway file is unreadable".to_string()))?;
821 let file: GatewayFile = serde_json::from_str(&text)
822 .map_err(|_| ShellError::Gateway("gateway file is unreadable".to_string()))?;
823 let scheme = file.scheme.trim();
824 if scheme != "http" && scheme != "https" {
825 return Err(ShellError::Gateway(
826 "gateway scheme is not http or https".to_string(),
827 ));
828 }
829 if file.port == 0 {
830 return Err(ShellError::Gateway("gateway port is unset".to_string()));
831 }
832 let token = token_override
833 .map(str::trim)
834 .filter(|value| !value.is_empty())
835 .map(str::to_string)
836 .or(file.token.filter(|value| !value.trim().is_empty()));
837 let Some(token) = token else {
838 return Err(ShellError::Gateway("gateway token is unset".to_string()));
839 };
840 let base = format!("{scheme}://127.0.0.1:{}", file.port);
842 Ok(Some(GatewayConn {
843 client: gateway_client()?,
844 base,
845 token,
846 }))
847}
848
849enum WakeOutcome {
852 Ready { url: String, key: String },
853 AwaitingBackend,
854 Failed(String),
855}
856
857impl WakeOutcome {
858 fn status(&self) -> WakeStatus {
859 match self {
860 WakeOutcome::Ready { .. } => WakeStatus::Ready,
861 WakeOutcome::AwaitingBackend => WakeStatus::AwaitingBackend,
862 WakeOutcome::Failed(reason) => WakeStatus::Failed(reason.clone()),
863 }
864 }
865}
866
867fn log_outcome(nick: &str, folder_id: &str, outcome: &WakeOutcome) {
868 match outcome {
869 WakeOutcome::Ready { .. } => eprintln!("mail4agent: wake {nick} ({folder_id}): ready"),
870 WakeOutcome::AwaitingBackend => eprintln!(
871 "mail4agent: wake {nick} ({folder_id}): no key yet; the bot has not created its own routine with this folder id; will retry"
872 ),
873 WakeOutcome::Failed(reason) => {
874 eprintln!("mail4agent: wake {nick} ({folder_id}): {reason}")
875 }
876 }
877}
878
879fn ensure_wake(gate: &GatewayConn, agent_id: &str, nick: &str) -> WakeOutcome {
889 let Some(folder) = crate::nick::routine_folder_id(nick) else {
890 return WakeOutcome::Failed("nick has no routine folder id".to_string());
891 };
892 let before = match list_agent_automations(gate, agent_id) {
893 Ok(cards) => cards,
894 Err(err) => return WakeOutcome::Failed(err.to_string()),
895 };
896 match before.iter().find(|card| card.id == folder) {
897 Some(card) if !card.is_webhook() => {
898 return WakeOutcome::Failed(
899 "a routine in this folder is not webhook-triggered; left as is".to_string(),
900 );
901 }
902 Some(_) => {}
903 None => {
904 let after = match create_mirror_routine(gate, agent_id, &folder) {
905 Ok(cards) => cards,
906 Err(err) => return WakeOutcome::Failed(err.to_string()),
907 };
908 if !after.iter().any(|card| card.id == folder) {
909 for stray in after.iter().filter(|card| {
910 card.name == folder && !before.iter().any(|old| old.id == card.id)
911 }) {
912 let _ = delete_agent_automation(gate, agent_id, &stray.id);
913 }
914 return WakeOutcome::Failed(
915 "the mirror did not land in the expected folder; removed it".to_string(),
916 );
917 }
918 }
919 }
920 match read_webhook_credential(gate, agent_id, &folder) {
921 Ok(CredentialRead::Ready { url, key }) => WakeOutcome::Ready { url, key },
922 Ok(CredentialRead::MintFailed) => WakeOutcome::AwaitingBackend,
923 Ok(CredentialRead::Missing) => {
924 WakeOutcome::Failed("the mirror is gone from the gateway".to_string())
925 }
926 Err(err) => WakeOutcome::Failed(err.to_string()),
927 }
928}
929
930fn attach_webhook_routines(
935 sessions: &mut [HostSession],
936 gateway_file: &Path,
937 token_override: Option<&str>,
938 store_root: Option<&Path>,
939) -> Result<(), ShellError> {
940 let gate = open_gateway(gateway_file, token_override)?;
941 for session in sessions.iter_mut() {
942 if session.routine_url.is_some() && session.routine_bearer.is_some() {
943 continue;
944 }
945 let Some(agent_id) = session.agent_id.clone().filter(|id| !id.is_empty()) else {
947 continue;
948 };
949 let Some(nick) = routine_name_for(session) else {
950 continue;
951 };
952 let Some(folder) = crate::nick::routine_folder_id(&nick) else {
953 continue;
954 };
955 let Some(gate) = gate.as_ref() else {
956 if let Some(root) = store_root {
957 if let Some((url, key)) = load_wake(root, &session.session_id, &folder) {
958 session.routine_url = Some(url);
959 session.routine_bearer = Some(key);
960 }
961 }
962 continue;
963 };
964 let outcome = ensure_wake(gate, &agent_id, &nick);
965 log_outcome(&nick, &folder, &outcome);
966 match outcome {
967 WakeOutcome::Ready { url, key } => {
968 if let Some(root) = store_root {
969 save_wake(root, &session.session_id, &folder, &url, &key);
970 crate::wake_policy::note_state(&crate::session_store_dir(root, &session.session_id), "ready", "gateway");
971 }
972 session.routine_url = Some(url);
973 session.routine_bearer = Some(key);
974 }
975 WakeOutcome::AwaitingBackend => {
976 if let Some(root) = store_root {
979 clear_wake(root, &session.session_id);
980 crate::wake_policy::note_state(&crate::session_store_dir(root, &session.session_id), "awaiting", "gateway");
981 }
982 }
983 WakeOutcome::Failed(_) => {
984 if let Some(root) = store_root {
985 crate::wake_policy::note_state(&crate::session_store_dir(root, &session.session_id), "failed", "gateway");
986 }
987 }
988 }
989 }
990 Ok(())
991}
992
993#[derive(serde::Deserialize)]
994#[serde(deny_unknown_fields)]
995struct RoutineFile {
996 url: String,
997 key: String,
998}
999
1000pub(crate) fn read_routine_file(path: &Path) -> Result<(String, String), String> {
1002 #[cfg(unix)]
1003 {
1004 use std::os::unix::fs::PermissionsExt;
1005 let meta = std::fs::metadata(path).map_err(|_| "the routine file cannot be read".to_string())?;
1006 if meta.permissions().mode() & 0o077 != 0 {
1007 return Err("the routine file must be mode 0600 (owner only)".to_string());
1008 }
1009 }
1010 let text = std::fs::read_to_string(path).map_err(|_| "the routine file cannot be read".to_string())?;
1011 let file: RoutineFile = serde_json::from_str(&text).map_err(|_| "the routine file is not {\"url\",\"key\"} json".to_string())?;
1012 let (url, key) = (file.url.trim().to_string(), file.key.trim().to_string());
1013 if key.is_empty() || !crate::webhook::url_ok(&url) {
1014 return Err("the routine file has no usable url and key".to_string());
1015 }
1016 Ok((url, key))
1017}
1018
1019fn attach_routine_files(sessions: &mut [HostSession], store_root: Option<&Path>) {
1023 for session in sessions.iter_mut() {
1024 let Some(path) = session.routine_file.clone() else { continue };
1025 if session.routine_url.is_some() && session.routine_bearer.is_some() {
1026 continue;
1027 }
1028 let label = routine_name_for(session).unwrap_or_else(|| session.session_id.clone());
1029 match read_routine_file(&path) {
1030 Ok((url, key)) => {
1031 session.routine_url = Some(url);
1032 session.routine_bearer = Some(key);
1033 if let Some(root) = store_root {
1034 crate::wake_policy::note_state(&crate::session_store_dir(root, &session.session_id), "ready", "file");
1035 }
1036 eprintln!("mail4agent: wake {label}: routine file read");
1037 }
1038 Err(reason) => eprintln!("mail4agent: wake {label}: {reason}"),
1039 }
1040 }
1041}
1042
1043pub(crate) fn wake_label(session_id: &str) -> String {
1045 format!("wake/{session_id}")
1046}
1047
1048fn attach_vault_wakes(sessions: &mut [HostSession], store_root: &Path) {
1052 let pending = |s: &HostSession| !(s.routine_url.is_some() && s.routine_bearer.is_some());
1053 if !sessions.iter().any(pending) {
1054 return;
1055 }
1056 let vault = match crate::store_key::vault(store_root) {
1057 Ok(v) => v,
1058 Err(err) => {
1059 eprintln!("mail4agent: wake vault not read: {err}");
1060 return;
1061 }
1062 };
1063 for session in sessions.iter_mut().filter(|s| pending(s)) {
1064 let Ok(Some(raw)) = vault.get(&wake_label(&session.session_id)) else { continue };
1065 #[derive(serde::Deserialize)]
1066 struct Held {
1067 url: String,
1068 key: String,
1069 }
1070 match serde_json::from_slice::<Held>(&raw) {
1071 Ok(h) if !h.key.is_empty() && crate::webhook::url_ok(&h.url) => {
1072 session.routine_url = Some(h.url);
1073 session.routine_bearer = Some(h.key);
1074 crate::wake_policy::note_state(&crate::session_store_dir(store_root, &session.session_id), "ready", "vault");
1075 eprintln!("mail4agent: wake {}: taken from the vault", routine_name_for(session).unwrap_or_else(|| session.session_id.clone()));
1076 }
1077 _ => eprintln!("mail4agent: wake {}: the vault entry is unusable", session.session_id),
1078 }
1079 }
1080}
1081
1082#[cfg(test)]
1083pub(crate) fn attach_vault_wakes_for_test(sessions: &mut [HostSession], store_root: &Path) {
1084 attach_vault_wakes(sessions, store_root)
1085}
1086
1087pub const STORE_LOCK_FILE: &str = ".lock";
1092
1093fn now_ms() -> i64 {
1094 std::time::SystemTime::now()
1095 .duration_since(std::time::UNIX_EPOCH)
1096 .map(|elapsed| elapsed.as_millis() as i64)
1097 .unwrap_or(0)
1098}
1099
1100pub(crate) fn lock_store(dir: &Path) -> Result<File, ShellError> {
1101 std::fs::create_dir_all(dir)?;
1102 let file = std::fs::OpenOptions::new()
1103 .create(true)
1104 .truncate(false)
1105 .write(true)
1106 .open(dir.join(STORE_LOCK_FILE))?;
1107 match file.try_lock() {
1108 Ok(()) => Ok(file),
1109 Err(_) => Err(ShellError::SessionList(
1110 "session store is in use by another process".to_string(),
1111 )),
1112 }
1113}
1114
1115fn write_secret_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
1117 let dir = path
1118 .parent()
1119 .ok_or_else(|| std::io::Error::other("no parent"))?;
1120 let mut builder = std::fs::DirBuilder::new();
1121 builder.recursive(true);
1122 #[cfg(unix)]
1123 {
1124 use std::os::unix::fs::DirBuilderExt;
1125 builder.mode(0o700);
1126 }
1127 builder.create(dir)?;
1128 let name = path
1129 .file_name()
1130 .and_then(|name| name.to_str())
1131 .unwrap_or("secret");
1132 let tmp = dir.join(format!("{name}.tmp"));
1133 let mut options = std::fs::OpenOptions::new();
1134 options.write(true).create(true).truncate(true);
1135 #[cfg(unix)]
1136 {
1137 use std::os::unix::fs::OpenOptionsExt;
1138 options.mode(0o600);
1139 }
1140 let mut file = options.open(&tmp)?;
1141 std::io::Write::write_all(&mut file, bytes)?;
1142 file.sync_all()?;
1143 drop(file);
1144 std::fs::rename(&tmp, path)
1145}
1146
1147fn keychain_path(store_root: &Path, session_id: &str) -> PathBuf {
1148 crate::session_store_dir(store_root, session_id).join(WAKE_KEYCHAIN_FILE)
1149}
1150
1151fn save_wake(store_root: &Path, session_id: &str, folder_id: &str, url: &str, key: &str) {
1154 let stored = StoredWake {
1155 folder_id: folder_id.to_string(),
1156 url: url.to_string(),
1157 key: key.to_string(),
1158 };
1159 let result = serde_json::to_vec(&stored)
1160 .map_err(std::io::Error::other)
1161 .and_then(|bytes| write_secret_file(&keychain_path(store_root, session_id), &bytes));
1162 if result.is_err() {
1163 eprintln!("mail4agent: wake keychain write failed for folder {folder_id}");
1164 }
1165}
1166
1167fn load_wake(store_root: &Path, session_id: &str, folder_id: &str) -> Option<(String, String)> {
1168 let text = std::fs::read_to_string(keychain_path(store_root, session_id)).ok()?;
1169 let stored: StoredWake = serde_json::from_str(&text).ok()?;
1170 if stored.folder_id != folder_id || stored.url.is_empty() || stored.key.is_empty() {
1171 return None;
1172 }
1173 Some((stored.url, stored.key))
1174}
1175
1176fn clear_wake(store_root: &Path, session_id: &str) {
1177 let _ = std::fs::remove_file(keychain_path(store_root, session_id));
1178}
1179
1180fn list_agent_automations(
1181 gate: &GatewayConn,
1182 agent_id: &str,
1183) -> Result<Vec<AutomationCard>, ShellError> {
1184 let (status, body) = gateway_post(
1185 gate,
1186 "getAgentAutomations",
1187 &serde_json::json!({ "id": agent_id }),
1188 )?;
1189 if status != 200 {
1190 return Err(ShellError::Gateway(format!("gateway list status {status}")));
1191 }
1192 serde_json::from_slice(&body)
1193 .map_err(|_| ShellError::Gateway("gateway list was not understood".to_string()))
1194}
1195
1196enum CredentialRead {
1197 Ready {
1198 url: String,
1199 key: String,
1200 },
1201 MintFailed,
1203 Missing,
1204}
1205
1206fn gateway_client() -> Result<reqwest::blocking::Client, ShellError> {
1207 reqwest::blocking::Client::builder()
1208 .timeout(std::time::Duration::from_secs(8))
1209 .redirect(reqwest::redirect::Policy::none())
1210 .http1_only()
1211 .build()
1212 .map_err(|_| ShellError::Gateway("gateway client could not start".to_string()))
1213}
1214
1215fn gateway_bearer_header(token: &str) -> Result<reqwest::header::HeaderValue, ()> {
1216 let token = crate::webhook::bearer_token(token).ok_or(())?;
1217 let mut header = reqwest::header::HeaderValue::from_str(&format!("Bearer {token}")).map_err(|_| ())?;
1218 header.set_sensitive(true);
1219 Ok(header)
1220}
1221
1222fn gateway_post(
1223 gate: &GatewayConn,
1224 method: &str,
1225 body: &serde_json::Value,
1226) -> Result<(u16, Vec<u8>), ShellError> {
1227 let url = format!("{}/api/{method}", gate.base);
1228 let authorization = gateway_bearer_header(&gate.token).map_err(|_| {
1229 ShellError::Gateway("gateway token is not a single header value".to_string())
1230 })?;
1231 let bytes = serde_json::to_vec(body)
1232 .map_err(|_| ShellError::Gateway("gateway request was not json".to_string()))?;
1233 let response = gate
1234 .client
1235 .post(&url)
1236 .header(reqwest::header::AUTHORIZATION, authorization)
1237 .header(reqwest::header::CONTENT_TYPE, "application/json")
1238 .body(bytes)
1239 .send()
1240 .map_err(|_| ShellError::Gateway("gateway request failed".to_string()))?;
1241 let status = response.status().as_u16();
1242 let body = response
1243 .bytes()
1244 .map_err(|_| ShellError::Gateway("gateway response failed".to_string()))?;
1245 Ok((status, body.to_vec()))
1246}
1247
1248fn read_webhook_credential(
1249 gate: &GatewayConn,
1250 agent_id: &str,
1251 automation_id: &str,
1252) -> Result<CredentialRead, ShellError> {
1253 let (status, body) = gateway_post(
1254 gate,
1255 "getAutomationWebhookCredential",
1256 &serde_json::json!({
1257 "id": agent_id,
1258 "automationId": automation_id,
1259 }),
1260 )?;
1261 if status == 200 {
1262 let parsed: WebhookCredential = serde_json::from_slice(&body).map_err(|_| {
1263 ShellError::Gateway("gateway credential was not understood".to_string())
1264 })?;
1265 let key = parsed.key.filter(|key| !key.is_empty());
1266 if parsed.url.is_empty() {
1267 return Err(ShellError::Gateway(
1268 "gateway credential was not understood".to_string(),
1269 ));
1270 }
1271 return Ok(match key {
1272 Some(key) => CredentialRead::Ready {
1273 url: parsed.url,
1274 key,
1275 },
1276 None => CredentialRead::MintFailed,
1277 });
1278 }
1279 if credential_is_missing(status, &body) {
1280 return Ok(CredentialRead::Missing);
1281 }
1282 Err(ShellError::Gateway(format!(
1283 "gateway credential status {status}"
1284 )))
1285}
1286
1287fn credential_is_missing(status: u16, body: &[u8]) -> bool {
1290 if status == 404 {
1291 return true;
1292 }
1293 if status != 500 {
1294 return false;
1295 }
1296 std::str::from_utf8(body)
1297 .map(|text| text.contains("Automation not found"))
1298 .unwrap_or(false)
1299}
1300
1301fn create_mirror_routine(
1303 gate: &GatewayConn,
1304 agent_id: &str,
1305 routine: &str,
1306) -> Result<Vec<AutomationCard>, ShellError> {
1307 let (status, body) = gateway_post(
1308 gate,
1309 "createAgentAutomation",
1310 &serde_json::json!({
1311 "id": agent_id,
1312 "spec": {
1313 "name": routine,
1314 "prompt": MIRROR_ROUTINE_PROMPT,
1315 "trigger": { "type": "webhook" },
1316 "isEnabled": false,
1317 },
1318 }),
1319 )?;
1320 if status != 200 {
1321 return Err(ShellError::Gateway(format!(
1322 "gateway create status {status}"
1323 )));
1324 }
1325 serde_json::from_slice(&body)
1326 .map_err(|_| ShellError::Gateway("gateway create was not understood".to_string()))
1327}
1328
1329fn delete_agent_automation(
1330 gate: &GatewayConn,
1331 agent_id: &str,
1332 automation_id: &str,
1333) -> Result<(), ShellError> {
1334 let (status, _) = gateway_post(
1335 gate,
1336 "deleteAgentAutomation",
1337 &serde_json::json!({ "id": agent_id, "automationId": automation_id }),
1338 )?;
1339 if status != 200 {
1340 return Err(ShellError::Gateway(format!(
1341 "gateway delete status {status}"
1342 )));
1343 }
1344 Ok(())
1345}
1346
1347pub(crate) struct Prepared {
1348 pub(crate) config: SessionConfig,
1349 pub(crate) nick: String,
1350 pub(crate) user_id: String,
1351 pub(crate) device_id: DeviceId,
1352 pub(crate) bearer: Zeroizing<String>,
1353 pub(crate) backend: Arc<dyn m4a_agent::Backend>,
1354 pub(crate) routine_url: Option<String>,
1355 pub(crate) routine_bearer: Option<String>,
1356}
1357
1358const PUSH_RETRY_SECS: u64 = 5;
1362const HEAL_AFTER_FAILED_TICKS: u32 = 5;
1364
1365pub struct MachineClient {
1366 product_mode: bool,
1368 sessions: Vec<OpenedStore>,
1369 bus: Arc<LocalBus>,
1370 push: m4a_agent::engine::PushLink,
1371 agents_dir: Option<PathBuf>,
1375 gateway_file: Option<PathBuf>,
1376 gateway_token: Option<String>,
1377 last_agent_poll: std::time::Instant,
1378 agent_rescan_secs: u64,
1379 sessions_dir: Option<PathBuf>,
1381 store_root: Option<PathBuf>,
1383 skip_nicks: Vec<String>,
1385 profile_note: bool,
1387 session_ids: Vec<(String, String)>,
1389 last_full_drive: std::time::Instant,
1391 pending_wakes: Vec<PendingWake>,
1393 ready_agents: HashSet<String>,
1396 _locks: Vec<File>,
1398 send_listener: Option<SendListener>,
1400 send_sock: Option<PathBuf>,
1401 send_queue: Vec<PendingSend>,
1403 homeserver_url: String,
1406 bus_next_user: i64,
1408 failed_opens: HashMap<String, std::time::Instant>,
1411 last_push_retry: std::time::Instant,
1413 error_streak: u32,
1415}
1416
1417const LATE_OPEN_RETRY_SECS: u64 = 60;
1420
1421struct PendingSend {
1423 stream: SendStream,
1424 request: crate::SendRequest,
1425 started: std::time::Instant,
1426 room: Option<String>,
1427 peer: Option<String>,
1428}
1429
1430const SEND_JOIN_WAIT_SECS: u64 = 120;
1432
1433impl Drop for MachineClient {
1434 fn drop(&mut self) {
1435 if let Some(path) = self.send_sock.take() {
1436 let _ = std::fs::remove_file(path);
1437 }
1438 }
1439}
1440
1441#[derive(Debug, Default)]
1444pub struct TickReport {
1445 pub pushed: Vec<(String, String)>,
1447 pub joined: Vec<(String, String)>,
1449 pub errors: Vec<(String, String)>,
1451 pub sent: Vec<(String, String, crate::SendReply)>,
1453 pub alerts: Vec<(String, String)>,
1455}
1456
1457struct PendingWake {
1459 store_dir: PathBuf,
1460 agent_id: String,
1461}
1462
1463impl MachineClient {
1464 pub fn open(
1471 homeserver_url: &str,
1472 store_root: &Path,
1473 sessions: Vec<HostSession>,
1474 ) -> Result<Self, ShellError> {
1475 Self::open_with(homeserver_url, store_root, sessions, false)
1476 }
1477
1478 fn open_with(
1482 homeserver_url: &str,
1483 store_root: &Path,
1484 sessions: Vec<HostSession>,
1485 lenient: bool,
1486 ) -> Result<Self, ShellError> {
1487 let mut sessions = sessions;
1488 attach_routine_files(&mut sessions, Some(store_root));
1489 attach_vault_wakes(&mut sessions, store_root);
1490 if sessions.is_empty() {
1491 return Err(ShellError::SessionList("session list is empty".to_string()));
1492 }
1493 let mut prepared = Vec::with_capacity(sessions.len());
1494 let mut seen_ids = Vec::new();
1495 let mut seen_nicks = Vec::new();
1496 let mut locks = Vec::new();
1497 for session in sessions {
1498 let own_root = session.store_root.clone().unwrap_or_else(|| store_root.to_path_buf());
1500 let config = session.config(homeserver_url, &own_root)?;
1501 if seen_ids.iter().any(|id: &String| id == config.session_id()) {
1502 return Err(ShellError::SessionList("duplicate session id".to_string()));
1503 }
1504 seen_ids.push(config.session_id().to_string());
1505 let lock = match crate::at("lock store", &config.store_dir(), lock_store(&config.store_dir())) {
1506 Ok(lock) => lock,
1507 Err(err) if lenient => {
1508 eprintln!("mail4agent: session {} left out: {err}", config.session_id());
1509 continue;
1510 }
1511 Err(err) => return Err(err),
1512 };
1513 let registered = match register_session(&config) {
1514 Ok(registered) => registered,
1515 Err(err) if lenient => {
1516 eprintln!("mail4agent: session {} left out: {err}", config.session_id());
1517 continue;
1518 }
1519 Err(err) => return Err(err),
1520 };
1521 if seen_nicks.iter().any(|nick: &String| nick.eq_ignore_ascii_case(®istered.nick)) {
1522 return Err(ShellError::SessionList("duplicate nick".to_string()));
1523 }
1524 seen_nicks.push(registered.nick.clone());
1525 locks.push(lock);
1526 prepared.push(Prepared {
1527 config,
1528 nick: registered.nick,
1529 user_id: registered.user_id,
1530 device_id: registered.device_id,
1531 bearer: registered.bearer,
1532 backend: registered.backend,
1533 routine_url: session.routine_url,
1534 routine_bearer: session.routine_bearer,
1535 });
1536 }
1537 if prepared.is_empty() {
1538 return Err(ShellError::SessionList(
1539 "no session could be opened".to_string(),
1540 ));
1541 }
1542 let server_name = server_name_of(&prepared[0].user_id)?;
1543 for item in &prepared[1..] {
1544 if server_name_of(&item.user_id)? != server_name {
1545 return Err(ShellError::SessionList(
1546 "sessions disagree on the homeserver name".to_string(),
1547 ));
1548 }
1549 }
1550 ensure_server_name(server_name)?;
1551 let bus = Arc::new(crate::at("local bus", store_root, LocalBus::open(&prepared))?);
1552 let peers: Vec<(String, String)> = prepared
1553 .iter()
1554 .map(|item| (item.nick.to_string(), item.user_id.clone()))
1555 .collect();
1556 let mut opened = Vec::with_capacity(prepared.len());
1557 for item in &prepared {
1558 let server_name = server_name_of(&item.user_id)?;
1559 let mut store = OpenedStore::open(
1560 &item.config.store_dir(),
1561 item.config.session_id(),
1562 item.device_id.clone(),
1563 &item.user_id,
1564 server_name,
1565 Arc::clone(&item.backend),
1566 item.bearer.as_str(),
1567 )?;
1568 store.set_registered_nick(item.nick.to_string());
1569 store.attach_bus(Arc::clone(&bus));
1570 store.set_local_peers(
1571 peers
1572 .iter()
1573 .filter(|(nick, _)| *nick != item.nick)
1574 .cloned()
1575 .collect(),
1576 );
1577 store.set_wake(SessionWake {
1578 routine_url: item.routine_url.clone(),
1579 routine_bearer: item.routine_bearer.clone(),
1580 leader_sock: None,
1581 leader_cwd: None,
1582 });
1583 if item.routine_url.is_none() {
1584 attach_detected_chain(&mut store, &item.config, &item.nick);
1585 }
1586 crate::at("first drive", &item.config.store_dir(), store.drive(1_000, false))?;
1587 crate::at("abandon inflight", &item.config.store_dir(), store.abandon_inflight_sync(1_000))?;
1588 opened.push(store);
1589 }
1590 let tokens: Vec<String> = prepared
1591 .iter()
1592 .map(|item| item.bearer.as_str().to_string())
1593 .collect();
1594 let product_mode = true;
1595 let push = m4a_agent::engine::PushLink::open(&prepared[0].config.homeserver_url, prepared[0].backend.keep_prefix(), tokens, product_mode)?;
1596 Ok(Self {
1597 product_mode,
1598 sessions: opened,
1599 bus,
1600 push,
1601 agents_dir: None,
1602 gateway_file: None,
1603 gateway_token: None,
1604 last_agent_poll: std::time::Instant::now(),
1605 agent_rescan_secs: 0,
1606 sessions_dir: None,
1607 store_root: None,
1608 skip_nicks: Vec::new(),
1609 profile_note: false,
1610 session_ids: Vec::new(),
1611 last_full_drive: std::time::Instant::now(),
1612 pending_wakes: Vec::new(),
1613 ready_agents: HashSet::new(),
1614 _locks: locks,
1615 send_listener: None,
1616 send_sock: None,
1617 send_queue: Vec::new(),
1618 homeserver_url: homeserver_url.to_string(),
1619 bus_next_user: prepared.len() as i64 + 1,
1620 failed_opens: HashMap::new(),
1621 last_push_retry: std::time::Instant::now(),
1622 error_streak: 0,
1623 })
1624 }
1625
1626 pub fn open_session_dir(
1629 homeserver_url: &str,
1630 store_root: &Path,
1631 sessions_dir: &Path,
1632 ) -> Result<Self, ShellError> {
1633 let sessions = load_session_records(sessions_dir)?;
1634 Self::open(homeserver_url, store_root, sessions)
1635 }
1636
1637 pub fn from_env() -> Result<Self, ShellError> {
1648 Self::from_env_filtered(None)
1649 }
1650
1651 pub fn from_env_for(nick: &str) -> Result<Self, ShellError> {
1655 Self::from_env_filtered(Some(nick))
1656 }
1657
1658 pub fn from_env_session(nick: &str, session_id: &str) -> Result<Self, ShellError> {
1661 let homeserver_url = std::env::var(HOMESERVER_URL_ENV)
1662 .ok()
1663 .filter(|value| !value.is_empty())
1664 .or_else(|| std::env::var("M4A_PRODUCT_URL").ok().filter(|value| !value.is_empty()))
1665 .ok_or(ShellError::HomeserverUrl)?;
1666 let store_root = std::env::var(STORE_ROOT_ENV).ok().filter(|value| !value.is_empty()).ok_or(ShellError::StoreRoot)?;
1667 let mut client = Self::open_with(&homeserver_url, Path::new(&store_root), vec![HostSession::new(nick.to_string(), session_id.to_string())], false)?;
1668 client.store_root = Some(PathBuf::from(&store_root));
1669 Ok(client)
1670 }
1671
1672 fn from_env_filtered(only: Option<&str>) -> Result<Self, ShellError> {
1673 let homeserver_url = std::env::var(HOMESERVER_URL_ENV)
1674 .ok()
1675 .filter(|value| !value.is_empty())
1676 .ok_or(ShellError::HomeserverUrl)?;
1677 let store_root = std::env::var(STORE_ROOT_ENV)
1678 .ok()
1679 .filter(|value| !value.is_empty())
1680 .ok_or(ShellError::StoreRoot)?;
1681 let mut get = |key: &str| -> Option<String> {
1682 std::env::var(key).ok().filter(|value| !value.is_empty())
1683 };
1684 let agents_dir = explicit_agents_dir(&mut get);
1685 let mut sessions_dir_seen: Option<PathBuf> = None;
1686 let (sessions, agents_dir) = if let Some(dir) = agents_dir {
1687 (load_web_agents(&dir, &mut get)?, Some(dir))
1688 } else {
1689 match get(SESSIONS_DIR_ENV) {
1690 Some(sessions_dir) => {
1691 sessions_dir_seen = Some(PathBuf::from(&sessions_dir));
1692 (load_web_sessions(Path::new(&sessions_dir), &mut get)?, None)
1693 }
1694 None => {
1697 let base = get(crate::ACCOUNTS_ROOT_ENV).map(PathBuf::from).or_else(|| accounts_base_of(Path::new(&store_root)));
1698 match base {
1699 Some(base) => (account_sessions(&base), None),
1700 None => return Err(ShellError::SessionList("session directory is unset".to_string())),
1701 }
1702 }
1703 }
1704 };
1705 let sessions = match only {
1706 Some(nick) => {
1707 let needle = crate::nick::lookup_nick(nick)?;
1708 let kept: Vec<HostSession> = sessions
1709 .into_iter()
1710 .filter(|session| {
1711 routine_name_for(session)
1712 .is_some_and(|name| name.eq_ignore_ascii_case(&needle))
1713 })
1714 .collect();
1715 if kept.is_empty() {
1716 return Err(ShellError::UnknownNick);
1717 }
1718 kept
1719 }
1720 None => sessions,
1721 };
1722 let mut sessions = sessions;
1724 attach_routine_files(&mut sessions, Some(Path::new(&store_root)));
1725 attach_vault_wakes(&mut sessions, Path::new(&store_root));
1726 let gateway_file = get(GATEWAY_FILE_ENV).map(PathBuf::from);
1727 let gateway_token = get(GATEWAY_TOKEN_ENV);
1728 let agent_rescan_secs = get(AGENT_RESCAN_SECS_ENV)
1729 .and_then(|value| value.parse::<u64>().ok())
1730 .unwrap_or(0);
1731 let mut pending_wakes = Vec::new();
1732 let mut ready_agents = HashSet::new();
1733 for session in &sessions {
1734 let Some(agent_id) = session.agent_id.clone() else {
1735 continue;
1736 };
1737 if session.routine_url.is_some() && session.routine_bearer.is_some() {
1738 ready_agents.insert(agent_id);
1739 } else {
1740 pending_wakes.push(PendingWake {
1741 store_dir: crate::session_store_dir(
1742 Path::new(&store_root),
1743 &session.session_id,
1744 ),
1745 agent_id,
1746 });
1747 }
1748 }
1749 let options = WakeOptions::from_lookup(&mut get);
1750 let mut client = Self::open_with(&homeserver_url, Path::new(&store_root), sessions, true)?;
1751 client.store_root = Some(PathBuf::from(&store_root));
1752 client.skip_nicks = options.skip_nicks;
1753 client.profile_note = options.profile_note;
1754 client.session_ids = options.session_ids;
1755 client.pending_wakes = pending_wakes;
1756 client.ready_agents = ready_agents;
1757 client.agents_dir = agents_dir;
1758 client.gateway_file = gateway_file;
1759 client.gateway_token = gateway_token;
1760 client.agent_rescan_secs = agent_rescan_secs;
1761 client.sessions_dir = sessions_dir_seen;
1762 if get(LOCAL_BUS_ENV).is_some_and(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "on" | "true" | "yes")) {
1763 #[cfg(feature = "local-bus")]
1764 client.set_local_delivery(true);
1765 #[cfg(not(feature = "local-bus"))]
1766 return Err(ShellError::SessionList("M4A_LOCAL_BUS needs a build with feature local-bus".to_string()));
1767 }
1768 client.last_agent_poll = std::time::Instant::now();
1769 Ok(client)
1770 }
1771
1772 pub fn poll_agent_directory(&mut self) -> Result<Vec<RoutineReport>, ShellError> {
1783 let Some(agents_dir) = self.agents_dir.clone() else {
1784 return self.poll_session_dir_wakes();
1785 };
1786 if self.agent_rescan_secs > 0 {
1787 let elapsed = self.last_agent_poll.elapsed().as_secs();
1788 if elapsed < self.agent_rescan_secs {
1789 return Ok(Vec::new());
1790 }
1791 }
1792 self.last_agent_poll = std::time::Instant::now();
1793 self.open_new_agent_sessions(&agents_dir);
1794 let gateway = self
1795 .gateway_file
1796 .clone()
1797 .unwrap_or_else(|| under_home(DEFAULT_GATEWAY_FILE));
1798 let Some(gate) = open_gateway(&gateway, self.gateway_token.as_deref())? else {
1799 return Ok(Vec::new());
1800 };
1801 let mut sessions = drop_skipped(load_agents_dir(&agents_dir)?, &self.skip_nicks);
1802 apply_session_ids(&mut sessions, &self.session_ids);
1803 let mut reports = Vec::new();
1804 for session in &sessions {
1805 let Some(agent_id) = session.agent_id.clone() else {
1806 continue;
1807 };
1808 let Some(nick) = routine_name_for(session) else {
1809 continue;
1810 };
1811 let folder_id = crate::nick::routine_folder_id(&nick).unwrap_or_default();
1812 if self.ready_agents.contains(&agent_id) {
1813 reports.push(RoutineReport {
1814 agent_id,
1815 nick,
1816 folder_id,
1817 status: WakeStatus::Ready,
1818 });
1819 continue;
1820 }
1821 let outcome = ensure_wake(&gate, &agent_id, &nick);
1822 log_outcome(&nick, &folder_id, &outcome);
1823 if self.profile_note && matches!(outcome, WakeOutcome::AwaitingBackend) {
1824 note_profile(&gate, &agents_dir, &agent_id, &nick);
1825 }
1826 if let WakeOutcome::Ready { url, key } = &outcome {
1827 if let Some(root) = &self.store_root {
1828 save_wake(root, &session.session_id, &folder_id, url, key);
1829 }
1830 if let Some(index) = self
1831 .pending_wakes
1832 .iter()
1833 .position(|pending| pending.agent_id == agent_id)
1834 {
1835 let pending = self.pending_wakes.remove(index);
1836 if let Some(store) = self
1837 .sessions
1838 .iter_mut()
1839 .find(|store| store.store_dir() == pending.store_dir)
1840 {
1841 store.set_wake(SessionWake {
1842 routine_url: Some(url.clone()),
1843 routine_bearer: Some(key.clone()),
1844 leader_sock: None,
1845 leader_cwd: None,
1846 });
1847 }
1848 }
1849 self.ready_agents.insert(agent_id.clone());
1850 if let Some(user_id) = self
1851 .sessions
1852 .iter()
1853 .find(|store| {
1854 store
1855 .nick()
1856 .map(|n| n.eq_ignore_ascii_case(&nick))
1857 .unwrap_or(false)
1858 })
1859 .map(|store| store.user_id().to_string())
1860 {
1861 self.announce_peer_joined(&nick, &user_id);
1862 }
1863 }
1864 reports.push(RoutineReport {
1865 agent_id,
1866 nick,
1867 folder_id,
1868 status: outcome.status(),
1869 });
1870 }
1871 Ok(reports)
1872 }
1873
1874 fn poll_session_dir_wakes(&mut self) -> Result<Vec<RoutineReport>, ShellError> {
1879 let Some(dir) = self.sessions_dir.clone() else {
1880 return Ok(Vec::new());
1881 };
1882 if self.pending_wakes.is_empty() {
1883 return Ok(Vec::new());
1884 }
1885 let every = if self.agent_rescan_secs > 0 { self.agent_rescan_secs } else { 60 };
1886 if self.last_agent_poll.elapsed().as_secs() < every {
1887 return Ok(Vec::new());
1888 }
1889 self.last_agent_poll = std::time::Instant::now();
1890 let Some(gateway) = self.gateway_file.clone() else {
1891 return Ok(Vec::new());
1892 };
1893 let Some(gate) = open_gateway(&gateway, self.gateway_token.as_deref())? else {
1894 return Ok(Vec::new());
1895 };
1896 let mut sessions = load_session_records(&dir)?;
1897 apply_session_ids(&mut sessions, &self.session_ids);
1898 let mut reports = Vec::new();
1899 for session in &sessions {
1900 let Some(agent_id) = session.agent_id.clone() else { continue };
1901 if !self.pending_wakes.iter().any(|p| p.agent_id == agent_id) {
1902 continue;
1903 }
1904 let Some(nick) = routine_name_for(session) else { continue };
1905 let folder_id = crate::nick::routine_folder_id(&nick).unwrap_or_default();
1906 let outcome = ensure_wake(&gate, &agent_id, &nick);
1907 log_outcome(&nick, &folder_id, &outcome);
1908 let dir_of = self.store_root.as_ref().map(|r| crate::session_store_dir(r, &session.session_id));
1909 match &outcome {
1910 WakeOutcome::Ready { url, key } => {
1911 if let Some(root) = &self.store_root {
1912 save_wake(root, &session.session_id, &folder_id, url, key);
1913 }
1914 if let Some(d) = &dir_of {
1915 crate::wake_policy::note_state(d, "ready", "gateway");
1916 }
1917 if let Some(index) = self.pending_wakes.iter().position(|p| p.agent_id == agent_id) {
1918 let pending = self.pending_wakes.remove(index);
1919 if let Some(store) = self.sessions.iter_mut().find(|s| s.store_dir() == pending.store_dir) {
1920 store.set_wake(SessionWake { routine_url: Some(url.clone()), routine_bearer: Some(key.clone()), leader_sock: None, leader_cwd: None });
1921 }
1922 }
1923 self.ready_agents.insert(agent_id.clone());
1924 }
1925 WakeOutcome::AwaitingBackend => {
1926 if let Some(d) = &dir_of {
1927 crate::wake_policy::note_state(d, "awaiting", "gateway");
1928 }
1929 }
1930 WakeOutcome::Failed(_) => {
1931 if let Some(d) = &dir_of {
1932 crate::wake_policy::note_state(d, "failed", "gateway");
1933 }
1934 }
1935 }
1936 reports.push(RoutineReport { agent_id, nick, folder_id, status: outcome.status() });
1937 }
1938 Ok(reports)
1939 }
1940
1941 fn open_new_agent_sessions(&mut self, agents_dir: &Path) {
1947 let Some(store_root) = self.store_root.clone() else {
1948 return;
1949 };
1950 let found = match load_agents_dir(agents_dir) {
1951 Ok(found) => found,
1952 Err(err) => {
1953 eprintln!("mail4agent: agent rescan: {err}");
1954 return;
1955 }
1956 };
1957 let held: Vec<(PathBuf, Option<String>)> = self
1958 .sessions
1959 .iter()
1960 .map(|store| {
1961 (
1962 store.store_dir().to_path_buf(),
1963 store.nick().map(str::to_string),
1964 )
1965 })
1966 .collect();
1967 let fresh = unopened_agent_sessions(
1968 found,
1969 &self.skip_nicks,
1970 &self.session_ids,
1971 &store_root,
1972 &held,
1973 );
1974 for (session, nick) in fresh {
1975 if let Some(failed_at) = self.failed_opens.get(&session.session_id) {
1976 if failed_at.elapsed().as_secs() < LATE_OPEN_RETRY_SECS {
1977 continue;
1978 }
1979 }
1980 let session_id = session.session_id.clone();
1981 match self.open_late_session(&store_root, session, &nick) {
1982 Ok(()) => {
1983 self.failed_opens.remove(&session_id);
1984 println!("mail4agent: session {nick} registered and open");
1985 }
1986 Err(err) => {
1987 eprintln!("mail4agent: session {nick} not opened: {err}");
1988 self.failed_opens
1989 .insert(session_id, std::time::Instant::now());
1990 }
1991 }
1992 }
1993 }
1994
1995 fn open_late_session(
1996 &mut self,
1997 store_root: &Path,
1998 mut session: HostSession,
1999 nick: &str,
2000 ) -> Result<(), ShellError> {
2001 if session.routine_url.is_none() || session.routine_bearer.is_none() {
2002 if let Some(folder) = crate::nick::routine_folder_id(nick) {
2003 if let Some((url, key)) = load_wake(store_root, &session.session_id, &folder) {
2004 session.routine_url = Some(url);
2005 session.routine_bearer = Some(key);
2006 }
2007 }
2008 }
2009 let config = session.config(&self.homeserver_url, store_root)?;
2010 let lock = lock_store(&config.store_dir())?;
2011 let registered = register_session(&config)?;
2012 if let Some(first) = self.sessions.first() {
2013 if server_name_of(®istered.user_id)? != server_name_of(first.user_id())? {
2014 return Err(ShellError::SessionList(
2015 "sessions disagree on the homeserver name".to_string(),
2016 ));
2017 }
2018 }
2019 let item = Prepared {
2020 config,
2021 nick: registered.nick,
2022 user_id: registered.user_id,
2023 device_id: registered.device_id,
2024 bearer: registered.bearer,
2025 backend: registered.backend,
2026 routine_url: session.routine_url.clone(),
2027 routine_bearer: session.routine_bearer.clone(),
2028 };
2029 self.bus.seed(self.bus_next_user, &item)?;
2030 self.bus_next_user += 1;
2031 let mut store = OpenedStore::open(
2032 &item.config.store_dir(),
2033 item.config.session_id(),
2034 item.device_id.clone(),
2035 &item.user_id,
2036 server_name_of(&item.user_id)?,
2037 Arc::clone(&item.backend),
2038 item.bearer.as_str(),
2039 )?;
2040 store.set_registered_nick(item.nick.to_string());
2041 store.attach_bus(Arc::clone(&self.bus));
2042 store.set_wake(SessionWake {
2043 routine_url: item.routine_url.clone(),
2044 routine_bearer: item.routine_bearer.clone(),
2045 leader_sock: None,
2046 leader_cwd: None,
2047 });
2048 if item.routine_url.is_none() {
2049 attach_detected_chain(&mut store, &item.config, &item.nick);
2050 }
2051 store.drive(1_000, false)?;
2052 store.abandon_inflight_sync(1_000)?;
2053 self.sessions.push(store);
2054 self._locks.push(lock);
2055 self.refresh_local_peers();
2056 if let Err(err) = self.reopen_push() {
2057 eprintln!("mail4agent: push socket not reopened for {nick}: {err}");
2058 }
2059 if let Some(agent_id) = session.agent_id.clone() {
2060 if item.routine_url.is_some() && item.routine_bearer.is_some() {
2061 self.ready_agents.insert(agent_id);
2062 self.announce_peer_joined(nick, &item.user_id);
2063 } else {
2064 self.ready_agents.remove(&agent_id);
2066 if !self.pending_wakes.iter().any(|p| p.agent_id == agent_id) {
2067 self.pending_wakes.push(PendingWake {
2068 store_dir: item.config.store_dir(),
2069 agent_id,
2070 });
2071 }
2072 }
2073 }
2074 Ok(())
2075 }
2076
2077 fn reopen_push(&mut self) -> Result<(), ShellError> {
2080 let tokens: Vec<String> = self.sessions.iter().map(|store| store.device_bearer().to_string()).collect();
2081 if tokens.is_empty() {
2082 return Ok(());
2083 }
2084 let keep_prefix = self.sessions.first().is_some_and(|s| s.keep_prefix());
2085 let push = m4a_agent::engine::PushLink::open(&self.homeserver_url, keep_prefix, tokens, self.product_mode)?;
2086 let old = std::mem::replace(&mut self.push, push);
2087 for (recipient, event) in old.drain() {
2088 if let Some(store) = self.sessions.iter_mut().find(|store| store.user_id() == recipient) {
2089 store.record_push(event);
2090 }
2091 }
2092 Ok(())
2093 }
2094
2095 fn heal_push(&mut self) -> bool {
2098 let wanted = !self.push.is_alive() || self.error_streak >= HEAL_AFTER_FAILED_TICKS;
2099 if !wanted || self.bus.local_only() || self.last_push_retry.elapsed().as_secs() < PUSH_RETRY_SECS {
2100 return false;
2101 }
2102 self.last_push_retry = std::time::Instant::now();
2103 self.reopen_push().is_ok()
2104 }
2105
2106 pub fn error_streak(&self) -> u32 {
2108 self.error_streak
2109 }
2110
2111 fn refresh_local_peers(&mut self) {
2113 let peers: Vec<(String, String)> = self
2114 .sessions
2115 .iter()
2116 .filter_map(|store| Some((store.nick()?.to_string(), store.user_id().to_string())))
2117 .collect();
2118 for store in self.sessions.iter_mut() {
2119 let own = store.nick().unwrap_or("").to_string();
2120 store.set_local_peers(
2121 peers
2122 .iter()
2123 .filter(|(nick, _)| *nick != own)
2124 .cloned()
2125 .collect(),
2126 );
2127 }
2128 }
2129
2130 fn announce_peer_joined(&self, joined_nick: &str, _joined_user_id: &str) {
2134 eprintln!("mail4agent: peer_joined {joined_nick}");
2135 }
2136
2137 pub fn pending_wake_agents(&self) -> Vec<String> {
2139 self.pending_wakes
2140 .iter()
2141 .map(|pending| pending.agent_id.clone())
2142 .collect()
2143 }
2144
2145 pub fn set_local_delivery(&mut self, enabled: bool) {
2156 self.bus.set_local_only(enabled);
2157 if enabled {
2158 for store in self.sessions.iter_mut() {
2161 store.driver.core.republish_device_keys();
2162 }
2163 }
2164 }
2165
2166 pub fn homeserver_hits(&self) -> u64 {
2170 self.bus.hits()
2171 }
2172
2173 pub fn session_mut(&mut self, name_or_nick: &str) -> Result<&mut OpenedStore, ShellError> {
2175 let needle = crate::nick::lookup_nick(name_or_nick)?;
2176 self.sessions
2177 .iter_mut()
2178 .find(|store| {
2179 store
2180 .nick()
2181 .is_some_and(|nick| nick.eq_ignore_ascii_case(&needle))
2182 })
2183 .ok_or(ShellError::UnknownNick)
2184 }
2185
2186 pub fn store_dir(&self, name_or_nick: &str) -> Result<PathBuf, ShellError> {
2188 let needle = crate::nick::lookup_nick(name_or_nick)?;
2189 self.sessions
2190 .iter()
2191 .find(|store| {
2192 store
2193 .nick()
2194 .is_some_and(|nick| nick.eq_ignore_ascii_case(&needle))
2195 })
2196 .map(|store| store.store_dir().to_path_buf())
2197 .ok_or(ShellError::UnknownNick)
2198 }
2199
2200 pub fn deliver_pushed(&mut self) -> usize {
2204 let batch = self.push.drain();
2205 let mut delivered = 0;
2206 for (recipient, event) in batch {
2207 let Some(session) = self
2208 .sessions
2209 .iter_mut()
2210 .find(|store| store.user_id() == recipient)
2211 else {
2212 continue;
2213 };
2214 session.record_push(event);
2215 delivered += 1;
2216 }
2217 delivered
2218 }
2219
2220 pub fn tick(&mut self, now_ms: i64, full_drive_secs: u64) -> TickReport {
2230 let mut report = TickReport::default();
2231 let mut due: Vec<usize> = Vec::new();
2232 if self.heal_push() {
2233 report.alerts.push((String::new(), "push socket was down and is open again".to_string()));
2234 }
2235 for (recipient, event) in self.push.drain() {
2236 let Some(index) = self
2237 .sessions
2238 .iter()
2239 .position(|store| store.user_id() == recipient)
2240 else {
2241 continue;
2242 };
2243 let nick = self.sessions[index].nick().unwrap_or("").to_string();
2244 report.pushed.push((nick, event.event_id.clone()));
2245 self.sessions[index].record_push(event);
2246 if !due.contains(&index) {
2247 due.push(index);
2248 }
2249 }
2250 let full = self.last_full_drive.elapsed().as_secs() >= full_drive_secs || self.bus.local_only();
2253 if full {
2254 self.last_full_drive = std::time::Instant::now();
2255 }
2256 for index in 0..self.sessions.len() {
2257 let pushed = due.contains(&index);
2258 if !pushed && !full {
2259 continue;
2260 }
2261 let store = &mut self.sessions[index];
2262 let nick = store.nick().unwrap_or("").to_string();
2263 let drove = store.drive(now_ms, pushed);
2264 for text in store.take_security_alerts() {
2265 report.alerts.push((nick.clone(), text));
2266 }
2267 if let Err(err) = drove {
2268 report.errors.push((nick.clone(), err.to_string()));
2269 continue;
2270 }
2271 match store.accept_direct_invites(now_ms) {
2272 Ok(joined) => {
2273 for room in joined {
2274 report.joined.push((nick.clone(), room));
2275 }
2276 }
2277 Err(err) => report.errors.push((nick, err.to_string())),
2278 }
2279 }
2280 self.error_streak = if report.errors.is_empty() { 0 } else { self.error_streak.saturating_add(1) };
2281 self.serve_sends(now_ms, &mut report);
2282 if self.bus.local_only() {
2283 for round in 1..=4 {
2285 for store in self.sessions.iter_mut() {
2286 let _ = store.drive(now_ms + round, false);
2287 let _ = store.accept_direct_invites(now_ms + round);
2288 }
2289 }
2290 }
2291 report
2292 }
2293
2294 pub fn listen_for_sends(&mut self, path: &Path) -> Result<(), ShellError> {
2302 let listener = SendListener::bind(path).map_err(|err| {
2303 if err.kind() == std::io::ErrorKind::AlreadyExists {
2304 ShellError::SessionList(
2305 "another client already listens on the send socket".to_string(),
2306 )
2307 } else {
2308 ShellError::Io(err)
2309 }
2310 })?;
2311 listener.set_nonblocking(true)?;
2312 self.send_listener = Some(listener);
2313 self.send_sock = Some(path.to_path_buf());
2314 Ok(())
2315 }
2316
2317 pub fn listen_for_sends_from_env(&mut self) -> Result<PathBuf, ShellError> {
2320 let root = self.store_root.clone().ok_or(ShellError::StoreRoot)?;
2321 let path = crate::send_sock_path(
2322 |key| std::env::var(key).ok().filter(|value| !value.is_empty()),
2323 &root,
2324 );
2325 self.listen_for_sends(&path)?;
2326 Ok(path)
2327 }
2328
2329 pub fn send_blocking(
2333 &mut self,
2334 as_nick: &str,
2335 to: &str,
2336 text: &str,
2337 wait: std::time::Duration,
2338 ) -> crate::SendReply {
2339 let started = std::time::Instant::now();
2340 let mut room = None;
2341 let mut peer = None;
2342 loop {
2343 let now = now_ms();
2344 match self.try_send(as_nick, to, text, now, &mut room, &mut peer) {
2345 Some(reply) => return reply,
2346 None if started.elapsed() >= wait => {
2347 return crate::SendReply {
2348 room,
2349 ..crate::SendReply::failed(format!("{to} has not joined the DM yet"))
2350 }
2351 }
2352 None => {
2353 if let Ok(store) = self.session_mut(as_nick) {
2354 let _ = store.drive(now, false);
2355 }
2356 std::thread::sleep(std::time::Duration::from_millis(500));
2357 }
2358 }
2359 }
2360 }
2361
2362 fn try_send(
2364 &mut self,
2365 as_nick: &str,
2366 to: &str,
2367 text: &str,
2368 now_ms: i64,
2369 room: &mut Option<String>,
2370 peer: &mut Option<String>,
2371 ) -> Option<crate::SendReply> {
2372 let store = match self.session_mut(as_nick) {
2373 Ok(store) => store,
2374 Err(_) => {
2375 return Some(crate::SendReply::failed(format!(
2376 "{as_nick} is not a session on this client"
2377 )))
2378 }
2379 };
2380 if peer.is_none() {
2381 let mut last_err = None;
2384 for attempt in 0..3 {
2385 if attempt > 0 {
2386 let _ = store.drive(now_ms, false);
2387 }
2388 match store.find_nick(to, now_ms) {
2389 Ok(found) => {
2390 *peer = Some(found.user_id);
2391 last_err = None;
2392 break;
2393 }
2394 Err(err) => last_err = Some(err),
2395 }
2396 }
2397 if let Some(err) = last_err {
2398 return Some(crate::SendReply::failed(format!("find {to}: {err}")));
2399 }
2400 }
2401 if room.is_none() {
2402 match store.ensure_dm(to, now_ms) {
2403 Ok(room_id) => *room = Some(room_id),
2404 Err(err) => return Some(crate::SendReply::failed(format!("open DM: {err}"))),
2405 }
2406 }
2407 let (room_id, peer_id) = (room.clone()?, peer.clone()?);
2408 if !store.member_joined(&room_id, &peer_id) {
2409 return None;
2410 }
2411 match store.write_to_nick(to, text, now_ms) {
2412 Ok(room_id) => {
2413 let event_id = store
2414 .texts()
2415 .into_iter()
2416 .rev()
2417 .find(|row| row.room_id == room_id && row.body == text)
2418 .and_then(|row| row.event_id);
2419 Some(crate::SendReply {
2420 ok: true,
2421 room: Some(room_id),
2422 event_id,
2423 error: None,
2424 })
2425 }
2426 Err(err) => Some(crate::SendReply {
2427 room: Some(room_id),
2428 ..crate::SendReply::failed(format!("send: {err}"))
2429 }),
2430 }
2431 }
2432
2433 fn serve_sends(&mut self, now_ms: i64, report: &mut TickReport) {
2434 let mut cmds: Vec<(crate::ipc::SendStream, crate::CmdRequest)> = Vec::new();
2435 if let Some(listener) = &self.send_listener {
2436 loop {
2437 match listener.accept() {
2438 Ok(mut stream) => match crate::send::read_incoming(&mut stream) {
2439 Ok(crate::send::Incoming::Send(request)) if session_gate(self.store_root.as_deref(), &request.as_nick, request.cap.as_deref(), stream.peer_uid()).is_err() => {
2440 let why = session_gate(self.store_root.as_deref(), &request.as_nick, request.cap.as_deref(), stream.peer_uid()).unwrap_err();
2441 crate::send::write_reply(&mut stream, &crate::SendReply::failed(why))
2442 }
2443 Ok(crate::send::Incoming::Cmd(cmd)) if session_gate(self.store_root.as_deref(), &cmd.as_nick, cmd.cap.as_deref(), stream.peer_uid()).is_err() => {
2444 let why = session_gate(self.store_root.as_deref(), &cmd.as_nick, cmd.cap.as_deref(), stream.peer_uid()).unwrap_err();
2445 crate::send::write_cmd_reply(&mut stream, &crate::CmdReply::failed(why))
2446 }
2447 Ok(crate::send::Incoming::Send(request)) => self.send_queue.push(PendingSend {
2448 stream,
2449 request,
2450 started: std::time::Instant::now(),
2451 room: None,
2452 peer: None,
2453 }),
2454 Ok(crate::send::Incoming::Cmd(cmd)) => cmds.push((stream, cmd)),
2455 Err(err) => {
2456 crate::send::write_reply(&mut stream, &crate::SendReply::failed(err))
2457 }
2458 },
2459 Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => break,
2460 Err(_) => break,
2461 }
2462 }
2463 }
2464 for (mut stream, cmd) in cmds {
2465 let reply = match self.sessions.iter_mut().find(|store| {
2466 store.nick().is_some_and(|n| n.eq_ignore_ascii_case(cmd.as_nick.trim()))
2467 }) {
2468 Some(store) => store.run_command(&cmd, now_ms),
2469 None => crate::CmdReply::failed(format!("{} is not a session on this client", cmd.as_nick)),
2470 };
2471 crate::send::write_cmd_reply(&mut stream, &reply);
2472 }
2473 let queue = std::mem::take(&mut self.send_queue);
2474 for mut pending in queue {
2475 let (as_nick, to, text) = (
2476 pending.request.as_nick.clone(),
2477 pending.request.to.clone(),
2478 pending.request.text.clone(),
2479 );
2480 let outcome = self.try_send(
2481 &as_nick,
2482 &to,
2483 &text,
2484 now_ms,
2485 &mut pending.room,
2486 &mut pending.peer,
2487 );
2488 let reply = match outcome {
2489 Some(reply) => reply,
2490 None if pending.started.elapsed().as_secs() >= SEND_JOIN_WAIT_SECS => {
2491 crate::SendReply {
2492 room: pending.room.clone(),
2493 ..crate::SendReply::failed(format!("{to} has not joined the DM yet"))
2494 }
2495 }
2496 None => {
2497 self.send_queue.push(pending);
2498 continue;
2499 }
2500 };
2501 crate::send::write_reply(&mut pending.stream, &reply);
2502 report.sent.push((as_nick, to, reply));
2503 }
2504 }
2505
2506 pub fn wake_log(&self) -> Vec<(String, crate::WakeAttempt)> {
2509 self.sessions
2510 .iter()
2511 .flat_map(|store| {
2512 let nick = store.nick().unwrap_or("").to_string();
2513 store
2514 .wake_log()
2515 .iter()
2516 .cloned()
2517 .map(move |attempt| (nick.clone(), attempt))
2518 })
2519 .collect()
2520 }
2521
2522 pub fn holds(&self, name_or_nick: &str) -> bool {
2524 let Ok(needle) = crate::nick::lookup_nick(name_or_nick) else {
2525 return false;
2526 };
2527 self.sessions.iter().any(|store| {
2528 store
2529 .nick()
2530 .is_some_and(|nick| nick.eq_ignore_ascii_case(&needle))
2531 })
2532 }
2533}
2534
2535fn server_name_of(mxid: &str) -> Result<&str, ShellError> {
2536 mxid.split_once(':')
2537 .map(|(_, server)| server)
2538 .filter(|server| !server.is_empty())
2539 .ok_or_else(|| ShellError::Register("user id has no server".to_string()))
2540}
2541
2542
2543fn attach_detected_chain(store: &mut OpenedStore, config: &SessionConfig, nick: &str) {
2549 if let Some((session, _)) = crate::provider::chain::detected_web_chain(config.session_id(), nick, &config.store_root) {
2552 let inbox = crate::provider::registry::inbox_dir(&config.store_root, config.session_id());
2553 let config = crate::provider::AdapterConfig::from_env(Some(inbox.clone()));
2554 let chain = crate::provider::letter_chain(session.kind, Some(inbox), &config);
2555 store.set_wake_chain(session, chain);
2556 }
2557}
2558
2559#[cfg(test)]
2560mod tests {
2561 #[cfg(unix)]
2562 #[test]
2563 fn a_routine_file_gives_a_session_without_an_agent_id_its_wake_and_leaks_nothing() {
2564 use std::os::unix::fs::PermissionsExt;
2565 let dir = tempfile::tempdir().expect("dir");
2566 let file = dir.path().join("wake.json");
2567 let write = |text: &str, mode: u32| {
2568 std::fs::write(&file, text).expect("write");
2569 std::fs::set_permissions(&file, std::fs::Permissions::from_mode(mode)).expect("mode");
2570 };
2571 let mut set = vec![HostSession::new("Courier", "web-courier").with_routine_file(&file)];
2572
2573 write(r#"{"url":"http://127.0.0.1:9/hook","key":"k-secret"}"#, 0o600);
2575 attach_routine_files(&mut set, None);
2576 assert_eq!(set[0].routine_url.as_deref(), Some("http://127.0.0.1:9/hook"));
2577 assert_eq!(set[0].routine_bearer.as_deref(), Some("k-secret"));
2578 assert!(!format!("{:?}", set[0]).contains("k-secret"), "Debug must not show the key");
2579
2580 for (text, mode) in [(r#"{"url":"http://127.0.0.1:9/hook","key":"k-secret"}"#, 0o644), (r#"{"url":"http://127.0.0.1:9/hook","key":"k-secret","extra":1}"#, 0o600), (r#"{"url":"http://127.0.0.1:9/hook","key":" "}"#, 0o600), (r#"{"url":"not a url","key":"k-secret"}"#, 0o600)] {
2582 write(text, mode);
2583 let err = read_routine_file(&file).expect_err("refused");
2584 assert!(!err.contains("k-secret") && !err.contains("127.0.0.1"), "{err}");
2585 let mut one = vec![HostSession::new("Courier", "web-courier").with_routine_file(&file)];
2586 attach_routine_files(&mut one, None);
2587 assert!(one[0].routine_url.is_none() && one[0].routine_bearer.is_none());
2588 }
2589 write(r#"{"url":"http://127.0.0.1:9/other","key":"k2"}"#, 0o600);
2591 let mut held = vec![HostSession::new("Courier", "web-courier").with_routine_file(&file).with_routine("http://127.0.0.1:9/mine", Some("mine".into()))];
2592 attach_routine_files(&mut held, None);
2593 assert_eq!(held[0].routine_url.as_deref(), Some("http://127.0.0.1:9/mine"));
2594 }
2595
2596 #[cfg(unix)]
2597 #[test]
2598 fn a_wake_from_a_routine_file_is_recorded_with_its_source() {
2599 use std::os::unix::fs::PermissionsExt;
2600 let dir = tempfile::tempdir().expect("dir");
2601 let file = dir.path().join("wake.json");
2602 std::fs::write(&file, r#"{"url":"http://127.0.0.1:9/hook","key":"k"}"#).expect("write");
2603 std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o600)).expect("mode");
2604 let mut set = vec![HostSession::new("Courier", "web-courier").with_routine_file(&file)];
2605 attach_routine_files(&mut set, Some(dir.path()));
2606 let status = crate::wake_policy::WakeStatusFile::load(&crate::session_store_dir(dir.path(), "web-courier")).expect("status");
2607 assert_eq!((status.state.as_str(), status.source.as_str()), ("ready", "file"));
2608 }
2609
2610 #[test]
2611 fn a_session_record_may_name_a_routine_file_but_never_carry_the_secret() {
2612 let dir = tempfile::tempdir().expect("dir");
2613 std::fs::write(dir.path().join("a.json"), r#"{"bot_name":"Courier","session_id":"web-courier","routine_file":"/some/where/wake.json"}"#).expect("write");
2614 let loaded = load_session_records(dir.path()).expect("records");
2615 assert_eq!(loaded[0].routine_file.as_deref(), Some(Path::new("/some/where/wake.json")));
2616 assert!(loaded[0].routine_url.is_none());
2617 std::fs::write(dir.path().join("a.json"), r#"{"bot_name":"Courier","session_id":"web-courier","routine_url":"http://127.0.0.1:9/h"}"#).expect("write");
2618 assert!(load_session_records(dir.path()).is_err(), "a URL in the record is still refused");
2619 }
2620
2621 use super::*;
2622
2623 #[test]
2624 fn web_client_agents_dir_is_only_the_env_path() {
2625 assert!(explicit_agents_dir(|_| None).is_none());
2626 let missing = std::env::temp_dir().join(format!(
2627 "m4a-missing-agents-{}",
2628 std::process::id()
2629 ));
2630 let _ = std::fs::remove_dir_all(&missing);
2631 assert!(explicit_agents_dir(|key| {
2632 (key == AGENTS_DIR_ENV).then(|| missing.display().to_string())
2633 })
2634 .is_none());
2635 let present = std::env::temp_dir().join(format!(
2636 "m4a-present-agents-{}-{}",
2637 std::process::id(),
2638 std::time::SystemTime::now()
2639 .duration_since(std::time::UNIX_EPOCH)
2640 .expect("clock")
2641 .as_nanos()
2642 ));
2643 std::fs::create_dir_all(&present).expect("dir");
2644 let got = explicit_agents_dir(|key| {
2645 (key == AGENTS_DIR_ENV).then(|| present.display().to_string())
2646 });
2647 assert_eq!(got.as_deref(), Some(present.as_path()));
2648 let _ = std::fs::remove_dir_all(&present);
2649 }
2650
2651 #[test]
2652 fn rescan_opens_only_bots_without_a_session() {
2653 let root = Path::new("/tmp/m4a-rescan-test");
2654 let agent = |name: &str, id: &str| {
2655 let mut session = HostSession::new(name, id);
2656 session.agent_id = Some(id.to_string());
2657 session
2658 };
2659 let found = vec![
2660 agent("alice", "a-hatch"),
2661 agent("m4a-proba2", "a-proba2"),
2662 agent("skipme", "a-skip"),
2663 agent("aliased", "a-alias"),
2664 ];
2665 let held = vec![
2666 (crate::session_store_dir(root, "a-hatch"), Some("alice".to_string())),
2667 (crate::session_store_dir(root, "old-alias"), None),
2668 ];
2669 let fresh = unopened_agent_sessions(
2670 found,
2671 &["skipme".to_string()],
2672 &[("a-alias".to_string(), "old-alias".to_string())],
2673 root,
2674 &held,
2675 );
2676 let nicks: Vec<&str> = fresh.iter().map(|(_, nick)| nick.as_str()).collect();
2677 assert_eq!(nicks, vec!["m4a-proba2"]);
2678 assert_eq!(fresh[0].0.session_id, "a-proba2");
2679 }
2680
2681 #[test]
2682 fn session_directory_is_name_and_id_only() {
2683 let dir = std::env::temp_dir().join(format!(
2684 "m4a-sessions-{}-{}",
2685 std::process::id(),
2686 std::time::SystemTime::now()
2687 .duration_since(std::time::UNIX_EPOCH)
2688 .expect("clock")
2689 .as_nanos()
2690 ));
2691 std::fs::create_dir_all(&dir).expect("dir");
2692 std::fs::write(
2693 dir.join("alice.json"),
2694 r#"{"bot_name":"Alice","session_id":"web-alice"}"#,
2695 )
2696 .expect("write");
2697 std::fs::write(
2698 dir.join("chief.json"),
2699 "{\"bot_name\":\"Привет мир\",\"session_id\":\"web-chief\"}",
2700 )
2701 .expect("write");
2702 let loaded = load_session_records(&dir).expect("records");
2703 assert_eq!(loaded.len(), 2);
2704 assert_eq!(loaded[1].bot_name, "Привет мир");
2705 assert_eq!(loaded[0].bot_name, "Alice");
2706 assert!(loaded[1].agent_id.is_none());
2707 assert!(loaded[0].agent_id.is_none());
2708 assert!(loaded[1].routine_url.is_none());
2709 assert!(loaded[1].routine_bearer.is_none());
2710 assert!(loaded[1].invite.is_none());
2711
2712 std::fs::write(
2713 dir.join("leaked.json"),
2714 r#"{"bot_name":"Courier","session_id":"web-courier","routine_url":"http://127.0.0.1/hook","routine_bearer":"not-a-file"}"#,
2715 )
2716 .expect("write");
2717 let refused = load_session_records(&dir).expect_err("webhook file");
2718 let text = refused.to_string();
2719 assert!(!text.contains("not-a-file"));
2720 assert!(!text.contains("127.0.0.1/hook"));
2721 let _ = std::fs::remove_dir_all(&dir);
2722 }
2723
2724 #[test]
2725 fn web_client_does_not_copy_one_routine_onto_every_session_and_node_cli_refuses_it() {
2726 let dir = std::env::temp_dir().join(format!(
2727 "m4a-split-{}-{}",
2728 std::process::id(),
2729 std::time::SystemTime::now()
2730 .duration_since(std::time::UNIX_EPOCH)
2731 .expect("clock")
2732 .as_nanos()
2733 ));
2734 std::fs::create_dir_all(&dir).expect("dir");
2735 let record = dir.join("alice.json");
2736 let body = r#"{"bot_name":"Alice","session_id":"web-alice"}"#;
2737 std::fs::write(&record, body).expect("write");
2738 let routine = "http://127.0.0.1:9/routine";
2739 let bearer = "host-injected-bearer";
2740 let sessions = load_web_sessions(&dir, |key| match key {
2741 crate::ROUTINE_URL_ENV => Some(routine.to_string()),
2742 crate::ROUTINE_BEARER_ENV => Some(bearer.to_string()),
2743 crate::LEADER_SOCK_ENV => Some("/tmp/leader.sock".to_string()),
2744 _ => None,
2745 })
2746 .expect("web sessions");
2747 assert_eq!(sessions.len(), 1);
2748 assert!(sessions[0].routine_url.is_none());
2749 assert!(sessions[0].routine_bearer.is_none());
2750 assert_eq!(std::fs::read_to_string(&record).expect("reread"), body);
2751 let wake = crate::SessionWake::web_from_lookup(|key| match key {
2752 crate::ROUTINE_URL_ENV => Some(routine.to_string()),
2753 crate::LEADER_SOCK_ENV => Some("/tmp/leader.sock".to_string()),
2754 _ => None,
2755 });
2756 assert!(wake.leader_sock.is_none());
2757 assert!(wake.leader_cwd.is_none());
2758
2759 std::fs::write(
2760 dir.join("leaked.json"),
2761 r#"{"bot_name":"Courier","session_id":"web-courier","routine_url":"http://127.0.0.1:9/from-file","routine_bearer":"file-bearer"}"#,
2762 )
2763 .expect("leak");
2764 let refused = load_web_sessions(&dir, |_| None).expect_err("json routine");
2765 let text = refused.to_string();
2766 assert!(!text.contains("from-file"));
2767 assert!(!text.contains("file-bearer"));
2768
2769 let node = crate::OpenedStore::connect_node_from_lookup(
2770 |key| match key {
2771 crate::ROUTINE_URL_ENV => Some(routine.to_string()),
2772 crate::ROUTINE_BEARER_ENV => Some(bearer.to_string()),
2773 crate::LEADER_SOCK_ENV => Some("/tmp/leader.sock".to_string()),
2774 _ => None,
2775 },
2776 None,
2777 );
2778 let err = match node {
2779 Ok(_) => panic!("node cli accepted a routine url"),
2780 Err(err) => err,
2781 };
2782 assert!(matches!(err, crate::ShellError::NodeRoutine));
2783 let text = err.to_string();
2784 assert!(!text.contains(routine));
2785 assert!(!text.contains(bearer));
2786 assert!(!text.contains("leader.sock"));
2787
2788 let node = crate::SessionWake::node_from_lookup(|key| match key {
2789 crate::LEADER_SOCK_ENV => Some("/tmp/node-leader.sock".to_string()),
2790 crate::LEADER_CWD_ENV => Some("/tmp/node".to_string()),
2791 _ => None,
2792 })
2793 .expect("node leader");
2794 assert!(node.routine_url.is_none());
2795 assert!(node.routine_bearer.is_none());
2796 assert_eq!(
2797 node.leader_sock.as_deref(),
2798 Some(std::path::Path::new("/tmp/node-leader.sock"))
2799 );
2800 assert_eq!(node.leader_cwd.as_deref(), Some("/tmp/node"));
2801 let _ = std::fs::remove_dir_all(&dir);
2802 }
2803
2804 #[test]
2805 fn mirrors_follow_the_nick_folder_and_keys_wait_for_the_bot_routine() {
2806 use std::collections::HashSet;
2807 use std::io::{Read, Write};
2808 use std::net::TcpListener;
2809 use std::sync::atomic::{AtomicBool, Ordering};
2810 use std::sync::{Arc, Mutex};
2811 use std::thread;
2812
2813 struct Card {
2814 agent: String,
2815 id: String,
2816 name: String,
2817 trigger: &'static str,
2818 enabled: bool,
2819 }
2820 #[derive(Default)]
2821 struct Gateway {
2822 creates: usize,
2823 deletes: usize,
2824 cards: Vec<Card>,
2825 backend: HashSet<(String, String)>,
2827 clash: HashSet<String>,
2829 agents_seen: HashSet<String>,
2830 profiles: Vec<(String, serde_json::Value)>,
2831 }
2832
2833 fn read_http(sock: &mut std::net::TcpStream) -> Option<(String, Vec<u8>)> {
2834 let _ = sock.set_read_timeout(Some(std::time::Duration::from_secs(2)));
2835 let mut buf = Vec::new();
2836 let mut tmp = [0u8; 2048];
2837 loop {
2838 let n = sock.read(&mut tmp).unwrap_or(0);
2839 if n == 0 {
2840 return None;
2841 }
2842 buf.extend_from_slice(&tmp[..n]);
2843 let Some(end) = buf.windows(4).position(|w| w == b"\r\n\r\n") else {
2844 continue;
2845 };
2846 let headers = String::from_utf8_lossy(&buf[..end]).to_string();
2847 let length = headers
2848 .lines()
2849 .find_map(|line| {
2850 let (name, value) = line.split_once(':')?;
2851 name.eq_ignore_ascii_case("content-length")
2852 .then(|| value.trim().parse::<usize>().ok())
2853 .flatten()
2854 })
2855 .unwrap_or(0);
2856 if buf.len() >= end + 4 + length {
2857 return Some((headers, buf[end + 4..end + 4 + length].to_vec()));
2858 }
2859 }
2860 }
2861 fn reply(status: &str, body: &serde_json::Value) -> Vec<u8> {
2862 let payload = serde_json::to_vec(body).expect("json");
2863 let mut out = format!(
2864 "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
2865 payload.len()
2866 )
2867 .into_bytes();
2868 out.extend(payload);
2869 out
2870 }
2871 fn cards_of(gate: &Gateway, agent: &str) -> serde_json::Value {
2872 serde_json::Value::Array(
2873 gate.cards
2874 .iter()
2875 .filter(|card| card.agent == agent)
2876 .map(|card| {
2877 serde_json::json!({
2878 "id": card.id,
2879 "name": card.name,
2880 "trigger": {"type": card.trigger},
2881 "isEnabled": card.enabled,
2882 })
2883 })
2884 .collect(),
2885 )
2886 }
2887
2888 let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
2889 listener.set_nonblocking(true).expect("nonblocking");
2890 let port = listener.local_addr().expect("addr").port();
2891 let state = Arc::new(Mutex::new(Gateway::default()));
2892 {
2893 let mut gate = state.lock().expect("gate");
2894 gate.backend.insert(("agent-h".into(), "alice".into()));
2895 gate.clash.insert("agent-x".into());
2896 gate.cards.push(Card {
2897 agent: "agent-k".into(),
2898 id: "cron-bot".into(),
2899 name: "cron_bot".into(),
2900 trigger: "cron",
2901 enabled: true,
2902 });
2903 }
2904 let shared = Arc::clone(&state);
2905 let token = "gw-test-token";
2906 let done = Arc::new(AtomicBool::new(false));
2907 let flag = Arc::clone(&done);
2908 let server = thread::spawn(move || {
2909 while !flag.load(Ordering::Relaxed) {
2910 let (mut sock, _) = match listener.accept() {
2911 Ok(pair) => pair,
2912 Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
2913 thread::sleep(std::time::Duration::from_millis(5));
2914 continue;
2915 }
2916 Err(_) => break,
2917 };
2918 let _ = sock.set_nonblocking(false);
2919 let Some((headers, body)) = read_http(&mut sock) else {
2920 continue;
2921 };
2922 let lower = headers.to_ascii_lowercase();
2923 let authorized = lower.contains(&format!("authorization: bearer {token}"));
2924 let request: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
2925 let agent = request["id"].as_str().unwrap_or("").to_string();
2926 let mut gate = shared.lock().expect("gate");
2927 gate.agents_seen.insert(agent.clone());
2928 let response = if !authorized {
2929 reply("401 Unauthorized", &serde_json::json!({}))
2930 } else if lower.starts_with("post /api/getagentautomations ") {
2931 reply("200 OK", &cards_of(&gate, &agent))
2932 } else if lower.starts_with("post /api/createagentautomation ") {
2933 let spec = &request["spec"];
2934 assert_eq!(spec["trigger"]["type"], "webhook");
2935 assert_eq!(spec["isEnabled"], false, "mirror must be disabled");
2936 let name = spec["name"].as_str().unwrap_or("").to_string();
2937 let prompt = spec["prompt"].as_str().unwrap_or("");
2938 assert!(!prompt.is_empty());
2939 assert!(!prompt.contains("http"));
2940 gate.creates += 1;
2941 let folder = crate::nick::routine_folder_id(&name).expect("slug");
2942 let id = if gate.clash.contains(&agent) {
2943 format!("{folder}-2")
2944 } else {
2945 folder
2946 };
2947 gate.cards.push(Card {
2948 agent: agent.clone(),
2949 id,
2950 name,
2951 trigger: "webhook",
2952 enabled: false,
2953 });
2954 reply("200 OK", &cards_of(&gate, &agent))
2955 } else if lower.starts_with("post /api/updateagent ") {
2956 gate.profiles
2957 .push((agent.clone(), request["profile"].clone()));
2958 reply("200 OK", &serde_json::json!({"id": agent}))
2959 } else if lower.starts_with("post /api/deleteagentautomation ") {
2960 let id = request["automationId"].as_str().unwrap_or("");
2961 gate.deletes += 1;
2962 gate.cards
2963 .retain(|card| !(card.agent == agent && card.id == id));
2964 reply("200 OK", &cards_of(&gate, &agent))
2965 } else if lower.starts_with("post /api/getautomationwebhookcredential ") {
2966 let id = request["automationId"].as_str().unwrap_or("").to_string();
2967 let local = gate
2968 .cards
2969 .iter()
2970 .find(|card| card.agent == agent && card.id == id);
2971 match local {
2972 None => reply(
2973 "500 Internal Server Error",
2974 &serde_json::json!({"error": format!("Automation not found: {id}")}),
2975 ),
2976 Some(card) if card.trigger != "webhook" => reply(
2977 "500 Internal Server Error",
2978 &serde_json::json!({"error": "Automation is not webhook-triggered"}),
2979 ),
2980 Some(_) => {
2981 let minted = gate.backend.contains(&(agent.clone(), id.clone()));
2982 reply(
2983 "200 OK",
2984 &serde_json::json!({
2985 "url": format!("https://backend.invalid/automations/webhook/{agent}-{id}"),
2986 "key": minted.then(|| format!("key-{agent}-{id}")),
2987 }),
2988 )
2989 }
2990 }
2991 } else {
2992 reply("404 Not Found", &serde_json::json!({}))
2993 };
2994 drop(gate);
2995 let _ = sock.write_all(&response);
2996 }
2997 });
2998
2999 let dir = std::env::temp_dir().join(format!(
3000 "m4a-mirror-{}-{}",
3001 std::process::id(),
3002 std::time::SystemTime::now()
3003 .duration_since(std::time::UNIX_EPOCH)
3004 .expect("clock")
3005 .as_nanos()
3006 ));
3007 let agents = dir.join("agents");
3008 let store_root = dir.join("stores");
3009 let describe = |id: &str| {
3010 if id == "agent-c" {
3011 "about the chief"
3012 } else {
3013 ""
3014 }
3015 };
3016 for (id, name) in [
3017 ("agent-h", "Alice"),
3018 ("agent-c", "Привет мир"),
3019 ("agent-s", "Свой браузер"),
3020 ("agent-x", "Clash Bot"),
3021 ("agent-k", "Cron Bot"),
3022 ] {
3023 std::fs::create_dir_all(agents.join(id)).expect("agent dir");
3024 std::fs::write(
3025 agents.join(id).join("profile.json"),
3026 serde_json::to_vec(&serde_json::json!({"name": name, "description": describe(id)}))
3027 .expect("profile"),
3028 )
3029 .expect("profile");
3030 }
3031 let gateway = dir.join("gateway-file.json");
3032 std::fs::write(
3033 &gateway,
3034 format!(r#"{{"host":"192.0.2.1","port":{port},"scheme":"http","token":"{token}"}}"#),
3035 )
3036 .expect("gateway");
3037 let skip = parse_skip_nicks(" svoi-brauzer , ");
3038 assert_eq!(skip, vec!["svoi-brauzer".to_string()]);
3039 let options = WakeOptions {
3040 skip_nicks: skip.clone(),
3041 store_root: Some(store_root.clone()),
3042 profile_note: false,
3043 session_ids: Vec::new(),
3044 };
3045
3046 let status_of = |reports: &[RoutineReport], agent: &str| {
3047 reports
3048 .iter()
3049 .find(|report| report.agent_id == agent)
3050 .map(|report| report.status.clone())
3051 };
3052 let first =
3053 ensure_agent_webhook_routines(&agents, &gateway, None, &options).expect("first pass");
3054 assert_eq!(status_of(&first, "agent-h"), Some(WakeStatus::Ready));
3055 assert_eq!(
3056 status_of(&first, "agent-c"),
3057 Some(WakeStatus::AwaitingBackend)
3058 );
3059 assert!(matches!(
3060 status_of(&first, "agent-x"),
3061 Some(WakeStatus::Failed(_))
3062 ));
3063 assert!(matches!(
3064 status_of(&first, "agent-k"),
3065 Some(WakeStatus::Failed(_))
3066 ));
3067 assert_eq!(status_of(&first, "agent-s"), None);
3068 let chief = first
3069 .iter()
3070 .find(|r| r.agent_id == "agent-c")
3071 .expect("chief");
3072 assert_eq!(chief.nick, "privet-mir");
3073 assert_eq!(chief.folder_id, "privet-mir");
3074 let shown = format!("{first:?}");
3075 assert!(!shown.contains("key-"));
3076 assert!(!shown.contains("automations/webhook"));
3077 {
3078 let gate = state.lock().expect("gate");
3079 assert_eq!(gate.creates, 3);
3082 assert_eq!(gate.deletes, 1);
3083 assert!(!gate.agents_seen.contains("agent-s"));
3084 assert!(gate.cards.iter().all(|card| card.agent != "agent-x"));
3085 let cron = gate
3086 .cards
3087 .iter()
3088 .find(|card| card.agent == "agent-k")
3089 .expect("cron");
3090 assert_eq!(cron.trigger, "cron");
3091 assert!(cron.enabled);
3092 let mirror = gate
3093 .cards
3094 .iter()
3095 .find(|card| card.agent == "agent-h")
3096 .expect("mirror");
3097 assert_eq!(
3098 (mirror.id.as_str(), mirror.name.as_str()),
3099 ("alice", "alice")
3100 );
3101 let chief_mirror = gate
3102 .cards
3103 .iter()
3104 .find(|card| card.agent == "agent-c")
3105 .expect("chief mirror");
3106 assert_eq!(chief_mirror.name, "privet-mir");
3108 assert_eq!(chief_mirror.id, "privet-mir");
3109 assert!(!mirror.enabled);
3110 }
3111 let alice_file = keychain_path(&store_root, "agent-h");
3113 let stored: serde_json::Value =
3114 serde_json::from_slice(&std::fs::read(&alice_file).expect("keychain"))
3115 .expect("keychain json");
3116 assert_eq!(stored["folder_id"], "alice");
3117 assert_eq!(stored["key"], "key-agent-h-alice");
3118 #[cfg(unix)]
3119 {
3120 use std::os::unix::fs::PermissionsExt;
3121 let mode = std::fs::metadata(&alice_file)
3122 .expect("meta")
3123 .permissions()
3124 .mode();
3125 assert_eq!(mode & 0o777, 0o600);
3126 }
3127 assert!(!keychain_path(&store_root, "agent-c").exists());
3128
3129 let second =
3131 ensure_agent_webhook_routines(&agents, &gateway, None, &options).expect("second pass");
3132 assert_eq!(
3133 status_of(&second, "agent-c"),
3134 Some(WakeStatus::AwaitingBackend)
3135 );
3136 assert_eq!(
3137 state.lock().expect("gate").creates,
3138 4,
3139 "only the clash bot retries"
3140 );
3141 assert!(state.lock().expect("gate").profiles.is_empty());
3142 let noted = WakeOptions {
3145 profile_note: true,
3146 ..options.clone()
3147 };
3148 ensure_agent_webhook_routines(&agents, &gateway, None, ¬ed).expect("note pass");
3149 {
3150 let gate = state.lock().expect("gate");
3151 assert_eq!(gate.profiles.len(), 1);
3152 let (agent, profile) = &gate.profiles[0];
3153 assert_eq!(agent, "agent-c");
3154 assert_eq!(profile["name"], "Привет мир");
3155 let description = profile["description"].as_str().unwrap_or("");
3156 assert!(description.starts_with("about the chief"));
3157 assert!(description.contains("routine named \"privet-mir\""));
3158 assert!(!description.contains("http"));
3159 }
3160 state.lock().expect("gate").creates = 4;
3161 state
3164 .lock()
3165 .expect("gate")
3166 .backend
3167 .insert(("agent-c".into(), "privet-mir".into()));
3168 let third =
3169 ensure_agent_webhook_routines(&agents, &gateway, None, &options).expect("third pass");
3170 assert_eq!(status_of(&third, "agent-c"), Some(WakeStatus::Ready));
3171 assert_eq!(state.lock().expect("gate").creates, 5);
3172 assert!(keychain_path(&store_root, "agent-c").exists());
3173
3174 let gateway_path = gateway.display().to_string();
3176 let root_text = store_root.display().to_string();
3177 let sessions = load_web_agents(&agents, |key| match key {
3178 GATEWAY_FILE_ENV => Some(gateway_path.clone()),
3179 SKIP_NICKS_ENV => Some("svoi-brauzer".to_string()),
3180 crate::STORE_ROOT_ENV => Some(root_text.clone()),
3181 _ => None,
3182 })
3183 .expect("web agents");
3184 assert_eq!(sessions.len(), 4);
3185 let alice = sessions
3186 .iter()
3187 .find(|s| s.session_id == "agent-h")
3188 .expect("h");
3189 assert!(alice
3190 .routine_url
3191 .as_deref()
3192 .unwrap_or("")
3193 .starts_with("https://"));
3194 assert!(alice.routine_bearer.is_some());
3195 assert!(!format!("{alice:?}").contains("key-"));
3196
3197 let mut offline = vec![HostSession::new("Alice", "agent-h")];
3199 offline[0].agent_id = Some("agent-h".to_string());
3200 let mut renamed = HostSession::new("Alice Two", "agent-h");
3201 renamed.agent_id = Some("agent-h".to_string());
3202 offline.push(renamed);
3203 attach_webhook_routines(
3204 &mut offline,
3205 &dir.join("absent.json"),
3206 None,
3207 Some(&store_root),
3208 )
3209 .expect("offline");
3210 assert_eq!(
3211 offline[0].routine_bearer.as_deref(),
3212 Some("key-agent-h-alice")
3213 );
3214 assert!(offline[1].routine_url.is_none());
3215 let mut bare = vec![HostSession::new("Alice", "agent-h")];
3216 attach_webhook_routines(&mut bare, &dir.join("absent.json"), None, None).expect("bare");
3217 assert!(bare[0].routine_url.is_none());
3218
3219 done.store(true, Ordering::Relaxed);
3220 let _ = server.join();
3221 let _ = std::fs::remove_dir_all(&dir);
3222 }
3223
3224 #[test]
3225 fn wake_note_is_appended_once_and_replaced_in_place() {
3226 let first = with_wake_note("Runs the alice.", "alice").expect("added");
3227 assert!(first.starts_with("Runs the alice.\n\n<!-- mail4agent:wake -->"));
3228 assert!(first.ends_with("<!-- /mail4agent:wake -->"));
3229 assert!(with_wake_note(&first, "alice").is_none());
3230 let renamed = with_wake_note(&first, "alice-two").expect("replaced");
3231 assert_eq!(renamed.matches("<!-- mail4agent:wake -->").count(), 1);
3232 assert!(renamed.contains("\"alice-two\""));
3233 assert!(!renamed.contains("\"alice\""));
3234 assert!(renamed.starts_with("Runs the alice."));
3235 assert_eq!(with_wake_note("", "carol").expect("empty"), wake_note("carol"));
3236 }
3237
3238 #[test]
3239 fn agents_directory_becomes_sessions_without_hardcoded_ids() {
3240 let dir = std::env::temp_dir().join(format!(
3241 "m4a-agents-{}-{}",
3242 std::process::id(),
3243 std::time::SystemTime::now()
3244 .duration_since(std::time::UNIX_EPOCH)
3245 .expect("clock")
3246 .as_nanos()
3247 ));
3248 let alice = dir.join("agent-alice");
3249 let chief = dir.join("agent-chief");
3250 std::fs::create_dir_all(&alice).expect("dir");
3251 std::fs::create_dir_all(&chief).expect("dir");
3252 std::fs::write(
3253 alice.join("profile.json"),
3254 r#"{"name":"Alice","description":"x"}"#,
3255 )
3256 .expect("profile");
3257 std::fs::write(
3258 chief.join("profile.json"),
3259 concat!(
3260 "{\"name\":\"",
3261 "Привет мир",
3262 "\",\"description\":\"x\"}"
3263 ),
3264 )
3265 .expect("profile");
3266 std::fs::write(dir.join("active-agent.json"), "{}").expect("skip file");
3267 let loaded = load_agents_dir(&dir).expect("agents");
3268 assert_eq!(loaded.len(), 2);
3269 assert_eq!(loaded[1].session_id, "agent-chief");
3270 assert_eq!(loaded[1].agent_id.as_deref(), Some("agent-chief"));
3271 assert_eq!(loaded[1].bot_name, "Привет мир");
3272 assert_eq!(
3273 routine_name_for(&loaded[1]).as_deref(),
3274 Some("privet-mir")
3275 );
3276 assert_ne!(
3277 routine_name_for(&loaded[1]).as_deref(),
3278 Some(loaded[1].session_id.as_str())
3279 );
3280 assert_eq!(loaded[0].bot_name, "Alice");
3281 assert_eq!(routine_name_for(&loaded[0]).as_deref(), Some("alice"));
3282 assert!(loaded.iter().all(|session| session.routine_url.is_none()));
3283 let _ = std::fs::remove_dir_all(&dir);
3284 }
3285}
3286
3287pub const REQUIRE_SESSION_CAP_ENV: &str = "M4A_REQUIRE_SESSION_CAP";
3289pub const ACCOUNTS_ROOT_ENV: &str = "M4A_ACCOUNTS_ROOT";
3291
3292pub fn session_gate(store_root: Option<&Path>, nick: &str, cap: Option<&str>, peer_uid: Option<u32>) -> Result<(), String> {
3297 let require = std::env::var(REQUIRE_SESSION_CAP_ENV).is_ok_and(|v| v.trim() == "1");
3298 let base = std::env::var(ACCOUNTS_ROOT_ENV).ok().filter(|v| !v.is_empty()).map(PathBuf::from).or_else(|| store_root.map(Path::to_path_buf));
3299 let exists = base.as_deref().is_some_and(|b| b.join(".m4a-accounts").join("registry.json").exists());
3300 if !exists {
3301 return if require { Err(format!("{nick}: no session account (capability required)")) } else { Ok(()) };
3302 }
3303 let reg = crate::session_account::AccountRegistry::open(base.as_deref().unwrap_or(Path::new("."))).map_err(|e| e.to_string())?;
3304 reg.gate(nick, cap, peer_uid, require).map_err(|e| format!("{nick}: {e}"))
3305}
3306
3307pub fn account_sessions(base: &Path) -> Vec<HostSession> {
3311 if !base.join(".m4a-accounts").join("registry.json").exists() {
3312 return Vec::new();
3313 }
3314 let Ok(reg) = crate::session_account::AccountRegistry::open(base) else { return Vec::new() };
3315 reg.accounts()
3316 .filter(|(_, e)| e.state == crate::session_account::ServerState::Enrolled)
3317 .map(|(r, e)| {
3318 let mut s = HostSession::new(e.nick.clone(), r.to_string());
3319 s.store_root = Some(reg.store_root_of(r));
3320 s
3321 })
3322 .collect()
3323}