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