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}
140
141impl HostSession {
142 pub fn new(bot_name: impl Into<String>, session_id: impl Into<String>) -> Self {
144 Self {
145 bot_name: bot_name.into(),
146 session_id: session_id.into(),
147 agent_id: None,
148 routine_url: None,
149 routine_bearer: None,
150 invite: None,
151 routine_file: None,
152 tier: m4a_agent::BackendKind::Server,
153 }
154 }
155
156 pub fn with_routine_file(mut self, path: impl Into<PathBuf>) -> Self {
158 self.routine_file = Some(path.into());
159 self
160 }
161
162 pub(crate) fn config(&self, url: &str, store_root: &Path) -> Result<SessionConfig, ShellError> {
163 Ok(SessionConfig::new_identity(url, self.tier, &self.session_id, store_root, self.invite.clone())?.with_nick_request(&self.bot_name))
164 }
165
166 pub fn with_invite(mut self, invite: impl Into<String>) -> Self {
168 self.invite = Some(invite.into());
169 self
170 }
171
172 pub fn with_tier(mut self, tier: m4a_agent::BackendKind) -> Self {
174 self.tier = tier;
175 self
176 }
177
178 pub fn with_routine(mut self, url: impl Into<String>, bearer: Option<String>) -> Self {
180 self.routine_url = Some(url.into());
181 self.routine_bearer = bearer.filter(|token| !token.is_empty());
182 self
183 }
184
185}
186
187impl std::fmt::Debug for HostSession {
188 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189 f.debug_struct("HostSession")
190 .field("bot_name", &self.bot_name)
191 .field("session_id", &self.session_id)
192 .field("agent_id", &self.agent_id)
193 .field("routine_url", &self.routine_url.as_ref().map(|_| "[set]"))
194 .field(
195 "routine_bearer",
196 &self.routine_bearer.as_ref().map(|_| "[redacted]"),
197 )
198 .finish()
199 }
200}
201
202#[derive(serde::Deserialize)]
203#[serde(deny_unknown_fields)]
204struct SessionFile {
205 bot_name: String,
206 session_id: String,
207 #[serde(default)]
209 agent_id: Option<String>,
210 #[serde(default)]
213 routine_file: Option<String>,
214 #[serde(default)]
216 tier: Option<String>,
217}
218
219pub fn load_session_records(dir: &Path) -> Result<Vec<HostSession>, ShellError> {
223 if !dir.is_dir() {
224 return Err(ShellError::SessionList(
225 "session directory is missing".to_string(),
226 ));
227 }
228 let mut paths = Vec::new();
229 for entry in std::fs::read_dir(dir)? {
230 let entry = entry?;
231 let path = entry.path();
232 if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
233 continue;
234 }
235 paths.push(path);
236 }
237 paths.sort();
238 if paths.is_empty() {
239 return Err(ShellError::SessionList(
240 "session directory has no records".to_string(),
241 ));
242 }
243 let mut out = Vec::with_capacity(paths.len());
244 for path in paths {
245 let text = std::fs::read_to_string(&path)
246 .map_err(|err| ShellError::SessionList(clip_public(err.to_string())))?;
247 let file: SessionFile = serde_json::from_str(&text)
248 .map_err(|err| ShellError::SessionList(clip_public(err.to_string())))?;
249 let mut session = HostSession::new(file.bot_name, file.session_id);
250 session.agent_id = file
251 .agent_id
252 .map(|id| id.trim().to_string())
253 .filter(|id| !id.is_empty());
254 session.tier = match file.tier.or_else(|| std::env::var("M4A_TIER").ok()).as_deref().map(str::trim) {
255 None | Some("") | Some("server") => m4a_agent::BackendKind::Server,
256 Some("matrix") => m4a_agent::BackendKind::Matrix,
257 Some(other) => return Err(ShellError::SessionList(format!("tier {other:?} is not server or matrix"))),
258 };
259 session.routine_file = file.routine_file.map(|p| PathBuf::from(p.trim())).filter(|p| !p.as_os_str().is_empty());
260 out.push(session);
261 }
262 Ok(out)
263}
264
265#[derive(serde::Deserialize)]
266struct AgentProfileFile {
267 name: String,
268}
269
270pub fn load_agents_dir(dir: &Path) -> Result<Vec<HostSession>, ShellError> {
274 if !dir.is_dir() {
275 return Err(ShellError::SessionList(
276 "agents directory is missing".to_string(),
277 ));
278 }
279 let mut ids = Vec::new();
280 for entry in std::fs::read_dir(dir)? {
281 let entry = entry?;
282 if !entry.file_type()?.is_dir() {
283 continue;
284 }
285 let name = entry.file_name();
286 let Some(id) = name.to_str() else {
287 continue;
288 };
289 if id.is_empty() || id.starts_with('.') {
290 continue;
291 }
292 ids.push(id.to_string());
293 }
294 ids.sort();
295 if ids.is_empty() {
296 return Err(ShellError::SessionList(
297 "agents directory has no agents".to_string(),
298 ));
299 }
300 let mut out = Vec::with_capacity(ids.len());
301 for id in ids {
302 let profile_path = dir.join(&id).join("profile.json");
303 if !profile_path.is_file() {
304 continue;
305 }
306 let text = std::fs::read_to_string(&profile_path)
307 .map_err(|err| ShellError::SessionList(clip_public(err.to_string())))?;
308 let profile: AgentProfileFile = serde_json::from_str(&text)
309 .map_err(|err| ShellError::SessionList(clip_public(err.to_string())))?;
310 let bot_name = profile.name.trim().to_string();
311 if bot_name.is_empty() {
312 continue;
313 }
314 let mut session = HostSession::new(bot_name, id.clone());
315 session.agent_id = Some(id);
316 out.push(session);
317 }
318 if out.is_empty() {
319 return Err(ShellError::SessionList(
320 "agents directory has no profiles".to_string(),
321 ));
322 }
323 Ok(out)
324}
325
326fn explicit_agents_dir(mut get: impl FnMut(&str) -> Option<String>) -> Option<PathBuf> {
331 let path = PathBuf::from(get(AGENTS_DIR_ENV)?);
332 path.is_dir().then_some(path)
333}
334
335fn resolve_agents_dir(mut get: impl FnMut(&str) -> Option<String>) -> Option<PathBuf> {
339 if let Some(path) = get(AGENTS_DIR_ENV).filter(|value| !value.is_empty()) {
340 let path = PathBuf::from(path);
341 if path.is_dir() {
342 return Some(path);
343 }
344 return None;
345 }
346 let default = under_home(DEFAULT_AGENTS_DIR);
347 if default.is_dir() {
348 Some(default)
349 } else {
350 None
351 }
352}
353
354fn load_web_sessions(
362 dir: &Path,
363 mut get: impl FnMut(&str) -> Option<String>,
364) -> Result<Vec<HostSession>, ShellError> {
365 let sessions = load_session_records(dir)?;
366 let mut sessions = drop_skipped(sessions, &skip_nicks(&mut get));
367 attach_routines_from_env(&mut sessions, &mut get)?;
368 Ok(sessions)
369}
370
371fn load_web_agents(
373 dir: &Path,
374 mut get: impl FnMut(&str) -> Option<String>,
375) -> Result<Vec<HostSession>, ShellError> {
376 let sessions = load_agents_dir(dir)?;
377 let mut sessions = drop_skipped(sessions, &skip_nicks(&mut get));
378 apply_session_ids(
379 &mut sessions,
380 &parse_session_ids(get(SESSION_IDS_ENV).as_deref().unwrap_or("")),
381 );
382 attach_routines_from_env(&mut sessions, &mut get)?;
383 Ok(sessions)
384}
385
386fn attach_routines_from_env(
387 sessions: &mut [HostSession],
388 get: &mut impl FnMut(&str) -> Option<String>,
389) -> Result<(), ShellError> {
390 let store_root = get(STORE_ROOT_ENV).map(PathBuf::from);
391 let Some(path) = get(GATEWAY_FILE_ENV).filter(|value| !value.is_empty()) else {
392 return Ok(());
393 };
394 let token = get(GATEWAY_TOKEN_ENV).filter(|value| !value.is_empty());
395 attach_webhook_routines(
396 sessions,
397 Path::new(&path),
398 token.as_deref(),
399 store_root.as_deref(),
400 )?;
401 Ok(())
402}
403
404fn unopened_agent_sessions(
408 found: Vec<HostSession>,
409 skip: &[String],
410 aliases: &[(String, String)],
411 store_root: &Path,
412 held: &[(PathBuf, Option<String>)],
413) -> Vec<(HostSession, String)> {
414 let mut sessions = drop_skipped(found, skip);
415 apply_session_ids(&mut sessions, aliases);
416 sessions
417 .into_iter()
418 .filter_map(|session| {
419 let nick = routine_name_for(&session)?;
420 let dir = crate::session_store_dir(store_root, &session.session_id);
421 let taken = held.iter().any(|(held_dir, held_nick)| {
422 *held_dir == dir
423 || held_nick
424 .as_deref()
425 .is_some_and(|held| held.eq_ignore_ascii_case(&nick))
426 });
427 (!taken).then_some((session, nick))
428 })
429 .collect()
430}
431
432pub const SESSION_IDS_ENV: &str = "M4A_SESSION_IDS";
437
438pub(crate) fn parse_session_ids(raw: &str) -> Vec<(String, String)> {
439 raw.split(',')
440 .filter_map(|pair| {
441 let (agent, session) = pair.split_once('=')?;
442 let (agent, session) = (agent.trim(), session.trim());
443 (!agent.is_empty() && !session.is_empty())
444 .then(|| (agent.to_string(), session.to_string()))
445 })
446 .collect()
447}
448
449pub(crate) fn apply_session_ids(sessions: &mut [HostSession], aliases: &[(String, String)]) {
450 for session in sessions.iter_mut() {
451 let Some(agent_id) = session.agent_id.as_deref() else {
452 continue;
453 };
454 if let Some((_, alias)) = aliases.iter().find(|(agent, _)| agent == agent_id) {
455 session.session_id = alias.clone();
456 }
457 }
458}
459
460pub const SKIP_NICKS_ENV: &str = "M4A_SKIP_NICKS";
464
465fn skip_nicks(get: &mut impl FnMut(&str) -> Option<String>) -> Vec<String> {
466 parse_skip_nicks(get(SKIP_NICKS_ENV).as_deref().unwrap_or(""))
467}
468
469fn parse_skip_nicks(raw: &str) -> Vec<String> {
470 raw.split(',')
471 .map(|item| item.trim().to_ascii_lowercase())
472 .filter(|item| !item.is_empty())
473 .collect()
474}
475
476fn drop_skipped(sessions: Vec<HostSession>, skip: &[String]) -> Vec<HostSession> {
477 if skip.is_empty() {
478 return sessions;
479 }
480 sessions
481 .into_iter()
482 .filter(|session| match routine_name_for(session) {
483 Some(nick) => !skip.iter().any(|item| item.eq_ignore_ascii_case(&nick)),
484 None => true,
485 })
486 .collect()
487}
488
489#[derive(Debug, Clone, PartialEq, Eq)]
492pub enum WakeStatus {
493 Ready,
495 AwaitingBackend,
499 Failed(String),
501}
502
503#[derive(Debug, Clone, PartialEq, Eq)]
505pub struct RoutineReport {
506 pub agent_id: String,
508 pub nick: String,
510 pub folder_id: String,
513 pub status: WakeStatus,
515}
516
517#[derive(Debug, Clone, Default)]
520pub struct WakeOptions {
521 pub skip_nicks: Vec<String>,
523 pub store_root: Option<PathBuf>,
525 pub profile_note: bool,
528 pub session_ids: Vec<(String, String)>,
530}
531
532impl WakeOptions {
533 pub fn from_lookup(mut get: impl FnMut(&str) -> Option<String>) -> Self {
535 Self {
536 skip_nicks: skip_nicks(&mut get),
537 store_root: get(STORE_ROOT_ENV).map(PathBuf::from),
538 profile_note: get(PROFILE_NOTE_ENV).as_deref() == Some("1"),
539 session_ids: parse_session_ids(get(SESSION_IDS_ENV).as_deref().unwrap_or("")),
540 }
541 }
542}
543
544pub fn ensure_agent_webhook_routines(
552 agents_dir: &Path,
553 gateway_file: &Path,
554 token_override: Option<&str>,
555 options: &WakeOptions,
556) -> Result<Vec<RoutineReport>, ShellError> {
557 let store_root = options.store_root.as_deref();
558 let mut sessions = drop_skipped(load_agents_dir(agents_dir)?, &options.skip_nicks);
559 apply_session_ids(&mut sessions, &options.session_ids);
560 let Some(gate) = open_gateway(gateway_file, token_override)? else {
561 return Err(ShellError::Gateway("gateway file is missing".to_string()));
562 };
563 let mut reports = Vec::new();
564 for session in &sessions {
565 let Some(agent_id) = session.agent_id.as_deref().filter(|id| !id.is_empty()) else {
566 continue;
567 };
568 let Some(nick) = routine_name_for(session) else {
569 continue;
570 };
571 let outcome = ensure_wake(&gate, agent_id, &nick);
572 let folder_id = crate::nick::routine_folder_id(&nick).unwrap_or_default();
573 if let (WakeOutcome::Ready { url, key }, Some(root)) = (&outcome, store_root) {
574 save_wake(root, &session.session_id, &folder_id, url, key);
575 }
576 log_outcome(&nick, &folder_id, &outcome);
577 if options.profile_note && matches!(outcome, WakeOutcome::AwaitingBackend) {
578 note_profile(&gate, agents_dir, agent_id, &nick);
579 }
580 reports.push(RoutineReport {
581 agent_id: agent_id.to_string(),
582 nick,
583 folder_id,
584 status: outcome.status(),
585 });
586 }
587 Ok(reports)
588}
589
590pub fn ensure_agent_webhook_routines_from_env() -> Result<Vec<RoutineReport>, ShellError> {
594 let mut get = |key: &str| std::env::var(key).ok().filter(|v| !v.is_empty());
595 let agents_dir = resolve_agents_dir(&mut get)
596 .ok_or_else(|| ShellError::SessionList("agents directory is missing".to_string()))?;
597 let gateway = get(GATEWAY_FILE_ENV).unwrap_or_else(|| DEFAULT_GATEWAY_FILE.to_string());
598 let token = get(GATEWAY_TOKEN_ENV);
599 let options = WakeOptions::from_lookup(&mut get);
600 ensure_agent_webhook_routines(&agents_dir, Path::new(&gateway), token.as_deref(), &options)
601}
602
603pub const PROFILE_NOTE_ENV: &str = "M4A_BOOTSTRAP_PROFILE_NOTE";
609
610const NOTE_OPEN: &str = "<!-- mail4agent:wake -->";
611const NOTE_CLOSE: &str = "<!-- /mail4agent:wake -->";
612const DESCRIPTION_MAX: usize = 20_000;
614
615fn wake_note(routine: &str) -> String {
618 format!(
619 "{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}"
620 )
621}
622
623fn with_wake_note(description: &str, routine: &str) -> Option<String> {
627 let note = wake_note(routine);
628 if description.contains(¬e) {
629 return None;
630 }
631 if let (Some(start), Some(end)) = (description.find(NOTE_OPEN), description.find(NOTE_CLOSE)) {
632 if end > start {
633 let mut out = String::with_capacity(description.len() + note.len());
634 out.push_str(&description[..start]);
635 out.push_str(¬e);
636 out.push_str(&description[end + NOTE_CLOSE.len()..]);
637 return Some(out);
638 }
639 }
640 let base = description.trim_end();
641 Some(if base.is_empty() {
642 note
643 } else {
644 format!("{base}\n\n{note}")
645 })
646}
647
648fn note_profile(gate: &GatewayConn, agents_dir: &Path, agent_id: &str, nick: &str) {
652 match ensure_profile_note(gate, agents_dir, agent_id, nick) {
653 Ok(true) => eprintln!("mail4agent: wake {nick}: bootstrap note written to the profile"),
654 Ok(false) => {}
655 Err(err) => eprintln!("mail4agent: wake {nick}: bootstrap note not written ({err})"),
656 }
657}
658
659fn ensure_profile_note(
660 gate: &GatewayConn,
661 agents_dir: &Path,
662 agent_id: &str,
663 nick: &str,
664) -> Result<bool, ShellError> {
665 let text = std::fs::read_to_string(agents_dir.join(agent_id).join("profile.json"))
666 .map_err(|_| ShellError::Gateway("profile is unreadable".to_string()))?;
667 let profile: serde_json::Value = serde_json::from_str(&text)
668 .map_err(|_| ShellError::Gateway("profile is unreadable".to_string()))?;
669 let field = |key: &str| profile.get(key).and_then(|value| value.as_str());
670 let Some(name) = field("name").filter(|name| !name.trim().is_empty()) else {
671 return Err(ShellError::Gateway("profile has no name".to_string()));
672 };
673 let routine = crate::nick::routine_folder_id(nick)
674 .ok_or_else(|| ShellError::Gateway("nick has no routine name".to_string()))?;
675 let Some(description) = with_wake_note(field("description").unwrap_or(""), &routine) else {
676 return Ok(false);
677 };
678 if description.chars().count() > DESCRIPTION_MAX {
679 return Err(ShellError::Gateway(
680 "description would be too long".to_string(),
681 ));
682 }
683 let mut body = serde_json::json!({ "name": name, "description": description });
684 for key in ["title", "avatarShape", "avatarColor"] {
685 if let Some(value) = field(key) {
686 body[key] = serde_json::Value::String(value.to_string());
687 }
688 }
689 let (status, _) = gateway_post(
690 gate,
691 "updateAgent",
692 &serde_json::json!({ "id": agent_id, "profile": body }),
693 )?;
694 if status != 200 {
695 return Err(ShellError::Gateway(format!(
696 "gateway profile status {status}"
697 )));
698 }
699 Ok(true)
700}
701
702const DEFAULT_GATEWAY_FILE: &str = "agent-data/gateway.json";
705
706pub const GATEWAY_FILE_ENV: &str = "M4A_GATEWAY_FILE";
710
711pub const GATEWAY_TOKEN_ENV: &str = "M4A_GATEWAY_TOKEN";
714
715const 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.";
719
720pub const WAKE_KEYCHAIN_FILE: &str = "routine-wake.json";
724
725#[derive(serde::Deserialize)]
726struct GatewayFile {
727 port: u16,
728 scheme: String,
729 #[serde(default)]
730 token: Option<String>,
731}
732
733#[derive(serde::Deserialize)]
734struct AutomationCard {
735 id: String,
736 name: String,
737 #[serde(default)]
738 trigger: Option<serde_json::Value>,
739}
740
741impl AutomationCard {
742 fn is_webhook(&self) -> bool {
745 fn has_webhook(value: &serde_json::Value) -> bool {
746 match value {
747 serde_json::Value::Array(items) => items.iter().any(has_webhook),
748 serde_json::Value::Object(map) => {
749 map.get("type").and_then(|kind| kind.as_str()) == Some("webhook")
750 || map
751 .values()
752 .any(|inner| inner.is_array() && has_webhook(inner))
753 }
754 _ => false,
755 }
756 }
757 self.trigger.as_ref().map(has_webhook).unwrap_or(true)
758 }
759}
760
761#[derive(serde::Deserialize)]
762struct WebhookCredential {
763 url: String,
764 key: Option<String>,
765}
766
767#[derive(serde::Serialize, serde::Deserialize)]
768#[serde(deny_unknown_fields)]
769struct StoredWake {
770 folder_id: String,
771 url: String,
772 key: String,
773}
774
775pub(crate) fn routine_name_for(session: &HostSession) -> Option<String> {
780 crate::nick::nick_from_display_name(session.bot_name.trim()).ok()
781}
782
783struct GatewayConn {
784 client: reqwest::blocking::Client,
785 base: String,
786 token: String,
787}
788
789fn open_gateway(
790 gateway_file: &Path,
791 token_override: Option<&str>,
792) -> Result<Option<GatewayConn>, ShellError> {
793 if !gateway_file.is_file() {
794 return Ok(None);
795 }
796 let text = std::fs::read_to_string(gateway_file)
797 .map_err(|_| ShellError::Gateway("gateway file is unreadable".to_string()))?;
798 let file: GatewayFile = serde_json::from_str(&text)
799 .map_err(|_| ShellError::Gateway("gateway file is unreadable".to_string()))?;
800 let scheme = file.scheme.trim();
801 if scheme != "http" && scheme != "https" {
802 return Err(ShellError::Gateway(
803 "gateway scheme is not http or https".to_string(),
804 ));
805 }
806 if file.port == 0 {
807 return Err(ShellError::Gateway("gateway port is unset".to_string()));
808 }
809 let token = token_override
810 .map(str::trim)
811 .filter(|value| !value.is_empty())
812 .map(str::to_string)
813 .or(file.token.filter(|value| !value.trim().is_empty()));
814 let Some(token) = token else {
815 return Err(ShellError::Gateway("gateway token is unset".to_string()));
816 };
817 let base = format!("{scheme}://127.0.0.1:{}", file.port);
819 Ok(Some(GatewayConn {
820 client: gateway_client()?,
821 base,
822 token,
823 }))
824}
825
826enum WakeOutcome {
829 Ready { url: String, key: String },
830 AwaitingBackend,
831 Failed(String),
832}
833
834impl WakeOutcome {
835 fn status(&self) -> WakeStatus {
836 match self {
837 WakeOutcome::Ready { .. } => WakeStatus::Ready,
838 WakeOutcome::AwaitingBackend => WakeStatus::AwaitingBackend,
839 WakeOutcome::Failed(reason) => WakeStatus::Failed(reason.clone()),
840 }
841 }
842}
843
844fn log_outcome(nick: &str, folder_id: &str, outcome: &WakeOutcome) {
845 match outcome {
846 WakeOutcome::Ready { .. } => eprintln!("mail4agent: wake {nick} ({folder_id}): ready"),
847 WakeOutcome::AwaitingBackend => eprintln!(
848 "mail4agent: wake {nick} ({folder_id}): no key yet; the bot has not created its own routine with this folder id; will retry"
849 ),
850 WakeOutcome::Failed(reason) => {
851 eprintln!("mail4agent: wake {nick} ({folder_id}): {reason}")
852 }
853 }
854}
855
856fn ensure_wake(gate: &GatewayConn, agent_id: &str, nick: &str) -> WakeOutcome {
866 let Some(folder) = crate::nick::routine_folder_id(nick) else {
867 return WakeOutcome::Failed("nick has no routine folder id".to_string());
868 };
869 let before = match list_agent_automations(gate, agent_id) {
870 Ok(cards) => cards,
871 Err(err) => return WakeOutcome::Failed(err.to_string()),
872 };
873 match before.iter().find(|card| card.id == folder) {
874 Some(card) if !card.is_webhook() => {
875 return WakeOutcome::Failed(
876 "a routine in this folder is not webhook-triggered; left as is".to_string(),
877 );
878 }
879 Some(_) => {}
880 None => {
881 let after = match create_mirror_routine(gate, agent_id, &folder) {
882 Ok(cards) => cards,
883 Err(err) => return WakeOutcome::Failed(err.to_string()),
884 };
885 if !after.iter().any(|card| card.id == folder) {
886 for stray in after.iter().filter(|card| {
887 card.name == folder && !before.iter().any(|old| old.id == card.id)
888 }) {
889 let _ = delete_agent_automation(gate, agent_id, &stray.id);
890 }
891 return WakeOutcome::Failed(
892 "the mirror did not land in the expected folder; removed it".to_string(),
893 );
894 }
895 }
896 }
897 match read_webhook_credential(gate, agent_id, &folder) {
898 Ok(CredentialRead::Ready { url, key }) => WakeOutcome::Ready { url, key },
899 Ok(CredentialRead::MintFailed) => WakeOutcome::AwaitingBackend,
900 Ok(CredentialRead::Missing) => {
901 WakeOutcome::Failed("the mirror is gone from the gateway".to_string())
902 }
903 Err(err) => WakeOutcome::Failed(err.to_string()),
904 }
905}
906
907fn attach_webhook_routines(
912 sessions: &mut [HostSession],
913 gateway_file: &Path,
914 token_override: Option<&str>,
915 store_root: Option<&Path>,
916) -> Result<(), ShellError> {
917 let gate = open_gateway(gateway_file, token_override)?;
918 for session in sessions.iter_mut() {
919 if session.routine_url.is_some() && session.routine_bearer.is_some() {
920 continue;
921 }
922 let Some(agent_id) = session.agent_id.clone().filter(|id| !id.is_empty()) else {
924 continue;
925 };
926 let Some(nick) = routine_name_for(session) else {
927 continue;
928 };
929 let Some(folder) = crate::nick::routine_folder_id(&nick) else {
930 continue;
931 };
932 let Some(gate) = gate.as_ref() else {
933 if let Some(root) = store_root {
934 if let Some((url, key)) = load_wake(root, &session.session_id, &folder) {
935 session.routine_url = Some(url);
936 session.routine_bearer = Some(key);
937 }
938 }
939 continue;
940 };
941 let outcome = ensure_wake(gate, &agent_id, &nick);
942 log_outcome(&nick, &folder, &outcome);
943 match outcome {
944 WakeOutcome::Ready { url, key } => {
945 if let Some(root) = store_root {
946 save_wake(root, &session.session_id, &folder, &url, &key);
947 crate::wake_policy::note_state(&crate::session_store_dir(root, &session.session_id), "ready", "gateway");
948 }
949 session.routine_url = Some(url);
950 session.routine_bearer = Some(key);
951 }
952 WakeOutcome::AwaitingBackend => {
953 if let Some(root) = store_root {
956 clear_wake(root, &session.session_id);
957 crate::wake_policy::note_state(&crate::session_store_dir(root, &session.session_id), "awaiting", "gateway");
958 }
959 }
960 WakeOutcome::Failed(_) => {
961 if let Some(root) = store_root {
962 crate::wake_policy::note_state(&crate::session_store_dir(root, &session.session_id), "failed", "gateway");
963 }
964 }
965 }
966 }
967 Ok(())
968}
969
970#[derive(serde::Deserialize)]
971#[serde(deny_unknown_fields)]
972struct RoutineFile {
973 url: String,
974 key: String,
975}
976
977pub(crate) fn read_routine_file(path: &Path) -> Result<(String, String), String> {
979 #[cfg(unix)]
980 {
981 use std::os::unix::fs::PermissionsExt;
982 let meta = std::fs::metadata(path).map_err(|_| "the routine file cannot be read".to_string())?;
983 if meta.permissions().mode() & 0o077 != 0 {
984 return Err("the routine file must be mode 0600 (owner only)".to_string());
985 }
986 }
987 let text = std::fs::read_to_string(path).map_err(|_| "the routine file cannot be read".to_string())?;
988 let file: RoutineFile = serde_json::from_str(&text).map_err(|_| "the routine file is not {\"url\",\"key\"} json".to_string())?;
989 let (url, key) = (file.url.trim().to_string(), file.key.trim().to_string());
990 if key.is_empty() || !crate::webhook::url_ok(&url) {
991 return Err("the routine file has no usable url and key".to_string());
992 }
993 Ok((url, key))
994}
995
996fn attach_routine_files(sessions: &mut [HostSession], store_root: Option<&Path>) {
1000 for session in sessions.iter_mut() {
1001 let Some(path) = session.routine_file.clone() else { continue };
1002 if session.routine_url.is_some() && session.routine_bearer.is_some() {
1003 continue;
1004 }
1005 let label = routine_name_for(session).unwrap_or_else(|| session.session_id.clone());
1006 match read_routine_file(&path) {
1007 Ok((url, key)) => {
1008 session.routine_url = Some(url);
1009 session.routine_bearer = Some(key);
1010 if let Some(root) = store_root {
1011 crate::wake_policy::note_state(&crate::session_store_dir(root, &session.session_id), "ready", "file");
1012 }
1013 eprintln!("mail4agent: wake {label}: routine file read");
1014 }
1015 Err(reason) => eprintln!("mail4agent: wake {label}: {reason}"),
1016 }
1017 }
1018}
1019
1020pub(crate) fn wake_label(session_id: &str) -> String {
1022 format!("wake/{session_id}")
1023}
1024
1025fn attach_vault_wakes(sessions: &mut [HostSession], store_root: &Path) {
1029 let pending = |s: &HostSession| !(s.routine_url.is_some() && s.routine_bearer.is_some());
1030 if !sessions.iter().any(pending) {
1031 return;
1032 }
1033 let vault = match crate::store_key::vault(store_root) {
1034 Ok(v) => v,
1035 Err(err) => {
1036 eprintln!("mail4agent: wake vault not read: {err}");
1037 return;
1038 }
1039 };
1040 for session in sessions.iter_mut().filter(|s| pending(s)) {
1041 let Ok(Some(raw)) = vault.get(&wake_label(&session.session_id)) else { continue };
1042 #[derive(serde::Deserialize)]
1043 struct Held {
1044 url: String,
1045 key: String,
1046 }
1047 match serde_json::from_slice::<Held>(&raw) {
1048 Ok(h) if !h.key.is_empty() && crate::webhook::url_ok(&h.url) => {
1049 session.routine_url = Some(h.url);
1050 session.routine_bearer = Some(h.key);
1051 crate::wake_policy::note_state(&crate::session_store_dir(store_root, &session.session_id), "ready", "vault");
1052 eprintln!("mail4agent: wake {}: taken from the vault", routine_name_for(session).unwrap_or_else(|| session.session_id.clone()));
1053 }
1054 _ => eprintln!("mail4agent: wake {}: the vault entry is unusable", session.session_id),
1055 }
1056 }
1057}
1058
1059#[cfg(test)]
1060pub(crate) fn attach_vault_wakes_for_test(sessions: &mut [HostSession], store_root: &Path) {
1061 attach_vault_wakes(sessions, store_root)
1062}
1063
1064pub const STORE_LOCK_FILE: &str = ".lock";
1069
1070fn now_ms() -> i64 {
1071 std::time::SystemTime::now()
1072 .duration_since(std::time::UNIX_EPOCH)
1073 .map(|elapsed| elapsed.as_millis() as i64)
1074 .unwrap_or(0)
1075}
1076
1077pub(crate) fn lock_store(dir: &Path) -> Result<File, ShellError> {
1078 std::fs::create_dir_all(dir)?;
1079 let file = std::fs::OpenOptions::new()
1080 .create(true)
1081 .truncate(false)
1082 .write(true)
1083 .open(dir.join(STORE_LOCK_FILE))?;
1084 match file.try_lock() {
1085 Ok(()) => Ok(file),
1086 Err(_) => Err(ShellError::SessionList(
1087 "session store is in use by another process".to_string(),
1088 )),
1089 }
1090}
1091
1092fn write_secret_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
1094 let dir = path
1095 .parent()
1096 .ok_or_else(|| std::io::Error::other("no parent"))?;
1097 let mut builder = std::fs::DirBuilder::new();
1098 builder.recursive(true);
1099 #[cfg(unix)]
1100 {
1101 use std::os::unix::fs::DirBuilderExt;
1102 builder.mode(0o700);
1103 }
1104 builder.create(dir)?;
1105 let name = path
1106 .file_name()
1107 .and_then(|name| name.to_str())
1108 .unwrap_or("secret");
1109 let tmp = dir.join(format!("{name}.tmp"));
1110 let mut options = std::fs::OpenOptions::new();
1111 options.write(true).create(true).truncate(true);
1112 #[cfg(unix)]
1113 {
1114 use std::os::unix::fs::OpenOptionsExt;
1115 options.mode(0o600);
1116 }
1117 let mut file = options.open(&tmp)?;
1118 std::io::Write::write_all(&mut file, bytes)?;
1119 file.sync_all()?;
1120 drop(file);
1121 std::fs::rename(&tmp, path)
1122}
1123
1124fn keychain_path(store_root: &Path, session_id: &str) -> PathBuf {
1125 crate::session_store_dir(store_root, session_id).join(WAKE_KEYCHAIN_FILE)
1126}
1127
1128fn save_wake(store_root: &Path, session_id: &str, folder_id: &str, url: &str, key: &str) {
1131 let stored = StoredWake {
1132 folder_id: folder_id.to_string(),
1133 url: url.to_string(),
1134 key: key.to_string(),
1135 };
1136 let result = serde_json::to_vec(&stored)
1137 .map_err(std::io::Error::other)
1138 .and_then(|bytes| write_secret_file(&keychain_path(store_root, session_id), &bytes));
1139 if result.is_err() {
1140 eprintln!("mail4agent: wake keychain write failed for folder {folder_id}");
1141 }
1142}
1143
1144fn load_wake(store_root: &Path, session_id: &str, folder_id: &str) -> Option<(String, String)> {
1145 let text = std::fs::read_to_string(keychain_path(store_root, session_id)).ok()?;
1146 let stored: StoredWake = serde_json::from_str(&text).ok()?;
1147 if stored.folder_id != folder_id || stored.url.is_empty() || stored.key.is_empty() {
1148 return None;
1149 }
1150 Some((stored.url, stored.key))
1151}
1152
1153fn clear_wake(store_root: &Path, session_id: &str) {
1154 let _ = std::fs::remove_file(keychain_path(store_root, session_id));
1155}
1156
1157fn list_agent_automations(
1158 gate: &GatewayConn,
1159 agent_id: &str,
1160) -> Result<Vec<AutomationCard>, ShellError> {
1161 let (status, body) = gateway_post(
1162 gate,
1163 "getAgentAutomations",
1164 &serde_json::json!({ "id": agent_id }),
1165 )?;
1166 if status != 200 {
1167 return Err(ShellError::Gateway(format!("gateway list status {status}")));
1168 }
1169 serde_json::from_slice(&body)
1170 .map_err(|_| ShellError::Gateway("gateway list was not understood".to_string()))
1171}
1172
1173enum CredentialRead {
1174 Ready {
1175 url: String,
1176 key: String,
1177 },
1178 MintFailed,
1180 Missing,
1181}
1182
1183fn gateway_client() -> Result<reqwest::blocking::Client, ShellError> {
1184 reqwest::blocking::Client::builder()
1185 .timeout(std::time::Duration::from_secs(8))
1186 .redirect(reqwest::redirect::Policy::none())
1187 .http1_only()
1188 .build()
1189 .map_err(|_| ShellError::Gateway("gateway client could not start".to_string()))
1190}
1191
1192fn gateway_post(
1193 gate: &GatewayConn,
1194 method: &str,
1195 body: &serde_json::Value,
1196) -> Result<(u16, Vec<u8>), ShellError> {
1197 let url = format!("{}/api/{method}", gate.base);
1198 let authorization = crate::bearer_header(&gate.token).map_err(|_| {
1199 ShellError::Gateway("gateway token is not a single header value".to_string())
1200 })?;
1201 let bytes = serde_json::to_vec(body)
1202 .map_err(|_| ShellError::Gateway("gateway request was not json".to_string()))?;
1203 let response = gate
1204 .client
1205 .post(&url)
1206 .header(reqwest::header::AUTHORIZATION, authorization)
1207 .header(reqwest::header::CONTENT_TYPE, "application/json")
1208 .body(bytes)
1209 .send()
1210 .map_err(|_| ShellError::Gateway("gateway request failed".to_string()))?;
1211 let status = response.status().as_u16();
1212 let body = response
1213 .bytes()
1214 .map_err(|_| ShellError::Gateway("gateway response failed".to_string()))?;
1215 Ok((status, body.to_vec()))
1216}
1217
1218fn read_webhook_credential(
1219 gate: &GatewayConn,
1220 agent_id: &str,
1221 automation_id: &str,
1222) -> Result<CredentialRead, ShellError> {
1223 let (status, body) = gateway_post(
1224 gate,
1225 "getAutomationWebhookCredential",
1226 &serde_json::json!({
1227 "id": agent_id,
1228 "automationId": automation_id,
1229 }),
1230 )?;
1231 if status == 200 {
1232 let parsed: WebhookCredential = serde_json::from_slice(&body).map_err(|_| {
1233 ShellError::Gateway("gateway credential was not understood".to_string())
1234 })?;
1235 let key = parsed.key.filter(|key| !key.is_empty());
1236 if parsed.url.is_empty() {
1237 return Err(ShellError::Gateway(
1238 "gateway credential was not understood".to_string(),
1239 ));
1240 }
1241 return Ok(match key {
1242 Some(key) => CredentialRead::Ready {
1243 url: parsed.url,
1244 key,
1245 },
1246 None => CredentialRead::MintFailed,
1247 });
1248 }
1249 if credential_is_missing(status, &body) {
1250 return Ok(CredentialRead::Missing);
1251 }
1252 Err(ShellError::Gateway(format!(
1253 "gateway credential status {status}"
1254 )))
1255}
1256
1257fn credential_is_missing(status: u16, body: &[u8]) -> bool {
1260 if status == 404 {
1261 return true;
1262 }
1263 if status != 500 {
1264 return false;
1265 }
1266 std::str::from_utf8(body)
1267 .map(|text| text.contains("Automation not found"))
1268 .unwrap_or(false)
1269}
1270
1271fn create_mirror_routine(
1273 gate: &GatewayConn,
1274 agent_id: &str,
1275 routine: &str,
1276) -> Result<Vec<AutomationCard>, ShellError> {
1277 let (status, body) = gateway_post(
1278 gate,
1279 "createAgentAutomation",
1280 &serde_json::json!({
1281 "id": agent_id,
1282 "spec": {
1283 "name": routine,
1284 "prompt": MIRROR_ROUTINE_PROMPT,
1285 "trigger": { "type": "webhook" },
1286 "isEnabled": false,
1287 },
1288 }),
1289 )?;
1290 if status != 200 {
1291 return Err(ShellError::Gateway(format!(
1292 "gateway create status {status}"
1293 )));
1294 }
1295 serde_json::from_slice(&body)
1296 .map_err(|_| ShellError::Gateway("gateway create was not understood".to_string()))
1297}
1298
1299fn delete_agent_automation(
1300 gate: &GatewayConn,
1301 agent_id: &str,
1302 automation_id: &str,
1303) -> Result<(), ShellError> {
1304 let (status, _) = gateway_post(
1305 gate,
1306 "deleteAgentAutomation",
1307 &serde_json::json!({ "id": agent_id, "automationId": automation_id }),
1308 )?;
1309 if status != 200 {
1310 return Err(ShellError::Gateway(format!(
1311 "gateway delete status {status}"
1312 )));
1313 }
1314 Ok(())
1315}
1316
1317pub(crate) struct Prepared {
1318 pub(crate) config: SessionConfig,
1319 pub(crate) nick: String,
1320 pub(crate) user_id: String,
1321 pub(crate) device_id: DeviceId,
1322 pub(crate) bearer: Zeroizing<String>,
1323 pub(crate) backend: Arc<dyn m4a_agent::Backend>,
1324 pub(crate) routine_url: Option<String>,
1325 pub(crate) routine_bearer: Option<String>,
1326}
1327
1328const PUSH_RETRY_SECS: u64 = 5;
1332const HEAL_AFTER_FAILED_TICKS: u32 = 5;
1334
1335pub struct MachineClient {
1336 product_mode: bool,
1338 sessions: Vec<OpenedStore>,
1339 bus: Arc<LocalBus>,
1340 push: m4a_agent::engine::PushLink,
1341 agents_dir: Option<PathBuf>,
1345 gateway_file: Option<PathBuf>,
1346 gateway_token: Option<String>,
1347 last_agent_poll: std::time::Instant,
1348 agent_rescan_secs: u64,
1349 sessions_dir: Option<PathBuf>,
1351 store_root: Option<PathBuf>,
1353 skip_nicks: Vec<String>,
1355 profile_note: bool,
1357 session_ids: Vec<(String, String)>,
1359 last_full_drive: std::time::Instant,
1361 pending_wakes: Vec<PendingWake>,
1363 ready_agents: HashSet<String>,
1366 _locks: Vec<File>,
1368 send_listener: Option<SendListener>,
1370 send_sock: Option<PathBuf>,
1371 send_queue: Vec<PendingSend>,
1373 homeserver_url: String,
1376 bus_next_user: i64,
1378 failed_opens: HashMap<String, std::time::Instant>,
1381 last_push_retry: std::time::Instant,
1383 error_streak: u32,
1385}
1386
1387const LATE_OPEN_RETRY_SECS: u64 = 60;
1390
1391struct PendingSend {
1393 stream: SendStream,
1394 request: crate::SendRequest,
1395 started: std::time::Instant,
1396 room: Option<String>,
1397 peer: Option<String>,
1398}
1399
1400const SEND_JOIN_WAIT_SECS: u64 = 120;
1402
1403impl Drop for MachineClient {
1404 fn drop(&mut self) {
1405 if let Some(path) = self.send_sock.take() {
1406 let _ = std::fs::remove_file(path);
1407 }
1408 }
1409}
1410
1411#[derive(Debug, Default)]
1414pub struct TickReport {
1415 pub pushed: Vec<(String, String)>,
1417 pub joined: Vec<(String, String)>,
1419 pub errors: Vec<(String, String)>,
1421 pub sent: Vec<(String, String, crate::SendReply)>,
1423 pub alerts: Vec<(String, String)>,
1425}
1426
1427struct PendingWake {
1429 store_dir: PathBuf,
1430 agent_id: String,
1431}
1432
1433impl MachineClient {
1434 pub fn open(
1441 homeserver_url: &str,
1442 store_root: &Path,
1443 sessions: Vec<HostSession>,
1444 ) -> Result<Self, ShellError> {
1445 Self::open_with(homeserver_url, store_root, sessions, false)
1446 }
1447
1448 fn open_with(
1452 homeserver_url: &str,
1453 store_root: &Path,
1454 sessions: Vec<HostSession>,
1455 lenient: bool,
1456 ) -> Result<Self, ShellError> {
1457 let mut sessions = sessions;
1458 attach_routine_files(&mut sessions, Some(store_root));
1459 attach_vault_wakes(&mut sessions, store_root);
1460 if sessions.is_empty() {
1461 return Err(ShellError::SessionList("session list is empty".to_string()));
1462 }
1463 let mut prepared = Vec::with_capacity(sessions.len());
1464 let mut seen_ids = Vec::new();
1465 let mut seen_nicks = Vec::new();
1466 let mut locks = Vec::new();
1467 for session in sessions {
1468 let config = session.config(homeserver_url, store_root)?;
1469 if seen_ids.iter().any(|id: &String| id == config.session_id()) {
1470 return Err(ShellError::SessionList("duplicate session id".to_string()));
1471 }
1472 seen_ids.push(config.session_id().to_string());
1473 let lock = match lock_store(&config.store_dir()) {
1474 Ok(lock) => lock,
1475 Err(err) if lenient => {
1476 eprintln!("mail4agent: session {} left out: {err}", config.session_id());
1477 continue;
1478 }
1479 Err(err) => return Err(err),
1480 };
1481 let registered = match register_session(&config) {
1482 Ok(registered) => registered,
1483 Err(err) if lenient => {
1484 eprintln!("mail4agent: session {} left out: {err}", config.session_id());
1485 continue;
1486 }
1487 Err(err) => return Err(err),
1488 };
1489 if seen_nicks.iter().any(|nick: &String| nick.eq_ignore_ascii_case(®istered.nick)) {
1490 return Err(ShellError::SessionList("duplicate nick".to_string()));
1491 }
1492 seen_nicks.push(registered.nick.clone());
1493 locks.push(lock);
1494 prepared.push(Prepared {
1495 config,
1496 nick: registered.nick,
1497 user_id: registered.user_id,
1498 device_id: registered.device_id,
1499 bearer: registered.bearer,
1500 backend: registered.backend,
1501 routine_url: session.routine_url,
1502 routine_bearer: session.routine_bearer,
1503 });
1504 }
1505 if prepared.is_empty() {
1506 return Err(ShellError::SessionList(
1507 "no session could be opened".to_string(),
1508 ));
1509 }
1510 let server_name = server_name_of(&prepared[0].user_id)?;
1511 for item in &prepared[1..] {
1512 if server_name_of(&item.user_id)? != server_name {
1513 return Err(ShellError::SessionList(
1514 "sessions disagree on the homeserver name".to_string(),
1515 ));
1516 }
1517 }
1518 ensure_server_name(server_name)?;
1519 let bus = Arc::new(LocalBus::open(&prepared)?);
1520 let peers: Vec<(String, String)> = prepared
1521 .iter()
1522 .map(|item| (item.nick.to_string(), item.user_id.clone()))
1523 .collect();
1524 let mut opened = Vec::with_capacity(prepared.len());
1525 for item in &prepared {
1526 let server_name = server_name_of(&item.user_id)?;
1527 let mut store = OpenedStore::open(
1528 &item.config.store_dir(),
1529 item.config.session_id(),
1530 item.device_id.clone(),
1531 &item.user_id,
1532 server_name,
1533 Arc::clone(&item.backend),
1534 item.bearer.as_str(),
1535 )?;
1536 store.set_registered_nick(item.nick.to_string());
1537 store.attach_bus(Arc::clone(&bus));
1538 store.set_local_peers(
1539 peers
1540 .iter()
1541 .filter(|(nick, _)| *nick != item.nick)
1542 .cloned()
1543 .collect(),
1544 );
1545 store.set_wake(SessionWake {
1546 routine_url: item.routine_url.clone(),
1547 routine_bearer: item.routine_bearer.clone(),
1548 leader_sock: None,
1549 leader_cwd: None,
1550 });
1551 if item.routine_url.is_none() {
1552 attach_detected_chain(&mut store, &item.config, &item.nick);
1553 }
1554 store.drive(1_000, false)?;
1555 store.abandon_inflight_sync(1_000)?;
1556 opened.push(store);
1557 }
1558 let tokens: Vec<String> = prepared
1559 .iter()
1560 .map(|item| item.bearer.as_str().to_string())
1561 .collect();
1562 let product_mode = true;
1563 let push = m4a_agent::engine::PushLink::open(&prepared[0].config.homeserver_url, prepared[0].backend.keep_prefix(), tokens, product_mode)?;
1564 Ok(Self {
1565 product_mode,
1566 sessions: opened,
1567 bus,
1568 push,
1569 agents_dir: None,
1570 gateway_file: None,
1571 gateway_token: None,
1572 last_agent_poll: std::time::Instant::now(),
1573 agent_rescan_secs: 0,
1574 sessions_dir: None,
1575 store_root: None,
1576 skip_nicks: Vec::new(),
1577 profile_note: false,
1578 session_ids: Vec::new(),
1579 last_full_drive: std::time::Instant::now(),
1580 pending_wakes: Vec::new(),
1581 ready_agents: HashSet::new(),
1582 _locks: locks,
1583 send_listener: None,
1584 send_sock: None,
1585 send_queue: Vec::new(),
1586 homeserver_url: homeserver_url.to_string(),
1587 bus_next_user: prepared.len() as i64 + 1,
1588 failed_opens: HashMap::new(),
1589 last_push_retry: std::time::Instant::now(),
1590 error_streak: 0,
1591 })
1592 }
1593
1594 pub fn open_session_dir(
1597 homeserver_url: &str,
1598 store_root: &Path,
1599 sessions_dir: &Path,
1600 ) -> Result<Self, ShellError> {
1601 let sessions = load_session_records(sessions_dir)?;
1602 Self::open(homeserver_url, store_root, sessions)
1603 }
1604
1605 pub fn from_env() -> Result<Self, ShellError> {
1616 Self::from_env_filtered(None)
1617 }
1618
1619 pub fn from_env_for(nick: &str) -> Result<Self, ShellError> {
1623 Self::from_env_filtered(Some(nick))
1624 }
1625
1626 fn from_env_filtered(only: Option<&str>) -> Result<Self, ShellError> {
1627 let homeserver_url = std::env::var(HOMESERVER_URL_ENV)
1628 .ok()
1629 .filter(|value| !value.is_empty())
1630 .ok_or(ShellError::HomeserverUrl)?;
1631 let store_root = std::env::var(STORE_ROOT_ENV)
1632 .ok()
1633 .filter(|value| !value.is_empty())
1634 .ok_or(ShellError::StoreRoot)?;
1635 let mut get = |key: &str| -> Option<String> {
1636 std::env::var(key).ok().filter(|value| !value.is_empty())
1637 };
1638 let agents_dir = explicit_agents_dir(&mut get);
1639 let mut sessions_dir_seen: Option<PathBuf> = None;
1640 let (sessions, agents_dir) = if let Some(dir) = agents_dir {
1641 (load_web_agents(&dir, &mut get)?, Some(dir))
1642 } else {
1643 let sessions_dir = get(SESSIONS_DIR_ENV)
1644 .ok_or_else(|| ShellError::SessionList("session directory is unset".to_string()))?;
1645 sessions_dir_seen = Some(PathBuf::from(&sessions_dir));
1646 (load_web_sessions(Path::new(&sessions_dir), &mut get)?, None)
1647 };
1648 let sessions = match only {
1649 Some(nick) => {
1650 let needle = crate::nick::lookup_nick(nick)?;
1651 let kept: Vec<HostSession> = sessions
1652 .into_iter()
1653 .filter(|session| {
1654 routine_name_for(session)
1655 .is_some_and(|name| name.eq_ignore_ascii_case(&needle))
1656 })
1657 .collect();
1658 if kept.is_empty() {
1659 return Err(ShellError::UnknownNick);
1660 }
1661 kept
1662 }
1663 None => sessions,
1664 };
1665 let mut sessions = sessions;
1667 attach_routine_files(&mut sessions, Some(Path::new(&store_root)));
1668 attach_vault_wakes(&mut sessions, Path::new(&store_root));
1669 let gateway_file = get(GATEWAY_FILE_ENV).map(PathBuf::from);
1670 let gateway_token = get(GATEWAY_TOKEN_ENV);
1671 let agent_rescan_secs = get(AGENT_RESCAN_SECS_ENV)
1672 .and_then(|value| value.parse::<u64>().ok())
1673 .unwrap_or(0);
1674 let mut pending_wakes = Vec::new();
1675 let mut ready_agents = HashSet::new();
1676 for session in &sessions {
1677 let Some(agent_id) = session.agent_id.clone() else {
1678 continue;
1679 };
1680 if session.routine_url.is_some() && session.routine_bearer.is_some() {
1681 ready_agents.insert(agent_id);
1682 } else {
1683 pending_wakes.push(PendingWake {
1684 store_dir: crate::session_store_dir(
1685 Path::new(&store_root),
1686 &session.session_id,
1687 ),
1688 agent_id,
1689 });
1690 }
1691 }
1692 let options = WakeOptions::from_lookup(&mut get);
1693 let mut client = Self::open_with(&homeserver_url, Path::new(&store_root), sessions, true)?;
1694 client.store_root = Some(PathBuf::from(&store_root));
1695 client.skip_nicks = options.skip_nicks;
1696 client.profile_note = options.profile_note;
1697 client.session_ids = options.session_ids;
1698 client.pending_wakes = pending_wakes;
1699 client.ready_agents = ready_agents;
1700 client.agents_dir = agents_dir;
1701 client.gateway_file = gateway_file;
1702 client.gateway_token = gateway_token;
1703 client.agent_rescan_secs = agent_rescan_secs;
1704 client.sessions_dir = sessions_dir_seen;
1705 if get(LOCAL_BUS_ENV).is_some_and(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "on" | "true" | "yes")) {
1706 #[cfg(feature = "local-bus")]
1707 client.set_local_delivery(true);
1708 #[cfg(not(feature = "local-bus"))]
1709 return Err(ShellError::SessionList("M4A_LOCAL_BUS needs a build with feature local-bus".to_string()));
1710 }
1711 client.last_agent_poll = std::time::Instant::now();
1712 Ok(client)
1713 }
1714
1715 pub fn poll_agent_directory(&mut self) -> Result<Vec<RoutineReport>, ShellError> {
1726 let Some(agents_dir) = self.agents_dir.clone() else {
1727 return self.poll_session_dir_wakes();
1728 };
1729 if self.agent_rescan_secs > 0 {
1730 let elapsed = self.last_agent_poll.elapsed().as_secs();
1731 if elapsed < self.agent_rescan_secs {
1732 return Ok(Vec::new());
1733 }
1734 }
1735 self.last_agent_poll = std::time::Instant::now();
1736 self.open_new_agent_sessions(&agents_dir);
1737 let gateway = self
1738 .gateway_file
1739 .clone()
1740 .unwrap_or_else(|| under_home(DEFAULT_GATEWAY_FILE));
1741 let Some(gate) = open_gateway(&gateway, self.gateway_token.as_deref())? else {
1742 return Ok(Vec::new());
1743 };
1744 let mut sessions = drop_skipped(load_agents_dir(&agents_dir)?, &self.skip_nicks);
1745 apply_session_ids(&mut sessions, &self.session_ids);
1746 let mut reports = Vec::new();
1747 for session in &sessions {
1748 let Some(agent_id) = session.agent_id.clone() else {
1749 continue;
1750 };
1751 let Some(nick) = routine_name_for(session) else {
1752 continue;
1753 };
1754 let folder_id = crate::nick::routine_folder_id(&nick).unwrap_or_default();
1755 if self.ready_agents.contains(&agent_id) {
1756 reports.push(RoutineReport {
1757 agent_id,
1758 nick,
1759 folder_id,
1760 status: WakeStatus::Ready,
1761 });
1762 continue;
1763 }
1764 let outcome = ensure_wake(&gate, &agent_id, &nick);
1765 log_outcome(&nick, &folder_id, &outcome);
1766 if self.profile_note && matches!(outcome, WakeOutcome::AwaitingBackend) {
1767 note_profile(&gate, &agents_dir, &agent_id, &nick);
1768 }
1769 if let WakeOutcome::Ready { url, key } = &outcome {
1770 if let Some(root) = &self.store_root {
1771 save_wake(root, &session.session_id, &folder_id, url, key);
1772 }
1773 if let Some(index) = self
1774 .pending_wakes
1775 .iter()
1776 .position(|pending| pending.agent_id == agent_id)
1777 {
1778 let pending = self.pending_wakes.remove(index);
1779 if let Some(store) = self
1780 .sessions
1781 .iter_mut()
1782 .find(|store| store.store_dir() == pending.store_dir)
1783 {
1784 store.set_wake(SessionWake {
1785 routine_url: Some(url.clone()),
1786 routine_bearer: Some(key.clone()),
1787 leader_sock: None,
1788 leader_cwd: None,
1789 });
1790 }
1791 }
1792 self.ready_agents.insert(agent_id.clone());
1793 if let Some(user_id) = self
1794 .sessions
1795 .iter()
1796 .find(|store| {
1797 store
1798 .nick()
1799 .map(|n| n.eq_ignore_ascii_case(&nick))
1800 .unwrap_or(false)
1801 })
1802 .map(|store| store.user_id().to_string())
1803 {
1804 self.announce_peer_joined(&nick, &user_id);
1805 }
1806 }
1807 reports.push(RoutineReport {
1808 agent_id,
1809 nick,
1810 folder_id,
1811 status: outcome.status(),
1812 });
1813 }
1814 Ok(reports)
1815 }
1816
1817 fn poll_session_dir_wakes(&mut self) -> Result<Vec<RoutineReport>, ShellError> {
1822 let Some(dir) = self.sessions_dir.clone() else {
1823 return Ok(Vec::new());
1824 };
1825 if self.pending_wakes.is_empty() {
1826 return Ok(Vec::new());
1827 }
1828 let every = if self.agent_rescan_secs > 0 { self.agent_rescan_secs } else { 60 };
1829 if self.last_agent_poll.elapsed().as_secs() < every {
1830 return Ok(Vec::new());
1831 }
1832 self.last_agent_poll = std::time::Instant::now();
1833 let Some(gateway) = self.gateway_file.clone() else {
1834 return Ok(Vec::new());
1835 };
1836 let Some(gate) = open_gateway(&gateway, self.gateway_token.as_deref())? else {
1837 return Ok(Vec::new());
1838 };
1839 let mut sessions = load_session_records(&dir)?;
1840 apply_session_ids(&mut sessions, &self.session_ids);
1841 let mut reports = Vec::new();
1842 for session in &sessions {
1843 let Some(agent_id) = session.agent_id.clone() else { continue };
1844 if !self.pending_wakes.iter().any(|p| p.agent_id == agent_id) {
1845 continue;
1846 }
1847 let Some(nick) = routine_name_for(session) else { continue };
1848 let folder_id = crate::nick::routine_folder_id(&nick).unwrap_or_default();
1849 let outcome = ensure_wake(&gate, &agent_id, &nick);
1850 log_outcome(&nick, &folder_id, &outcome);
1851 let dir_of = self.store_root.as_ref().map(|r| crate::session_store_dir(r, &session.session_id));
1852 match &outcome {
1853 WakeOutcome::Ready { url, key } => {
1854 if let Some(root) = &self.store_root {
1855 save_wake(root, &session.session_id, &folder_id, url, key);
1856 }
1857 if let Some(d) = &dir_of {
1858 crate::wake_policy::note_state(d, "ready", "gateway");
1859 }
1860 if let Some(index) = self.pending_wakes.iter().position(|p| p.agent_id == agent_id) {
1861 let pending = self.pending_wakes.remove(index);
1862 if let Some(store) = self.sessions.iter_mut().find(|s| s.store_dir() == pending.store_dir) {
1863 store.set_wake(SessionWake { routine_url: Some(url.clone()), routine_bearer: Some(key.clone()), leader_sock: None, leader_cwd: None });
1864 }
1865 }
1866 self.ready_agents.insert(agent_id.clone());
1867 }
1868 WakeOutcome::AwaitingBackend => {
1869 if let Some(d) = &dir_of {
1870 crate::wake_policy::note_state(d, "awaiting", "gateway");
1871 }
1872 }
1873 WakeOutcome::Failed(_) => {
1874 if let Some(d) = &dir_of {
1875 crate::wake_policy::note_state(d, "failed", "gateway");
1876 }
1877 }
1878 }
1879 reports.push(RoutineReport { agent_id, nick, folder_id, status: outcome.status() });
1880 }
1881 Ok(reports)
1882 }
1883
1884 fn open_new_agent_sessions(&mut self, agents_dir: &Path) {
1890 let Some(store_root) = self.store_root.clone() else {
1891 return;
1892 };
1893 let found = match load_agents_dir(agents_dir) {
1894 Ok(found) => found,
1895 Err(err) => {
1896 eprintln!("mail4agent: agent rescan: {err}");
1897 return;
1898 }
1899 };
1900 let held: Vec<(PathBuf, Option<String>)> = self
1901 .sessions
1902 .iter()
1903 .map(|store| {
1904 (
1905 store.store_dir().to_path_buf(),
1906 store.nick().map(str::to_string),
1907 )
1908 })
1909 .collect();
1910 let fresh = unopened_agent_sessions(
1911 found,
1912 &self.skip_nicks,
1913 &self.session_ids,
1914 &store_root,
1915 &held,
1916 );
1917 for (session, nick) in fresh {
1918 if let Some(failed_at) = self.failed_opens.get(&session.session_id) {
1919 if failed_at.elapsed().as_secs() < LATE_OPEN_RETRY_SECS {
1920 continue;
1921 }
1922 }
1923 let session_id = session.session_id.clone();
1924 match self.open_late_session(&store_root, session, &nick) {
1925 Ok(()) => {
1926 self.failed_opens.remove(&session_id);
1927 println!("mail4agent: session {nick} registered and open");
1928 }
1929 Err(err) => {
1930 eprintln!("mail4agent: session {nick} not opened: {err}");
1931 self.failed_opens
1932 .insert(session_id, std::time::Instant::now());
1933 }
1934 }
1935 }
1936 }
1937
1938 fn open_late_session(
1939 &mut self,
1940 store_root: &Path,
1941 mut session: HostSession,
1942 nick: &str,
1943 ) -> Result<(), ShellError> {
1944 if session.routine_url.is_none() || session.routine_bearer.is_none() {
1945 if let Some(folder) = crate::nick::routine_folder_id(nick) {
1946 if let Some((url, key)) = load_wake(store_root, &session.session_id, &folder) {
1947 session.routine_url = Some(url);
1948 session.routine_bearer = Some(key);
1949 }
1950 }
1951 }
1952 let config = session.config(&self.homeserver_url, store_root)?;
1953 let lock = lock_store(&config.store_dir())?;
1954 let registered = register_session(&config)?;
1955 if let Some(first) = self.sessions.first() {
1956 if server_name_of(®istered.user_id)? != server_name_of(first.user_id())? {
1957 return Err(ShellError::SessionList(
1958 "sessions disagree on the homeserver name".to_string(),
1959 ));
1960 }
1961 }
1962 let item = Prepared {
1963 config,
1964 nick: registered.nick,
1965 user_id: registered.user_id,
1966 device_id: registered.device_id,
1967 bearer: registered.bearer,
1968 backend: registered.backend,
1969 routine_url: session.routine_url.clone(),
1970 routine_bearer: session.routine_bearer.clone(),
1971 };
1972 self.bus.seed(self.bus_next_user, &item)?;
1973 self.bus_next_user += 1;
1974 let mut store = OpenedStore::open(
1975 &item.config.store_dir(),
1976 item.config.session_id(),
1977 item.device_id.clone(),
1978 &item.user_id,
1979 server_name_of(&item.user_id)?,
1980 Arc::clone(&item.backend),
1981 item.bearer.as_str(),
1982 )?;
1983 store.set_registered_nick(item.nick.to_string());
1984 store.attach_bus(Arc::clone(&self.bus));
1985 store.set_wake(SessionWake {
1986 routine_url: item.routine_url.clone(),
1987 routine_bearer: item.routine_bearer.clone(),
1988 leader_sock: None,
1989 leader_cwd: None,
1990 });
1991 if item.routine_url.is_none() {
1992 attach_detected_chain(&mut store, &item.config, &item.nick);
1993 }
1994 store.drive(1_000, false)?;
1995 store.abandon_inflight_sync(1_000)?;
1996 self.sessions.push(store);
1997 self._locks.push(lock);
1998 self.refresh_local_peers();
1999 if let Err(err) = self.reopen_push() {
2000 eprintln!("mail4agent: push socket not reopened for {nick}: {err}");
2001 }
2002 if let Some(agent_id) = session.agent_id.clone() {
2003 if item.routine_url.is_some() && item.routine_bearer.is_some() {
2004 self.ready_agents.insert(agent_id);
2005 self.announce_peer_joined(nick, &item.user_id);
2006 } else {
2007 self.ready_agents.remove(&agent_id);
2009 if !self.pending_wakes.iter().any(|p| p.agent_id == agent_id) {
2010 self.pending_wakes.push(PendingWake {
2011 store_dir: item.config.store_dir(),
2012 agent_id,
2013 });
2014 }
2015 }
2016 }
2017 Ok(())
2018 }
2019
2020 fn reopen_push(&mut self) -> Result<(), ShellError> {
2023 let tokens: Vec<String> = self.sessions.iter().map(|store| store.device_bearer().to_string()).collect();
2024 if tokens.is_empty() {
2025 return Ok(());
2026 }
2027 let keep_prefix = self.sessions.first().is_some_and(|s| s.keep_prefix());
2028 let push = m4a_agent::engine::PushLink::open(&self.homeserver_url, keep_prefix, tokens, self.product_mode)?;
2029 let old = std::mem::replace(&mut self.push, push);
2030 for (recipient, event) in old.drain() {
2031 if let Some(store) = self.sessions.iter_mut().find(|store| store.user_id() == recipient) {
2032 store.record_push(event);
2033 }
2034 }
2035 Ok(())
2036 }
2037
2038 fn heal_push(&mut self) -> bool {
2041 let wanted = !self.push.is_alive() || self.error_streak >= HEAL_AFTER_FAILED_TICKS;
2042 if !wanted || self.bus.local_only() || self.last_push_retry.elapsed().as_secs() < PUSH_RETRY_SECS {
2043 return false;
2044 }
2045 self.last_push_retry = std::time::Instant::now();
2046 self.reopen_push().is_ok()
2047 }
2048
2049 pub fn error_streak(&self) -> u32 {
2051 self.error_streak
2052 }
2053
2054 fn refresh_local_peers(&mut self) {
2056 let peers: Vec<(String, String)> = self
2057 .sessions
2058 .iter()
2059 .filter_map(|store| Some((store.nick()?.to_string(), store.user_id().to_string())))
2060 .collect();
2061 for store in self.sessions.iter_mut() {
2062 let own = store.nick().unwrap_or("").to_string();
2063 store.set_local_peers(
2064 peers
2065 .iter()
2066 .filter(|(nick, _)| *nick != own)
2067 .cloned()
2068 .collect(),
2069 );
2070 }
2071 }
2072
2073 fn announce_peer_joined(&self, joined_nick: &str, joined_user_id: &str) {
2077 let body = serde_json::json!({
2078 "kind": "peer_joined",
2079 "nick": joined_nick,
2080 "user_id": joined_user_id,
2081 });
2082 for store in &self.sessions {
2083 let Some(nick) = store.nick() else {
2084 continue;
2085 };
2086 if nick.eq_ignore_ascii_case(joined_nick) {
2087 continue;
2088 }
2089 if !store.has_routine() {
2090 continue;
2091 }
2092 let Some((url, bearer)) = store.routine_target() else {
2093 continue;
2094 };
2095 match crate::post_routine_json(&url, &body, bearer.as_deref()) {
2096 Ok(()) => {
2097 eprintln!("mail4agent: peer_joined {joined_nick} -> {nick} status=200")
2098 }
2099 Err(err) => {
2100 eprintln!("mail4agent: peer_joined {joined_nick} -> {nick}: {err}")
2101 }
2102 }
2103 }
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)) => self.send_queue.push(PendingSend {
2409 stream,
2410 request,
2411 started: std::time::Instant::now(),
2412 room: None,
2413 peer: None,
2414 }),
2415 Ok(crate::send::Incoming::Cmd(cmd)) => cmds.push((stream, cmd)),
2416 Err(err) => {
2417 crate::send::write_reply(&mut stream, &crate::SendReply::failed(err))
2418 }
2419 },
2420 Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => break,
2421 Err(_) => break,
2422 }
2423 }
2424 }
2425 for (mut stream, cmd) in cmds {
2426 let reply = match self.sessions.iter_mut().find(|store| {
2427 store.nick().is_some_and(|n| n.eq_ignore_ascii_case(cmd.as_nick.trim()))
2428 }) {
2429 Some(store) => store.run_command(&cmd, now_ms),
2430 None => crate::CmdReply::failed(format!("{} is not a session on this client", cmd.as_nick)),
2431 };
2432 crate::send::write_cmd_reply(&mut stream, &reply);
2433 }
2434 let queue = std::mem::take(&mut self.send_queue);
2435 for mut pending in queue {
2436 let (as_nick, to, text) = (
2437 pending.request.as_nick.clone(),
2438 pending.request.to.clone(),
2439 pending.request.text.clone(),
2440 );
2441 let outcome = self.try_send(
2442 &as_nick,
2443 &to,
2444 &text,
2445 now_ms,
2446 &mut pending.room,
2447 &mut pending.peer,
2448 );
2449 let reply = match outcome {
2450 Some(reply) => reply,
2451 None if pending.started.elapsed().as_secs() >= SEND_JOIN_WAIT_SECS => {
2452 crate::SendReply {
2453 room: pending.room.clone(),
2454 ..crate::SendReply::failed(format!("{to} has not joined the DM yet"))
2455 }
2456 }
2457 None => {
2458 self.send_queue.push(pending);
2459 continue;
2460 }
2461 };
2462 crate::send::write_reply(&mut pending.stream, &reply);
2463 report.sent.push((as_nick, to, reply));
2464 }
2465 }
2466
2467 pub fn wake_log(&self) -> Vec<(String, crate::WakeAttempt)> {
2470 self.sessions
2471 .iter()
2472 .flat_map(|store| {
2473 let nick = store.nick().unwrap_or("").to_string();
2474 store
2475 .wake_log()
2476 .iter()
2477 .cloned()
2478 .map(move |attempt| (nick.clone(), attempt))
2479 })
2480 .collect()
2481 }
2482
2483 pub fn holds(&self, name_or_nick: &str) -> bool {
2485 let Ok(needle) = crate::nick::lookup_nick(name_or_nick) else {
2486 return false;
2487 };
2488 self.sessions.iter().any(|store| {
2489 store
2490 .nick()
2491 .is_some_and(|nick| nick.eq_ignore_ascii_case(&needle))
2492 })
2493 }
2494}
2495
2496fn server_name_of(mxid: &str) -> Result<&str, ShellError> {
2497 mxid.split_once(':')
2498 .map(|(_, server)| server)
2499 .filter(|server| !server.is_empty())
2500 .ok_or_else(|| ShellError::Register("user id has no server".to_string()))
2501}
2502
2503
2504fn attach_detected_chain(store: &mut OpenedStore, config: &SessionConfig, nick: &str) {
2510 if let Some((session, chain)) = crate::provider::chain::detected_web_chain(
2511 config.session_id(),
2512 nick,
2513 &config.store_root,
2514 ) {
2515 store.set_wake_chain(session, chain);
2516 }
2517}
2518
2519#[cfg(test)]
2520mod tests {
2521 #[cfg(unix)]
2522 #[test]
2523 fn a_routine_file_gives_a_session_without_an_agent_id_its_wake_and_leaks_nothing() {
2524 use std::os::unix::fs::PermissionsExt;
2525 let dir = tempfile::tempdir().expect("dir");
2526 let file = dir.path().join("wake.json");
2527 let write = |text: &str, mode: u32| {
2528 std::fs::write(&file, text).expect("write");
2529 std::fs::set_permissions(&file, std::fs::Permissions::from_mode(mode)).expect("mode");
2530 };
2531 let mut set = vec![HostSession::new("Courier", "web-courier").with_routine_file(&file)];
2532
2533 write(r#"{"url":"http://127.0.0.1:9/hook","key":"k-secret"}"#, 0o600);
2535 attach_routine_files(&mut set, None);
2536 assert_eq!(set[0].routine_url.as_deref(), Some("http://127.0.0.1:9/hook"));
2537 assert_eq!(set[0].routine_bearer.as_deref(), Some("k-secret"));
2538 assert!(!format!("{:?}", set[0]).contains("k-secret"), "Debug must not show the key");
2539
2540 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)] {
2542 write(text, mode);
2543 let err = read_routine_file(&file).expect_err("refused");
2544 assert!(!err.contains("k-secret") && !err.contains("127.0.0.1"), "{err}");
2545 let mut one = vec![HostSession::new("Courier", "web-courier").with_routine_file(&file)];
2546 attach_routine_files(&mut one, None);
2547 assert!(one[0].routine_url.is_none() && one[0].routine_bearer.is_none());
2548 }
2549 write(r#"{"url":"http://127.0.0.1:9/other","key":"k2"}"#, 0o600);
2551 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()))];
2552 attach_routine_files(&mut held, None);
2553 assert_eq!(held[0].routine_url.as_deref(), Some("http://127.0.0.1:9/mine"));
2554 }
2555
2556 #[cfg(unix)]
2557 #[test]
2558 fn a_wake_from_a_routine_file_is_recorded_with_its_source() {
2559 use std::os::unix::fs::PermissionsExt;
2560 let dir = tempfile::tempdir().expect("dir");
2561 let file = dir.path().join("wake.json");
2562 std::fs::write(&file, r#"{"url":"http://127.0.0.1:9/hook","key":"k"}"#).expect("write");
2563 std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o600)).expect("mode");
2564 let mut set = vec![HostSession::new("Courier", "web-courier").with_routine_file(&file)];
2565 attach_routine_files(&mut set, Some(dir.path()));
2566 let status = crate::wake_policy::WakeStatusFile::load(&crate::session_store_dir(dir.path(), "web-courier")).expect("status");
2567 assert_eq!((status.state.as_str(), status.source.as_str()), ("ready", "file"));
2568 }
2569
2570 #[test]
2571 fn a_session_record_may_name_a_routine_file_but_never_carry_the_secret() {
2572 let dir = tempfile::tempdir().expect("dir");
2573 std::fs::write(dir.path().join("a.json"), r#"{"bot_name":"Courier","session_id":"web-courier","routine_file":"/some/where/wake.json"}"#).expect("write");
2574 let loaded = load_session_records(dir.path()).expect("records");
2575 assert_eq!(loaded[0].routine_file.as_deref(), Some(Path::new("/some/where/wake.json")));
2576 assert!(loaded[0].routine_url.is_none());
2577 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");
2578 assert!(load_session_records(dir.path()).is_err(), "a URL in the record is still refused");
2579 }
2580
2581 use super::*;
2582
2583 #[test]
2584 fn web_client_agents_dir_is_only_the_env_path() {
2585 assert!(explicit_agents_dir(|_| None).is_none());
2586 let missing = std::env::temp_dir().join(format!(
2587 "m4a-missing-agents-{}",
2588 std::process::id()
2589 ));
2590 let _ = std::fs::remove_dir_all(&missing);
2591 assert!(explicit_agents_dir(|key| {
2592 (key == AGENTS_DIR_ENV).then(|| missing.display().to_string())
2593 })
2594 .is_none());
2595 let present = std::env::temp_dir().join(format!(
2596 "m4a-present-agents-{}-{}",
2597 std::process::id(),
2598 std::time::SystemTime::now()
2599 .duration_since(std::time::UNIX_EPOCH)
2600 .expect("clock")
2601 .as_nanos()
2602 ));
2603 std::fs::create_dir_all(&present).expect("dir");
2604 let got = explicit_agents_dir(|key| {
2605 (key == AGENTS_DIR_ENV).then(|| present.display().to_string())
2606 });
2607 assert_eq!(got.as_deref(), Some(present.as_path()));
2608 let _ = std::fs::remove_dir_all(&present);
2609 }
2610
2611 #[test]
2612 fn rescan_opens_only_bots_without_a_session() {
2613 let root = Path::new("/tmp/m4a-rescan-test");
2614 let agent = |name: &str, id: &str| {
2615 let mut session = HostSession::new(name, id);
2616 session.agent_id = Some(id.to_string());
2617 session
2618 };
2619 let found = vec![
2620 agent("alice", "a-hatch"),
2621 agent("m4a-proba2", "a-proba2"),
2622 agent("skipme", "a-skip"),
2623 agent("aliased", "a-alias"),
2624 ];
2625 let held = vec![
2626 (crate::session_store_dir(root, "a-hatch"), Some("alice".to_string())),
2627 (crate::session_store_dir(root, "old-alias"), None),
2628 ];
2629 let fresh = unopened_agent_sessions(
2630 found,
2631 &["skipme".to_string()],
2632 &[("a-alias".to_string(), "old-alias".to_string())],
2633 root,
2634 &held,
2635 );
2636 let nicks: Vec<&str> = fresh.iter().map(|(_, nick)| nick.as_str()).collect();
2637 assert_eq!(nicks, vec!["m4a-proba2"]);
2638 assert_eq!(fresh[0].0.session_id, "a-proba2");
2639 }
2640
2641 #[test]
2642 fn session_directory_is_name_and_id_only() {
2643 let dir = std::env::temp_dir().join(format!(
2644 "m4a-sessions-{}-{}",
2645 std::process::id(),
2646 std::time::SystemTime::now()
2647 .duration_since(std::time::UNIX_EPOCH)
2648 .expect("clock")
2649 .as_nanos()
2650 ));
2651 std::fs::create_dir_all(&dir).expect("dir");
2652 std::fs::write(
2653 dir.join("alice.json"),
2654 r#"{"bot_name":"Alice","session_id":"web-alice"}"#,
2655 )
2656 .expect("write");
2657 std::fs::write(
2658 dir.join("chief.json"),
2659 "{\"bot_name\":\"Привет мир\",\"session_id\":\"web-chief\"}",
2660 )
2661 .expect("write");
2662 let loaded = load_session_records(&dir).expect("records");
2663 assert_eq!(loaded.len(), 2);
2664 assert_eq!(loaded[1].bot_name, "Привет мир");
2665 assert_eq!(loaded[0].bot_name, "Alice");
2666 assert!(loaded[1].agent_id.is_none());
2667 assert!(loaded[0].agent_id.is_none());
2668 assert!(loaded[1].routine_url.is_none());
2669 assert!(loaded[1].routine_bearer.is_none());
2670 assert!(loaded[1].invite.is_none());
2671
2672 std::fs::write(
2673 dir.join("leaked.json"),
2674 r#"{"bot_name":"Courier","session_id":"web-courier","routine_url":"http://127.0.0.1/hook","routine_bearer":"not-a-file"}"#,
2675 )
2676 .expect("write");
2677 let refused = load_session_records(&dir).expect_err("webhook file");
2678 let text = refused.to_string();
2679 assert!(!text.contains("not-a-file"));
2680 assert!(!text.contains("127.0.0.1/hook"));
2681 let _ = std::fs::remove_dir_all(&dir);
2682 }
2683
2684 #[test]
2685 fn web_client_does_not_copy_one_routine_onto_every_session_and_node_cli_refuses_it() {
2686 let dir = std::env::temp_dir().join(format!(
2687 "m4a-split-{}-{}",
2688 std::process::id(),
2689 std::time::SystemTime::now()
2690 .duration_since(std::time::UNIX_EPOCH)
2691 .expect("clock")
2692 .as_nanos()
2693 ));
2694 std::fs::create_dir_all(&dir).expect("dir");
2695 let record = dir.join("alice.json");
2696 let body = r#"{"bot_name":"Alice","session_id":"web-alice"}"#;
2697 std::fs::write(&record, body).expect("write");
2698 let routine = "http://127.0.0.1:9/routine";
2699 let bearer = "host-injected-bearer";
2700 let sessions = load_web_sessions(&dir, |key| match key {
2701 crate::ROUTINE_URL_ENV => Some(routine.to_string()),
2702 crate::ROUTINE_BEARER_ENV => Some(bearer.to_string()),
2703 crate::LEADER_SOCK_ENV => Some("/tmp/leader.sock".to_string()),
2704 _ => None,
2705 })
2706 .expect("web sessions");
2707 assert_eq!(sessions.len(), 1);
2708 assert!(sessions[0].routine_url.is_none());
2709 assert!(sessions[0].routine_bearer.is_none());
2710 assert_eq!(std::fs::read_to_string(&record).expect("reread"), body);
2711 let wake = crate::SessionWake::web_from_lookup(|key| match key {
2712 crate::ROUTINE_URL_ENV => Some(routine.to_string()),
2713 crate::LEADER_SOCK_ENV => Some("/tmp/leader.sock".to_string()),
2714 _ => None,
2715 });
2716 assert!(wake.leader_sock.is_none());
2717 assert!(wake.leader_cwd.is_none());
2718
2719 std::fs::write(
2720 dir.join("leaked.json"),
2721 r#"{"bot_name":"Courier","session_id":"web-courier","routine_url":"http://127.0.0.1:9/from-file","routine_bearer":"file-bearer"}"#,
2722 )
2723 .expect("leak");
2724 let refused = load_web_sessions(&dir, |_| None).expect_err("json routine");
2725 let text = refused.to_string();
2726 assert!(!text.contains("from-file"));
2727 assert!(!text.contains("file-bearer"));
2728
2729 let node = crate::OpenedStore::connect_node_from_lookup(
2730 |key| match key {
2731 crate::ROUTINE_URL_ENV => Some(routine.to_string()),
2732 crate::ROUTINE_BEARER_ENV => Some(bearer.to_string()),
2733 crate::LEADER_SOCK_ENV => Some("/tmp/leader.sock".to_string()),
2734 _ => None,
2735 },
2736 None,
2737 );
2738 let err = match node {
2739 Ok(_) => panic!("node cli accepted a routine url"),
2740 Err(err) => err,
2741 };
2742 assert!(matches!(err, crate::ShellError::NodeRoutine));
2743 let text = err.to_string();
2744 assert!(!text.contains(routine));
2745 assert!(!text.contains(bearer));
2746 assert!(!text.contains("leader.sock"));
2747
2748 let node = crate::SessionWake::node_from_lookup(|key| match key {
2749 crate::LEADER_SOCK_ENV => Some("/tmp/node-leader.sock".to_string()),
2750 crate::LEADER_CWD_ENV => Some("/tmp/node".to_string()),
2751 _ => None,
2752 })
2753 .expect("node leader");
2754 assert!(node.routine_url.is_none());
2755 assert!(node.routine_bearer.is_none());
2756 assert_eq!(
2757 node.leader_sock.as_deref(),
2758 Some(std::path::Path::new("/tmp/node-leader.sock"))
2759 );
2760 assert_eq!(node.leader_cwd.as_deref(), Some("/tmp/node"));
2761 let _ = std::fs::remove_dir_all(&dir);
2762 }
2763
2764 #[test]
2765 fn mirrors_follow_the_nick_folder_and_keys_wait_for_the_bot_routine() {
2766 use std::collections::HashSet;
2767 use std::io::{Read, Write};
2768 use std::net::TcpListener;
2769 use std::sync::atomic::{AtomicBool, Ordering};
2770 use std::sync::{Arc, Mutex};
2771 use std::thread;
2772
2773 struct Card {
2774 agent: String,
2775 id: String,
2776 name: String,
2777 trigger: &'static str,
2778 enabled: bool,
2779 }
2780 #[derive(Default)]
2781 struct Gateway {
2782 creates: usize,
2783 deletes: usize,
2784 cards: Vec<Card>,
2785 backend: HashSet<(String, String)>,
2787 clash: HashSet<String>,
2789 agents_seen: HashSet<String>,
2790 profiles: Vec<(String, serde_json::Value)>,
2791 }
2792
2793 fn read_http(sock: &mut std::net::TcpStream) -> Option<(String, Vec<u8>)> {
2794 let _ = sock.set_read_timeout(Some(std::time::Duration::from_secs(2)));
2795 let mut buf = Vec::new();
2796 let mut tmp = [0u8; 2048];
2797 loop {
2798 let n = sock.read(&mut tmp).unwrap_or(0);
2799 if n == 0 {
2800 return None;
2801 }
2802 buf.extend_from_slice(&tmp[..n]);
2803 let Some(end) = buf.windows(4).position(|w| w == b"\r\n\r\n") else {
2804 continue;
2805 };
2806 let headers = String::from_utf8_lossy(&buf[..end]).to_string();
2807 let length = headers
2808 .lines()
2809 .find_map(|line| {
2810 let (name, value) = line.split_once(':')?;
2811 name.eq_ignore_ascii_case("content-length")
2812 .then(|| value.trim().parse::<usize>().ok())
2813 .flatten()
2814 })
2815 .unwrap_or(0);
2816 if buf.len() >= end + 4 + length {
2817 return Some((headers, buf[end + 4..end + 4 + length].to_vec()));
2818 }
2819 }
2820 }
2821 fn reply(status: &str, body: &serde_json::Value) -> Vec<u8> {
2822 let payload = serde_json::to_vec(body).expect("json");
2823 let mut out = format!(
2824 "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
2825 payload.len()
2826 )
2827 .into_bytes();
2828 out.extend(payload);
2829 out
2830 }
2831 fn cards_of(gate: &Gateway, agent: &str) -> serde_json::Value {
2832 serde_json::Value::Array(
2833 gate.cards
2834 .iter()
2835 .filter(|card| card.agent == agent)
2836 .map(|card| {
2837 serde_json::json!({
2838 "id": card.id,
2839 "name": card.name,
2840 "trigger": {"type": card.trigger},
2841 "isEnabled": card.enabled,
2842 })
2843 })
2844 .collect(),
2845 )
2846 }
2847
2848 let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
2849 listener.set_nonblocking(true).expect("nonblocking");
2850 let port = listener.local_addr().expect("addr").port();
2851 let state = Arc::new(Mutex::new(Gateway::default()));
2852 {
2853 let mut gate = state.lock().expect("gate");
2854 gate.backend.insert(("agent-h".into(), "alice".into()));
2855 gate.clash.insert("agent-x".into());
2856 gate.cards.push(Card {
2857 agent: "agent-k".into(),
2858 id: "cron-bot".into(),
2859 name: "cron_bot".into(),
2860 trigger: "cron",
2861 enabled: true,
2862 });
2863 }
2864 let shared = Arc::clone(&state);
2865 let token = "gw-test-token";
2866 let done = Arc::new(AtomicBool::new(false));
2867 let flag = Arc::clone(&done);
2868 let server = thread::spawn(move || {
2869 while !flag.load(Ordering::Relaxed) {
2870 let (mut sock, _) = match listener.accept() {
2871 Ok(pair) => pair,
2872 Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
2873 thread::sleep(std::time::Duration::from_millis(5));
2874 continue;
2875 }
2876 Err(_) => break,
2877 };
2878 let _ = sock.set_nonblocking(false);
2879 let Some((headers, body)) = read_http(&mut sock) else {
2880 continue;
2881 };
2882 let lower = headers.to_ascii_lowercase();
2883 let authorized = lower.contains(&format!("authorization: bearer {token}"));
2884 let request: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
2885 let agent = request["id"].as_str().unwrap_or("").to_string();
2886 let mut gate = shared.lock().expect("gate");
2887 gate.agents_seen.insert(agent.clone());
2888 let response = if !authorized {
2889 reply("401 Unauthorized", &serde_json::json!({}))
2890 } else if lower.starts_with("post /api/getagentautomations ") {
2891 reply("200 OK", &cards_of(&gate, &agent))
2892 } else if lower.starts_with("post /api/createagentautomation ") {
2893 let spec = &request["spec"];
2894 assert_eq!(spec["trigger"]["type"], "webhook");
2895 assert_eq!(spec["isEnabled"], false, "mirror must be disabled");
2896 let name = spec["name"].as_str().unwrap_or("").to_string();
2897 let prompt = spec["prompt"].as_str().unwrap_or("");
2898 assert!(!prompt.is_empty());
2899 assert!(!prompt.contains("http"));
2900 gate.creates += 1;
2901 let folder = crate::nick::routine_folder_id(&name).expect("slug");
2902 let id = if gate.clash.contains(&agent) {
2903 format!("{folder}-2")
2904 } else {
2905 folder
2906 };
2907 gate.cards.push(Card {
2908 agent: agent.clone(),
2909 id,
2910 name,
2911 trigger: "webhook",
2912 enabled: false,
2913 });
2914 reply("200 OK", &cards_of(&gate, &agent))
2915 } else if lower.starts_with("post /api/updateagent ") {
2916 gate.profiles
2917 .push((agent.clone(), request["profile"].clone()));
2918 reply("200 OK", &serde_json::json!({"id": agent}))
2919 } else if lower.starts_with("post /api/deleteagentautomation ") {
2920 let id = request["automationId"].as_str().unwrap_or("");
2921 gate.deletes += 1;
2922 gate.cards
2923 .retain(|card| !(card.agent == agent && card.id == id));
2924 reply("200 OK", &cards_of(&gate, &agent))
2925 } else if lower.starts_with("post /api/getautomationwebhookcredential ") {
2926 let id = request["automationId"].as_str().unwrap_or("").to_string();
2927 let local = gate
2928 .cards
2929 .iter()
2930 .find(|card| card.agent == agent && card.id == id);
2931 match local {
2932 None => reply(
2933 "500 Internal Server Error",
2934 &serde_json::json!({"error": format!("Automation not found: {id}")}),
2935 ),
2936 Some(card) if card.trigger != "webhook" => reply(
2937 "500 Internal Server Error",
2938 &serde_json::json!({"error": "Automation is not webhook-triggered"}),
2939 ),
2940 Some(_) => {
2941 let minted = gate.backend.contains(&(agent.clone(), id.clone()));
2942 reply(
2943 "200 OK",
2944 &serde_json::json!({
2945 "url": format!("https://backend.invalid/automations/webhook/{agent}-{id}"),
2946 "key": minted.then(|| format!("key-{agent}-{id}")),
2947 }),
2948 )
2949 }
2950 }
2951 } else {
2952 reply("404 Not Found", &serde_json::json!({}))
2953 };
2954 drop(gate);
2955 let _ = sock.write_all(&response);
2956 }
2957 });
2958
2959 let dir = std::env::temp_dir().join(format!(
2960 "m4a-mirror-{}-{}",
2961 std::process::id(),
2962 std::time::SystemTime::now()
2963 .duration_since(std::time::UNIX_EPOCH)
2964 .expect("clock")
2965 .as_nanos()
2966 ));
2967 let agents = dir.join("agents");
2968 let store_root = dir.join("stores");
2969 let describe = |id: &str| {
2970 if id == "agent-c" {
2971 "about the chief"
2972 } else {
2973 ""
2974 }
2975 };
2976 for (id, name) in [
2977 ("agent-h", "Alice"),
2978 ("agent-c", "Привет мир"),
2979 ("agent-s", "Свой браузер"),
2980 ("agent-x", "Clash Bot"),
2981 ("agent-k", "Cron Bot"),
2982 ] {
2983 std::fs::create_dir_all(agents.join(id)).expect("agent dir");
2984 std::fs::write(
2985 agents.join(id).join("profile.json"),
2986 serde_json::to_vec(&serde_json::json!({"name": name, "description": describe(id)}))
2987 .expect("profile"),
2988 )
2989 .expect("profile");
2990 }
2991 let gateway = dir.join("gateway-file.json");
2992 std::fs::write(
2993 &gateway,
2994 format!(r#"{{"host":"192.0.2.1","port":{port},"scheme":"http","token":"{token}"}}"#),
2995 )
2996 .expect("gateway");
2997 let skip = parse_skip_nicks(" svoi-brauzer , ");
2998 assert_eq!(skip, vec!["svoi-brauzer".to_string()]);
2999 let options = WakeOptions {
3000 skip_nicks: skip.clone(),
3001 store_root: Some(store_root.clone()),
3002 profile_note: false,
3003 session_ids: Vec::new(),
3004 };
3005
3006 let status_of = |reports: &[RoutineReport], agent: &str| {
3007 reports
3008 .iter()
3009 .find(|report| report.agent_id == agent)
3010 .map(|report| report.status.clone())
3011 };
3012 let first =
3013 ensure_agent_webhook_routines(&agents, &gateway, None, &options).expect("first pass");
3014 assert_eq!(status_of(&first, "agent-h"), Some(WakeStatus::Ready));
3015 assert_eq!(
3016 status_of(&first, "agent-c"),
3017 Some(WakeStatus::AwaitingBackend)
3018 );
3019 assert!(matches!(
3020 status_of(&first, "agent-x"),
3021 Some(WakeStatus::Failed(_))
3022 ));
3023 assert!(matches!(
3024 status_of(&first, "agent-k"),
3025 Some(WakeStatus::Failed(_))
3026 ));
3027 assert_eq!(status_of(&first, "agent-s"), None);
3028 let chief = first
3029 .iter()
3030 .find(|r| r.agent_id == "agent-c")
3031 .expect("chief");
3032 assert_eq!(chief.nick, "privet-mir");
3033 assert_eq!(chief.folder_id, "privet-mir");
3034 let shown = format!("{first:?}");
3035 assert!(!shown.contains("key-"));
3036 assert!(!shown.contains("automations/webhook"));
3037 {
3038 let gate = state.lock().expect("gate");
3039 assert_eq!(gate.creates, 3);
3042 assert_eq!(gate.deletes, 1);
3043 assert!(!gate.agents_seen.contains("agent-s"));
3044 assert!(gate.cards.iter().all(|card| card.agent != "agent-x"));
3045 let cron = gate
3046 .cards
3047 .iter()
3048 .find(|card| card.agent == "agent-k")
3049 .expect("cron");
3050 assert_eq!(cron.trigger, "cron");
3051 assert!(cron.enabled);
3052 let mirror = gate
3053 .cards
3054 .iter()
3055 .find(|card| card.agent == "agent-h")
3056 .expect("mirror");
3057 assert_eq!(
3058 (mirror.id.as_str(), mirror.name.as_str()),
3059 ("alice", "alice")
3060 );
3061 let chief_mirror = gate
3062 .cards
3063 .iter()
3064 .find(|card| card.agent == "agent-c")
3065 .expect("chief mirror");
3066 assert_eq!(chief_mirror.name, "privet-mir");
3068 assert_eq!(chief_mirror.id, "privet-mir");
3069 assert!(!mirror.enabled);
3070 }
3071 let alice_file = keychain_path(&store_root, "agent-h");
3073 let stored: serde_json::Value =
3074 serde_json::from_slice(&std::fs::read(&alice_file).expect("keychain"))
3075 .expect("keychain json");
3076 assert_eq!(stored["folder_id"], "alice");
3077 assert_eq!(stored["key"], "key-agent-h-alice");
3078 #[cfg(unix)]
3079 {
3080 use std::os::unix::fs::PermissionsExt;
3081 let mode = std::fs::metadata(&alice_file)
3082 .expect("meta")
3083 .permissions()
3084 .mode();
3085 assert_eq!(mode & 0o777, 0o600);
3086 }
3087 assert!(!keychain_path(&store_root, "agent-c").exists());
3088
3089 let second =
3091 ensure_agent_webhook_routines(&agents, &gateway, None, &options).expect("second pass");
3092 assert_eq!(
3093 status_of(&second, "agent-c"),
3094 Some(WakeStatus::AwaitingBackend)
3095 );
3096 assert_eq!(
3097 state.lock().expect("gate").creates,
3098 4,
3099 "only the clash bot retries"
3100 );
3101 assert!(state.lock().expect("gate").profiles.is_empty());
3102 let noted = WakeOptions {
3105 profile_note: true,
3106 ..options.clone()
3107 };
3108 ensure_agent_webhook_routines(&agents, &gateway, None, ¬ed).expect("note pass");
3109 {
3110 let gate = state.lock().expect("gate");
3111 assert_eq!(gate.profiles.len(), 1);
3112 let (agent, profile) = &gate.profiles[0];
3113 assert_eq!(agent, "agent-c");
3114 assert_eq!(profile["name"], "Привет мир");
3115 let description = profile["description"].as_str().unwrap_or("");
3116 assert!(description.starts_with("about the chief"));
3117 assert!(description.contains("routine named \"privet-mir\""));
3118 assert!(!description.contains("http"));
3119 }
3120 state.lock().expect("gate").creates = 4;
3121 state
3124 .lock()
3125 .expect("gate")
3126 .backend
3127 .insert(("agent-c".into(), "privet-mir".into()));
3128 let third =
3129 ensure_agent_webhook_routines(&agents, &gateway, None, &options).expect("third pass");
3130 assert_eq!(status_of(&third, "agent-c"), Some(WakeStatus::Ready));
3131 assert_eq!(state.lock().expect("gate").creates, 5);
3132 assert!(keychain_path(&store_root, "agent-c").exists());
3133
3134 let gateway_path = gateway.display().to_string();
3136 let root_text = store_root.display().to_string();
3137 let sessions = load_web_agents(&agents, |key| match key {
3138 GATEWAY_FILE_ENV => Some(gateway_path.clone()),
3139 SKIP_NICKS_ENV => Some("svoi-brauzer".to_string()),
3140 crate::STORE_ROOT_ENV => Some(root_text.clone()),
3141 _ => None,
3142 })
3143 .expect("web agents");
3144 assert_eq!(sessions.len(), 4);
3145 let alice = sessions
3146 .iter()
3147 .find(|s| s.session_id == "agent-h")
3148 .expect("h");
3149 assert!(alice
3150 .routine_url
3151 .as_deref()
3152 .unwrap_or("")
3153 .starts_with("https://"));
3154 assert!(alice.routine_bearer.is_some());
3155 assert!(!format!("{alice:?}").contains("key-"));
3156
3157 let mut offline = vec![HostSession::new("Alice", "agent-h")];
3159 offline[0].agent_id = Some("agent-h".to_string());
3160 let mut renamed = HostSession::new("Alice Two", "agent-h");
3161 renamed.agent_id = Some("agent-h".to_string());
3162 offline.push(renamed);
3163 attach_webhook_routines(
3164 &mut offline,
3165 &dir.join("absent.json"),
3166 None,
3167 Some(&store_root),
3168 )
3169 .expect("offline");
3170 assert_eq!(
3171 offline[0].routine_bearer.as_deref(),
3172 Some("key-agent-h-alice")
3173 );
3174 assert!(offline[1].routine_url.is_none());
3175 let mut bare = vec![HostSession::new("Alice", "agent-h")];
3176 attach_webhook_routines(&mut bare, &dir.join("absent.json"), None, None).expect("bare");
3177 assert!(bare[0].routine_url.is_none());
3178
3179 done.store(true, Ordering::Relaxed);
3180 let _ = server.join();
3181 let _ = std::fs::remove_dir_all(&dir);
3182 }
3183
3184 #[test]
3185 fn wake_note_is_appended_once_and_replaced_in_place() {
3186 let first = with_wake_note("Runs the alice.", "alice").expect("added");
3187 assert!(first.starts_with("Runs the alice.\n\n<!-- mail4agent:wake -->"));
3188 assert!(first.ends_with("<!-- /mail4agent:wake -->"));
3189 assert!(with_wake_note(&first, "alice").is_none());
3190 let renamed = with_wake_note(&first, "alice-two").expect("replaced");
3191 assert_eq!(renamed.matches("<!-- mail4agent:wake -->").count(), 1);
3192 assert!(renamed.contains("\"alice-two\""));
3193 assert!(!renamed.contains("\"alice\""));
3194 assert!(renamed.starts_with("Runs the alice."));
3195 assert_eq!(with_wake_note("", "carol").expect("empty"), wake_note("carol"));
3196 }
3197
3198 #[test]
3199 fn agents_directory_becomes_sessions_without_hardcoded_ids() {
3200 let dir = std::env::temp_dir().join(format!(
3201 "m4a-agents-{}-{}",
3202 std::process::id(),
3203 std::time::SystemTime::now()
3204 .duration_since(std::time::UNIX_EPOCH)
3205 .expect("clock")
3206 .as_nanos()
3207 ));
3208 let alice = dir.join("agent-alice");
3209 let chief = dir.join("agent-chief");
3210 std::fs::create_dir_all(&alice).expect("dir");
3211 std::fs::create_dir_all(&chief).expect("dir");
3212 std::fs::write(
3213 alice.join("profile.json"),
3214 r#"{"name":"Alice","description":"x"}"#,
3215 )
3216 .expect("profile");
3217 std::fs::write(
3218 chief.join("profile.json"),
3219 concat!(
3220 "{\"name\":\"",
3221 "Привет мир",
3222 "\",\"description\":\"x\"}"
3223 ),
3224 )
3225 .expect("profile");
3226 std::fs::write(dir.join("active-agent.json"), "{}").expect("skip file");
3227 let loaded = load_agents_dir(&dir).expect("agents");
3228 assert_eq!(loaded.len(), 2);
3229 assert_eq!(loaded[1].session_id, "agent-chief");
3230 assert_eq!(loaded[1].agent_id.as_deref(), Some("agent-chief"));
3231 assert_eq!(loaded[1].bot_name, "Привет мир");
3232 assert_eq!(
3233 routine_name_for(&loaded[1]).as_deref(),
3234 Some("privet-mir")
3235 );
3236 assert_ne!(
3237 routine_name_for(&loaded[1]).as_deref(),
3238 Some(loaded[1].session_id.as_str())
3239 );
3240 assert_eq!(loaded[0].bot_name, "Alice");
3241 assert_eq!(routine_name_for(&loaded[0]).as_deref(), Some("alice"));
3242 assert!(loaded.iter().all(|session| session.routine_url.is_none()));
3243 let _ = std::fs::remove_dir_all(&dir);
3244 }
3245}