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 AGENT_RESCAN_SECS_ENV: &str = "M4A_AGENT_RESCAN_SECS";
109
110pub struct HostSession {
115 pub bot_name: String,
117 pub session_id: String,
120 pub agent_id: Option<String>,
123 pub routine_url: Option<String>,
125 pub routine_bearer: Option<String>,
127 pub invite: Option<String>,
130 pub routine_file: Option<PathBuf>,
133 pub tier: m4a_agent::BackendKind,
135}
136
137impl HostSession {
138 pub fn new(bot_name: impl Into<String>, session_id: impl Into<String>) -> Self {
140 Self {
141 bot_name: bot_name.into(),
142 session_id: session_id.into(),
143 agent_id: None,
144 routine_url: None,
145 routine_bearer: None,
146 invite: None,
147 routine_file: None,
148 tier: m4a_agent::BackendKind::Server,
149 }
150 }
151
152 pub fn with_routine_file(mut self, path: impl Into<PathBuf>) -> Self {
154 self.routine_file = Some(path.into());
155 self
156 }
157
158 pub(crate) fn config(&self, url: &str, store_root: &Path) -> Result<SessionConfig, ShellError> {
159 Ok(SessionConfig::new_identity(url, self.tier, &self.session_id, store_root, self.invite.clone())?.with_nick_request(&self.bot_name))
160 }
161
162 pub fn with_invite(mut self, invite: impl Into<String>) -> Self {
164 self.invite = Some(invite.into());
165 self
166 }
167
168 pub fn with_tier(mut self, tier: m4a_agent::BackendKind) -> Self {
170 self.tier = tier;
171 self
172 }
173
174 pub fn with_routine(mut self, url: impl Into<String>, bearer: Option<String>) -> Self {
176 self.routine_url = Some(url.into());
177 self.routine_bearer = bearer.filter(|token| !token.is_empty());
178 self
179 }
180
181}
182
183impl std::fmt::Debug for HostSession {
184 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
185 f.debug_struct("HostSession")
186 .field("bot_name", &self.bot_name)
187 .field("session_id", &self.session_id)
188 .field("agent_id", &self.agent_id)
189 .field("routine_url", &self.routine_url.as_ref().map(|_| "[set]"))
190 .field(
191 "routine_bearer",
192 &self.routine_bearer.as_ref().map(|_| "[redacted]"),
193 )
194 .finish()
195 }
196}
197
198#[derive(serde::Deserialize)]
199#[serde(deny_unknown_fields)]
200struct SessionFile {
201 bot_name: String,
202 session_id: String,
203 #[serde(default)]
205 agent_id: Option<String>,
206 #[serde(default)]
209 routine_file: Option<String>,
210}
211
212pub fn load_session_records(dir: &Path) -> Result<Vec<HostSession>, ShellError> {
216 if !dir.is_dir() {
217 return Err(ShellError::SessionList(
218 "session directory is missing".to_string(),
219 ));
220 }
221 let mut paths = Vec::new();
222 for entry in std::fs::read_dir(dir)? {
223 let entry = entry?;
224 let path = entry.path();
225 if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
226 continue;
227 }
228 paths.push(path);
229 }
230 paths.sort();
231 if paths.is_empty() {
232 return Err(ShellError::SessionList(
233 "session directory has no records".to_string(),
234 ));
235 }
236 let mut out = Vec::with_capacity(paths.len());
237 for path in paths {
238 let text = std::fs::read_to_string(&path)
239 .map_err(|err| ShellError::SessionList(clip_public(err.to_string())))?;
240 let file: SessionFile = serde_json::from_str(&text)
241 .map_err(|err| ShellError::SessionList(clip_public(err.to_string())))?;
242 let mut session = HostSession::new(file.bot_name, file.session_id);
243 session.agent_id = file
244 .agent_id
245 .map(|id| id.trim().to_string())
246 .filter(|id| !id.is_empty());
247 session.routine_file = file.routine_file.map(|p| PathBuf::from(p.trim())).filter(|p| !p.as_os_str().is_empty());
248 out.push(session);
249 }
250 Ok(out)
251}
252
253#[derive(serde::Deserialize)]
254struct AgentProfileFile {
255 name: String,
256}
257
258pub fn load_agents_dir(dir: &Path) -> Result<Vec<HostSession>, ShellError> {
262 if !dir.is_dir() {
263 return Err(ShellError::SessionList(
264 "agents directory is missing".to_string(),
265 ));
266 }
267 let mut ids = Vec::new();
268 for entry in std::fs::read_dir(dir)? {
269 let entry = entry?;
270 if !entry.file_type()?.is_dir() {
271 continue;
272 }
273 let name = entry.file_name();
274 let Some(id) = name.to_str() else {
275 continue;
276 };
277 if id.is_empty() || id.starts_with('.') {
278 continue;
279 }
280 ids.push(id.to_string());
281 }
282 ids.sort();
283 if ids.is_empty() {
284 return Err(ShellError::SessionList(
285 "agents directory has no agents".to_string(),
286 ));
287 }
288 let mut out = Vec::with_capacity(ids.len());
289 for id in ids {
290 let profile_path = dir.join(&id).join("profile.json");
291 if !profile_path.is_file() {
292 continue;
293 }
294 let text = std::fs::read_to_string(&profile_path)
295 .map_err(|err| ShellError::SessionList(clip_public(err.to_string())))?;
296 let profile: AgentProfileFile = serde_json::from_str(&text)
297 .map_err(|err| ShellError::SessionList(clip_public(err.to_string())))?;
298 let bot_name = profile.name.trim().to_string();
299 if bot_name.is_empty() {
300 continue;
301 }
302 let mut session = HostSession::new(bot_name, id.clone());
303 session.agent_id = Some(id);
304 out.push(session);
305 }
306 if out.is_empty() {
307 return Err(ShellError::SessionList(
308 "agents directory has no profiles".to_string(),
309 ));
310 }
311 Ok(out)
312}
313
314fn explicit_agents_dir(mut get: impl FnMut(&str) -> Option<String>) -> Option<PathBuf> {
319 let path = PathBuf::from(get(AGENTS_DIR_ENV)?);
320 path.is_dir().then_some(path)
321}
322
323fn resolve_agents_dir(mut get: impl FnMut(&str) -> Option<String>) -> Option<PathBuf> {
327 if let Some(path) = get(AGENTS_DIR_ENV).filter(|value| !value.is_empty()) {
328 let path = PathBuf::from(path);
329 if path.is_dir() {
330 return Some(path);
331 }
332 return None;
333 }
334 let default = under_home(DEFAULT_AGENTS_DIR);
335 if default.is_dir() {
336 Some(default)
337 } else {
338 None
339 }
340}
341
342fn load_web_sessions(
350 dir: &Path,
351 mut get: impl FnMut(&str) -> Option<String>,
352) -> Result<Vec<HostSession>, ShellError> {
353 let sessions = load_session_records(dir)?;
354 let mut sessions = drop_skipped(sessions, &skip_nicks(&mut get));
355 attach_routines_from_env(&mut sessions, &mut get)?;
356 Ok(sessions)
357}
358
359fn load_web_agents(
361 dir: &Path,
362 mut get: impl FnMut(&str) -> Option<String>,
363) -> Result<Vec<HostSession>, ShellError> {
364 let sessions = load_agents_dir(dir)?;
365 let mut sessions = drop_skipped(sessions, &skip_nicks(&mut get));
366 apply_session_ids(
367 &mut sessions,
368 &parse_session_ids(get(SESSION_IDS_ENV).as_deref().unwrap_or("")),
369 );
370 attach_routines_from_env(&mut sessions, &mut get)?;
371 Ok(sessions)
372}
373
374fn attach_routines_from_env(
375 sessions: &mut [HostSession],
376 get: &mut impl FnMut(&str) -> Option<String>,
377) -> Result<(), ShellError> {
378 let store_root = get(STORE_ROOT_ENV).map(PathBuf::from);
379 let Some(path) = get(GATEWAY_FILE_ENV).filter(|value| !value.is_empty()) else {
380 return Ok(());
381 };
382 let token = get(GATEWAY_TOKEN_ENV).filter(|value| !value.is_empty());
383 attach_webhook_routines(
384 sessions,
385 Path::new(&path),
386 token.as_deref(),
387 store_root.as_deref(),
388 )?;
389 Ok(())
390}
391
392fn unopened_agent_sessions(
396 found: Vec<HostSession>,
397 skip: &[String],
398 aliases: &[(String, String)],
399 store_root: &Path,
400 held: &[(PathBuf, Option<String>)],
401) -> Vec<(HostSession, String)> {
402 let mut sessions = drop_skipped(found, skip);
403 apply_session_ids(&mut sessions, aliases);
404 sessions
405 .into_iter()
406 .filter_map(|session| {
407 let nick = routine_name_for(&session)?;
408 let dir = crate::session_store_dir(store_root, &session.session_id);
409 let taken = held.iter().any(|(held_dir, held_nick)| {
410 *held_dir == dir
411 || held_nick
412 .as_deref()
413 .is_some_and(|held| held.eq_ignore_ascii_case(&nick))
414 });
415 (!taken).then_some((session, nick))
416 })
417 .collect()
418}
419
420pub const SESSION_IDS_ENV: &str = "M4A_SESSION_IDS";
425
426pub(crate) fn parse_session_ids(raw: &str) -> Vec<(String, String)> {
427 raw.split(',')
428 .filter_map(|pair| {
429 let (agent, session) = pair.split_once('=')?;
430 let (agent, session) = (agent.trim(), session.trim());
431 (!agent.is_empty() && !session.is_empty())
432 .then(|| (agent.to_string(), session.to_string()))
433 })
434 .collect()
435}
436
437pub(crate) fn apply_session_ids(sessions: &mut [HostSession], aliases: &[(String, String)]) {
438 for session in sessions.iter_mut() {
439 let Some(agent_id) = session.agent_id.as_deref() else {
440 continue;
441 };
442 if let Some((_, alias)) = aliases.iter().find(|(agent, _)| agent == agent_id) {
443 session.session_id = alias.clone();
444 }
445 }
446}
447
448pub const SKIP_NICKS_ENV: &str = "M4A_SKIP_NICKS";
452
453fn skip_nicks(get: &mut impl FnMut(&str) -> Option<String>) -> Vec<String> {
454 parse_skip_nicks(get(SKIP_NICKS_ENV).as_deref().unwrap_or(""))
455}
456
457fn parse_skip_nicks(raw: &str) -> Vec<String> {
458 raw.split(',')
459 .map(|item| item.trim().to_ascii_lowercase())
460 .filter(|item| !item.is_empty())
461 .collect()
462}
463
464fn drop_skipped(sessions: Vec<HostSession>, skip: &[String]) -> Vec<HostSession> {
465 if skip.is_empty() {
466 return sessions;
467 }
468 sessions
469 .into_iter()
470 .filter(|session| match routine_name_for(session) {
471 Some(nick) => !skip.iter().any(|item| item.eq_ignore_ascii_case(&nick)),
472 None => true,
473 })
474 .collect()
475}
476
477#[derive(Debug, Clone, PartialEq, Eq)]
480pub enum WakeStatus {
481 Ready,
483 AwaitingBackend,
487 Failed(String),
489}
490
491#[derive(Debug, Clone, PartialEq, Eq)]
493pub struct RoutineReport {
494 pub agent_id: String,
496 pub nick: String,
498 pub folder_id: String,
501 pub status: WakeStatus,
503}
504
505#[derive(Debug, Clone, Default)]
508pub struct WakeOptions {
509 pub skip_nicks: Vec<String>,
511 pub store_root: Option<PathBuf>,
513 pub profile_note: bool,
516 pub session_ids: Vec<(String, String)>,
518}
519
520impl WakeOptions {
521 pub fn from_lookup(mut get: impl FnMut(&str) -> Option<String>) -> Self {
523 Self {
524 skip_nicks: skip_nicks(&mut get),
525 store_root: get(STORE_ROOT_ENV).map(PathBuf::from),
526 profile_note: get(PROFILE_NOTE_ENV).as_deref() == Some("1"),
527 session_ids: parse_session_ids(get(SESSION_IDS_ENV).as_deref().unwrap_or("")),
528 }
529 }
530}
531
532pub fn ensure_agent_webhook_routines(
540 agents_dir: &Path,
541 gateway_file: &Path,
542 token_override: Option<&str>,
543 options: &WakeOptions,
544) -> Result<Vec<RoutineReport>, ShellError> {
545 let store_root = options.store_root.as_deref();
546 let mut sessions = drop_skipped(load_agents_dir(agents_dir)?, &options.skip_nicks);
547 apply_session_ids(&mut sessions, &options.session_ids);
548 let Some(gate) = open_gateway(gateway_file, token_override)? else {
549 return Err(ShellError::Gateway("gateway file is missing".to_string()));
550 };
551 let mut reports = Vec::new();
552 for session in &sessions {
553 let Some(agent_id) = session.agent_id.as_deref().filter(|id| !id.is_empty()) else {
554 continue;
555 };
556 let Some(nick) = routine_name_for(session) else {
557 continue;
558 };
559 let outcome = ensure_wake(&gate, agent_id, &nick);
560 let folder_id = crate::nick::routine_folder_id(&nick).unwrap_or_default();
561 if let (WakeOutcome::Ready { url, key }, Some(root)) = (&outcome, store_root) {
562 save_wake(root, &session.session_id, &folder_id, url, key);
563 }
564 log_outcome(&nick, &folder_id, &outcome);
565 if options.profile_note && matches!(outcome, WakeOutcome::AwaitingBackend) {
566 note_profile(&gate, agents_dir, agent_id, &nick);
567 }
568 reports.push(RoutineReport {
569 agent_id: agent_id.to_string(),
570 nick,
571 folder_id,
572 status: outcome.status(),
573 });
574 }
575 Ok(reports)
576}
577
578pub fn ensure_agent_webhook_routines_from_env() -> Result<Vec<RoutineReport>, ShellError> {
582 let mut get = |key: &str| std::env::var(key).ok().filter(|v| !v.is_empty());
583 let agents_dir = resolve_agents_dir(&mut get)
584 .ok_or_else(|| ShellError::SessionList("agents directory is missing".to_string()))?;
585 let gateway = get(GATEWAY_FILE_ENV).unwrap_or_else(|| DEFAULT_GATEWAY_FILE.to_string());
586 let token = get(GATEWAY_TOKEN_ENV);
587 let options = WakeOptions::from_lookup(&mut get);
588 ensure_agent_webhook_routines(&agents_dir, Path::new(&gateway), token.as_deref(), &options)
589}
590
591pub const PROFILE_NOTE_ENV: &str = "M4A_BOOTSTRAP_PROFILE_NOTE";
597
598const NOTE_OPEN: &str = "<!-- mail4agent:wake -->";
599const NOTE_CLOSE: &str = "<!-- /mail4agent:wake -->";
600const DESCRIPTION_MAX: usize = 20_000;
602
603fn wake_note(routine: &str) -> String {
606 format!(
607 "{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}"
608 )
609}
610
611fn with_wake_note(description: &str, routine: &str) -> Option<String> {
615 let note = wake_note(routine);
616 if description.contains(¬e) {
617 return None;
618 }
619 if let (Some(start), Some(end)) = (description.find(NOTE_OPEN), description.find(NOTE_CLOSE)) {
620 if end > start {
621 let mut out = String::with_capacity(description.len() + note.len());
622 out.push_str(&description[..start]);
623 out.push_str(¬e);
624 out.push_str(&description[end + NOTE_CLOSE.len()..]);
625 return Some(out);
626 }
627 }
628 let base = description.trim_end();
629 Some(if base.is_empty() {
630 note
631 } else {
632 format!("{base}\n\n{note}")
633 })
634}
635
636fn note_profile(gate: &GatewayConn, agents_dir: &Path, agent_id: &str, nick: &str) {
640 match ensure_profile_note(gate, agents_dir, agent_id, nick) {
641 Ok(true) => eprintln!("mail4agent: wake {nick}: bootstrap note written to the profile"),
642 Ok(false) => {}
643 Err(err) => eprintln!("mail4agent: wake {nick}: bootstrap note not written ({err})"),
644 }
645}
646
647fn ensure_profile_note(
648 gate: &GatewayConn,
649 agents_dir: &Path,
650 agent_id: &str,
651 nick: &str,
652) -> Result<bool, ShellError> {
653 let text = std::fs::read_to_string(agents_dir.join(agent_id).join("profile.json"))
654 .map_err(|_| ShellError::Gateway("profile is unreadable".to_string()))?;
655 let profile: serde_json::Value = serde_json::from_str(&text)
656 .map_err(|_| ShellError::Gateway("profile is unreadable".to_string()))?;
657 let field = |key: &str| profile.get(key).and_then(|value| value.as_str());
658 let Some(name) = field("name").filter(|name| !name.trim().is_empty()) else {
659 return Err(ShellError::Gateway("profile has no name".to_string()));
660 };
661 let routine = crate::nick::routine_folder_id(nick)
662 .ok_or_else(|| ShellError::Gateway("nick has no routine name".to_string()))?;
663 let Some(description) = with_wake_note(field("description").unwrap_or(""), &routine) else {
664 return Ok(false);
665 };
666 if description.chars().count() > DESCRIPTION_MAX {
667 return Err(ShellError::Gateway(
668 "description would be too long".to_string(),
669 ));
670 }
671 let mut body = serde_json::json!({ "name": name, "description": description });
672 for key in ["title", "avatarShape", "avatarColor"] {
673 if let Some(value) = field(key) {
674 body[key] = serde_json::Value::String(value.to_string());
675 }
676 }
677 let (status, _) = gateway_post(
678 gate,
679 "updateAgent",
680 &serde_json::json!({ "id": agent_id, "profile": body }),
681 )?;
682 if status != 200 {
683 return Err(ShellError::Gateway(format!(
684 "gateway profile status {status}"
685 )));
686 }
687 Ok(true)
688}
689
690const DEFAULT_GATEWAY_FILE: &str = "agent-data/gateway.json";
693
694pub const GATEWAY_FILE_ENV: &str = "M4A_GATEWAY_FILE";
698
699pub const GATEWAY_TOKEN_ENV: &str = "M4A_GATEWAY_TOKEN";
702
703const 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.";
707
708pub const WAKE_KEYCHAIN_FILE: &str = "routine-wake.json";
712
713#[derive(serde::Deserialize)]
714struct GatewayFile {
715 port: u16,
716 scheme: String,
717 #[serde(default)]
718 token: Option<String>,
719}
720
721#[derive(serde::Deserialize)]
722struct AutomationCard {
723 id: String,
724 name: String,
725 #[serde(default)]
726 trigger: Option<serde_json::Value>,
727}
728
729impl AutomationCard {
730 fn is_webhook(&self) -> bool {
733 fn has_webhook(value: &serde_json::Value) -> bool {
734 match value {
735 serde_json::Value::Array(items) => items.iter().any(has_webhook),
736 serde_json::Value::Object(map) => {
737 map.get("type").and_then(|kind| kind.as_str()) == Some("webhook")
738 || map
739 .values()
740 .any(|inner| inner.is_array() && has_webhook(inner))
741 }
742 _ => false,
743 }
744 }
745 self.trigger.as_ref().map(has_webhook).unwrap_or(true)
746 }
747}
748
749#[derive(serde::Deserialize)]
750struct WebhookCredential {
751 url: String,
752 key: Option<String>,
753}
754
755#[derive(serde::Serialize, serde::Deserialize)]
756#[serde(deny_unknown_fields)]
757struct StoredWake {
758 folder_id: String,
759 url: String,
760 key: String,
761}
762
763fn routine_name_for(session: &HostSession) -> Option<String> {
768 crate::nick::nick_from_display_name(session.bot_name.trim()).ok()
769}
770
771struct GatewayConn {
772 client: reqwest::blocking::Client,
773 base: String,
774 token: String,
775}
776
777fn open_gateway(
778 gateway_file: &Path,
779 token_override: Option<&str>,
780) -> Result<Option<GatewayConn>, ShellError> {
781 if !gateway_file.is_file() {
782 return Ok(None);
783 }
784 let text = std::fs::read_to_string(gateway_file)
785 .map_err(|_| ShellError::Gateway("gateway file is unreadable".to_string()))?;
786 let file: GatewayFile = serde_json::from_str(&text)
787 .map_err(|_| ShellError::Gateway("gateway file is unreadable".to_string()))?;
788 let scheme = file.scheme.trim();
789 if scheme != "http" && scheme != "https" {
790 return Err(ShellError::Gateway(
791 "gateway scheme is not http or https".to_string(),
792 ));
793 }
794 if file.port == 0 {
795 return Err(ShellError::Gateway("gateway port is unset".to_string()));
796 }
797 let token = token_override
798 .map(str::trim)
799 .filter(|value| !value.is_empty())
800 .map(str::to_string)
801 .or(file.token.filter(|value| !value.trim().is_empty()));
802 let Some(token) = token else {
803 return Err(ShellError::Gateway("gateway token is unset".to_string()));
804 };
805 let base = format!("{scheme}://127.0.0.1:{}", file.port);
807 Ok(Some(GatewayConn {
808 client: gateway_client()?,
809 base,
810 token,
811 }))
812}
813
814enum WakeOutcome {
817 Ready { url: String, key: String },
818 AwaitingBackend,
819 Failed(String),
820}
821
822impl WakeOutcome {
823 fn status(&self) -> WakeStatus {
824 match self {
825 WakeOutcome::Ready { .. } => WakeStatus::Ready,
826 WakeOutcome::AwaitingBackend => WakeStatus::AwaitingBackend,
827 WakeOutcome::Failed(reason) => WakeStatus::Failed(reason.clone()),
828 }
829 }
830}
831
832fn log_outcome(nick: &str, folder_id: &str, outcome: &WakeOutcome) {
833 match outcome {
834 WakeOutcome::Ready { .. } => eprintln!("mail4agent: wake {nick} ({folder_id}): ready"),
835 WakeOutcome::AwaitingBackend => eprintln!(
836 "mail4agent: wake {nick} ({folder_id}): no key yet; the bot has not created its own routine with this folder id; will retry"
837 ),
838 WakeOutcome::Failed(reason) => {
839 eprintln!("mail4agent: wake {nick} ({folder_id}): {reason}")
840 }
841 }
842}
843
844fn ensure_wake(gate: &GatewayConn, agent_id: &str, nick: &str) -> WakeOutcome {
854 let Some(folder) = crate::nick::routine_folder_id(nick) else {
855 return WakeOutcome::Failed("nick has no routine folder id".to_string());
856 };
857 let before = match list_agent_automations(gate, agent_id) {
858 Ok(cards) => cards,
859 Err(err) => return WakeOutcome::Failed(err.to_string()),
860 };
861 match before.iter().find(|card| card.id == folder) {
862 Some(card) if !card.is_webhook() => {
863 return WakeOutcome::Failed(
864 "a routine in this folder is not webhook-triggered; left as is".to_string(),
865 );
866 }
867 Some(_) => {}
868 None => {
869 let after = match create_mirror_routine(gate, agent_id, &folder) {
870 Ok(cards) => cards,
871 Err(err) => return WakeOutcome::Failed(err.to_string()),
872 };
873 if !after.iter().any(|card| card.id == folder) {
874 for stray in after.iter().filter(|card| {
875 card.name == folder && !before.iter().any(|old| old.id == card.id)
876 }) {
877 let _ = delete_agent_automation(gate, agent_id, &stray.id);
878 }
879 return WakeOutcome::Failed(
880 "the mirror did not land in the expected folder; removed it".to_string(),
881 );
882 }
883 }
884 }
885 match read_webhook_credential(gate, agent_id, &folder) {
886 Ok(CredentialRead::Ready { url, key }) => WakeOutcome::Ready { url, key },
887 Ok(CredentialRead::MintFailed) => WakeOutcome::AwaitingBackend,
888 Ok(CredentialRead::Missing) => {
889 WakeOutcome::Failed("the mirror is gone from the gateway".to_string())
890 }
891 Err(err) => WakeOutcome::Failed(err.to_string()),
892 }
893}
894
895fn attach_webhook_routines(
900 sessions: &mut [HostSession],
901 gateway_file: &Path,
902 token_override: Option<&str>,
903 store_root: Option<&Path>,
904) -> Result<(), ShellError> {
905 let gate = open_gateway(gateway_file, token_override)?;
906 for session in sessions.iter_mut() {
907 if session.routine_url.is_some() && session.routine_bearer.is_some() {
908 continue;
909 }
910 let Some(agent_id) = session.agent_id.clone().filter(|id| !id.is_empty()) else {
912 continue;
913 };
914 let Some(nick) = routine_name_for(session) else {
915 continue;
916 };
917 let Some(folder) = crate::nick::routine_folder_id(&nick) else {
918 continue;
919 };
920 let Some(gate) = gate.as_ref() else {
921 if let Some(root) = store_root {
922 if let Some((url, key)) = load_wake(root, &session.session_id, &folder) {
923 session.routine_url = Some(url);
924 session.routine_bearer = Some(key);
925 }
926 }
927 continue;
928 };
929 let outcome = ensure_wake(gate, &agent_id, &nick);
930 log_outcome(&nick, &folder, &outcome);
931 match outcome {
932 WakeOutcome::Ready { url, key } => {
933 if let Some(root) = store_root {
934 save_wake(root, &session.session_id, &folder, &url, &key);
935 }
936 session.routine_url = Some(url);
937 session.routine_bearer = Some(key);
938 }
939 WakeOutcome::AwaitingBackend => {
940 if let Some(root) = store_root {
943 clear_wake(root, &session.session_id);
944 }
945 }
946 WakeOutcome::Failed(_) => {}
947 }
948 }
949 Ok(())
950}
951
952#[derive(serde::Deserialize)]
953#[serde(deny_unknown_fields)]
954struct RoutineFile {
955 url: String,
956 key: String,
957}
958
959fn read_routine_file(path: &Path) -> Result<(String, String), String> {
961 #[cfg(unix)]
962 {
963 use std::os::unix::fs::PermissionsExt;
964 let meta = std::fs::metadata(path).map_err(|_| "the routine file cannot be read".to_string())?;
965 if meta.permissions().mode() & 0o077 != 0 {
966 return Err("the routine file must be mode 0600 (owner only)".to_string());
967 }
968 }
969 let text = std::fs::read_to_string(path).map_err(|_| "the routine file cannot be read".to_string())?;
970 let file: RoutineFile = serde_json::from_str(&text).map_err(|_| "the routine file is not {\"url\",\"key\"} json".to_string())?;
971 let (url, key) = (file.url.trim().to_string(), file.key.trim().to_string());
972 if key.is_empty() || !crate::webhook::url_ok(&url) {
973 return Err("the routine file has no usable url and key".to_string());
974 }
975 Ok((url, key))
976}
977
978fn attach_routine_files(sessions: &mut [HostSession]) {
982 for session in sessions.iter_mut() {
983 let Some(path) = session.routine_file.clone() else { continue };
984 if session.routine_url.is_some() && session.routine_bearer.is_some() {
985 continue;
986 }
987 let label = routine_name_for(session).unwrap_or_else(|| session.session_id.clone());
988 match read_routine_file(&path) {
989 Ok((url, key)) => {
990 session.routine_url = Some(url);
991 session.routine_bearer = Some(key);
992 eprintln!("mail4agent: wake {label}: routine file read");
993 }
994 Err(reason) => eprintln!("mail4agent: wake {label}: {reason}"),
995 }
996 }
997}
998
999pub const STORE_LOCK_FILE: &str = ".lock";
1004
1005fn now_ms() -> i64 {
1006 std::time::SystemTime::now()
1007 .duration_since(std::time::UNIX_EPOCH)
1008 .map(|elapsed| elapsed.as_millis() as i64)
1009 .unwrap_or(0)
1010}
1011
1012pub(crate) fn lock_store(dir: &Path) -> Result<File, ShellError> {
1013 std::fs::create_dir_all(dir)?;
1014 let file = std::fs::OpenOptions::new()
1015 .create(true)
1016 .truncate(false)
1017 .write(true)
1018 .open(dir.join(STORE_LOCK_FILE))?;
1019 match file.try_lock() {
1020 Ok(()) => Ok(file),
1021 Err(_) => Err(ShellError::SessionList(
1022 "session store is in use by another process".to_string(),
1023 )),
1024 }
1025}
1026
1027fn write_secret_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
1029 let dir = path
1030 .parent()
1031 .ok_or_else(|| std::io::Error::other("no parent"))?;
1032 let mut builder = std::fs::DirBuilder::new();
1033 builder.recursive(true);
1034 #[cfg(unix)]
1035 {
1036 use std::os::unix::fs::DirBuilderExt;
1037 builder.mode(0o700);
1038 }
1039 builder.create(dir)?;
1040 let name = path
1041 .file_name()
1042 .and_then(|name| name.to_str())
1043 .unwrap_or("secret");
1044 let tmp = dir.join(format!("{name}.tmp"));
1045 let mut options = std::fs::OpenOptions::new();
1046 options.write(true).create(true).truncate(true);
1047 #[cfg(unix)]
1048 {
1049 use std::os::unix::fs::OpenOptionsExt;
1050 options.mode(0o600);
1051 }
1052 let mut file = options.open(&tmp)?;
1053 std::io::Write::write_all(&mut file, bytes)?;
1054 file.sync_all()?;
1055 drop(file);
1056 std::fs::rename(&tmp, path)
1057}
1058
1059fn keychain_path(store_root: &Path, session_id: &str) -> PathBuf {
1060 crate::session_store_dir(store_root, session_id).join(WAKE_KEYCHAIN_FILE)
1061}
1062
1063fn save_wake(store_root: &Path, session_id: &str, folder_id: &str, url: &str, key: &str) {
1066 let stored = StoredWake {
1067 folder_id: folder_id.to_string(),
1068 url: url.to_string(),
1069 key: key.to_string(),
1070 };
1071 let result = serde_json::to_vec(&stored)
1072 .map_err(std::io::Error::other)
1073 .and_then(|bytes| write_secret_file(&keychain_path(store_root, session_id), &bytes));
1074 if result.is_err() {
1075 eprintln!("mail4agent: wake keychain write failed for folder {folder_id}");
1076 }
1077}
1078
1079fn load_wake(store_root: &Path, session_id: &str, folder_id: &str) -> Option<(String, String)> {
1080 let text = std::fs::read_to_string(keychain_path(store_root, session_id)).ok()?;
1081 let stored: StoredWake = serde_json::from_str(&text).ok()?;
1082 if stored.folder_id != folder_id || stored.url.is_empty() || stored.key.is_empty() {
1083 return None;
1084 }
1085 Some((stored.url, stored.key))
1086}
1087
1088fn clear_wake(store_root: &Path, session_id: &str) {
1089 let _ = std::fs::remove_file(keychain_path(store_root, session_id));
1090}
1091
1092fn list_agent_automations(
1093 gate: &GatewayConn,
1094 agent_id: &str,
1095) -> Result<Vec<AutomationCard>, ShellError> {
1096 let (status, body) = gateway_post(
1097 gate,
1098 "getAgentAutomations",
1099 &serde_json::json!({ "id": agent_id }),
1100 )?;
1101 if status != 200 {
1102 return Err(ShellError::Gateway(format!("gateway list status {status}")));
1103 }
1104 serde_json::from_slice(&body)
1105 .map_err(|_| ShellError::Gateway("gateway list was not understood".to_string()))
1106}
1107
1108enum CredentialRead {
1109 Ready {
1110 url: String,
1111 key: String,
1112 },
1113 MintFailed,
1115 Missing,
1116}
1117
1118fn gateway_client() -> Result<reqwest::blocking::Client, ShellError> {
1119 reqwest::blocking::Client::builder()
1120 .timeout(std::time::Duration::from_secs(8))
1121 .redirect(reqwest::redirect::Policy::none())
1122 .http1_only()
1123 .build()
1124 .map_err(|_| ShellError::Gateway("gateway client could not start".to_string()))
1125}
1126
1127fn gateway_post(
1128 gate: &GatewayConn,
1129 method: &str,
1130 body: &serde_json::Value,
1131) -> Result<(u16, Vec<u8>), ShellError> {
1132 let url = format!("{}/api/{method}", gate.base);
1133 let authorization = crate::bearer_header(&gate.token).map_err(|_| {
1134 ShellError::Gateway("gateway token is not a single header value".to_string())
1135 })?;
1136 let bytes = serde_json::to_vec(body)
1137 .map_err(|_| ShellError::Gateway("gateway request was not json".to_string()))?;
1138 let response = gate
1139 .client
1140 .post(&url)
1141 .header(reqwest::header::AUTHORIZATION, authorization)
1142 .header(reqwest::header::CONTENT_TYPE, "application/json")
1143 .body(bytes)
1144 .send()
1145 .map_err(|_| ShellError::Gateway("gateway request failed".to_string()))?;
1146 let status = response.status().as_u16();
1147 let body = response
1148 .bytes()
1149 .map_err(|_| ShellError::Gateway("gateway response failed".to_string()))?;
1150 Ok((status, body.to_vec()))
1151}
1152
1153fn read_webhook_credential(
1154 gate: &GatewayConn,
1155 agent_id: &str,
1156 automation_id: &str,
1157) -> Result<CredentialRead, ShellError> {
1158 let (status, body) = gateway_post(
1159 gate,
1160 "getAutomationWebhookCredential",
1161 &serde_json::json!({
1162 "id": agent_id,
1163 "automationId": automation_id,
1164 }),
1165 )?;
1166 if status == 200 {
1167 let parsed: WebhookCredential = serde_json::from_slice(&body).map_err(|_| {
1168 ShellError::Gateway("gateway credential was not understood".to_string())
1169 })?;
1170 let key = parsed.key.filter(|key| !key.is_empty());
1171 if parsed.url.is_empty() {
1172 return Err(ShellError::Gateway(
1173 "gateway credential was not understood".to_string(),
1174 ));
1175 }
1176 return Ok(match key {
1177 Some(key) => CredentialRead::Ready {
1178 url: parsed.url,
1179 key,
1180 },
1181 None => CredentialRead::MintFailed,
1182 });
1183 }
1184 if credential_is_missing(status, &body) {
1185 return Ok(CredentialRead::Missing);
1186 }
1187 Err(ShellError::Gateway(format!(
1188 "gateway credential status {status}"
1189 )))
1190}
1191
1192fn credential_is_missing(status: u16, body: &[u8]) -> bool {
1195 if status == 404 {
1196 return true;
1197 }
1198 if status != 500 {
1199 return false;
1200 }
1201 std::str::from_utf8(body)
1202 .map(|text| text.contains("Automation not found"))
1203 .unwrap_or(false)
1204}
1205
1206fn create_mirror_routine(
1208 gate: &GatewayConn,
1209 agent_id: &str,
1210 routine: &str,
1211) -> Result<Vec<AutomationCard>, ShellError> {
1212 let (status, body) = gateway_post(
1213 gate,
1214 "createAgentAutomation",
1215 &serde_json::json!({
1216 "id": agent_id,
1217 "spec": {
1218 "name": routine,
1219 "prompt": MIRROR_ROUTINE_PROMPT,
1220 "trigger": { "type": "webhook" },
1221 "isEnabled": false,
1222 },
1223 }),
1224 )?;
1225 if status != 200 {
1226 return Err(ShellError::Gateway(format!(
1227 "gateway create status {status}"
1228 )));
1229 }
1230 serde_json::from_slice(&body)
1231 .map_err(|_| ShellError::Gateway("gateway create was not understood".to_string()))
1232}
1233
1234fn delete_agent_automation(
1235 gate: &GatewayConn,
1236 agent_id: &str,
1237 automation_id: &str,
1238) -> Result<(), ShellError> {
1239 let (status, _) = gateway_post(
1240 gate,
1241 "deleteAgentAutomation",
1242 &serde_json::json!({ "id": agent_id, "automationId": automation_id }),
1243 )?;
1244 if status != 200 {
1245 return Err(ShellError::Gateway(format!(
1246 "gateway delete status {status}"
1247 )));
1248 }
1249 Ok(())
1250}
1251
1252pub(crate) struct Prepared {
1253 pub(crate) config: SessionConfig,
1254 pub(crate) nick: String,
1255 pub(crate) user_id: String,
1256 pub(crate) device_id: DeviceId,
1257 pub(crate) bearer: Zeroizing<String>,
1258 pub(crate) backend: Arc<dyn m4a_agent::Backend>,
1259 pub(crate) routine_url: Option<String>,
1260 pub(crate) routine_bearer: Option<String>,
1261}
1262
1263pub struct MachineClient {
1266 product_mode: bool,
1268 sessions: Vec<OpenedStore>,
1269 bus: Arc<LocalBus>,
1270 push: m4a_agent::engine::PushLink,
1271 agents_dir: Option<PathBuf>,
1275 gateway_file: Option<PathBuf>,
1276 gateway_token: Option<String>,
1277 last_agent_poll: std::time::Instant,
1278 agent_rescan_secs: u64,
1279 store_root: Option<PathBuf>,
1281 skip_nicks: Vec<String>,
1283 profile_note: bool,
1285 session_ids: Vec<(String, String)>,
1287 last_full_drive: std::time::Instant,
1289 pending_wakes: Vec<PendingWake>,
1291 ready_agents: HashSet<String>,
1294 _locks: Vec<File>,
1296 send_listener: Option<SendListener>,
1298 send_sock: Option<PathBuf>,
1299 send_queue: Vec<PendingSend>,
1301 homeserver_url: String,
1304 bus_next_user: i64,
1306 failed_opens: HashMap<String, std::time::Instant>,
1309}
1310
1311const LATE_OPEN_RETRY_SECS: u64 = 60;
1314
1315struct PendingSend {
1317 stream: SendStream,
1318 request: crate::SendRequest,
1319 started: std::time::Instant,
1320 room: Option<String>,
1321 peer: Option<String>,
1322}
1323
1324const SEND_JOIN_WAIT_SECS: u64 = 120;
1326
1327impl Drop for MachineClient {
1328 fn drop(&mut self) {
1329 if let Some(path) = self.send_sock.take() {
1330 let _ = std::fs::remove_file(path);
1331 }
1332 }
1333}
1334
1335#[derive(Debug, Default)]
1338pub struct TickReport {
1339 pub pushed: Vec<(String, String)>,
1341 pub joined: Vec<(String, String)>,
1343 pub errors: Vec<(String, String)>,
1345 pub sent: Vec<(String, String, crate::SendReply)>,
1347 pub alerts: Vec<(String, String)>,
1349}
1350
1351struct PendingWake {
1353 store_dir: PathBuf,
1354 agent_id: String,
1355}
1356
1357impl MachineClient {
1358 pub fn open(
1365 homeserver_url: &str,
1366 store_root: &Path,
1367 sessions: Vec<HostSession>,
1368 ) -> Result<Self, ShellError> {
1369 Self::open_with(homeserver_url, store_root, sessions, false)
1370 }
1371
1372 fn open_with(
1376 homeserver_url: &str,
1377 store_root: &Path,
1378 sessions: Vec<HostSession>,
1379 lenient: bool,
1380 ) -> Result<Self, ShellError> {
1381 let mut sessions = sessions;
1382 attach_routine_files(&mut sessions);
1383 if sessions.is_empty() {
1384 return Err(ShellError::SessionList("session list is empty".to_string()));
1385 }
1386 let mut prepared = Vec::with_capacity(sessions.len());
1387 let mut seen_ids = Vec::new();
1388 let mut seen_nicks = Vec::new();
1389 let mut locks = Vec::new();
1390 for session in sessions {
1391 let config = session.config(homeserver_url, store_root)?;
1392 if seen_ids.iter().any(|id: &String| id == config.session_id()) {
1393 return Err(ShellError::SessionList("duplicate session id".to_string()));
1394 }
1395 seen_ids.push(config.session_id().to_string());
1396 let lock = match lock_store(&config.store_dir()) {
1397 Ok(lock) => lock,
1398 Err(err) if lenient => {
1399 eprintln!("mail4agent: session {} left out: {err}", config.session_id());
1400 continue;
1401 }
1402 Err(err) => return Err(err),
1403 };
1404 let registered = match register_session(&config) {
1405 Ok(registered) => registered,
1406 Err(err) if lenient => {
1407 eprintln!("mail4agent: session {} left out: {err}", config.session_id());
1408 continue;
1409 }
1410 Err(err) => return Err(err),
1411 };
1412 if seen_nicks.iter().any(|nick: &String| nick.eq_ignore_ascii_case(®istered.nick)) {
1413 return Err(ShellError::SessionList("duplicate nick".to_string()));
1414 }
1415 seen_nicks.push(registered.nick.clone());
1416 locks.push(lock);
1417 prepared.push(Prepared {
1418 config,
1419 nick: registered.nick,
1420 user_id: registered.user_id,
1421 device_id: registered.device_id,
1422 bearer: registered.bearer,
1423 backend: registered.backend,
1424 routine_url: session.routine_url,
1425 routine_bearer: session.routine_bearer,
1426 });
1427 }
1428 if prepared.is_empty() {
1429 return Err(ShellError::SessionList(
1430 "no session could be opened".to_string(),
1431 ));
1432 }
1433 let server_name = server_name_of(&prepared[0].user_id)?;
1434 for item in &prepared[1..] {
1435 if server_name_of(&item.user_id)? != server_name {
1436 return Err(ShellError::SessionList(
1437 "sessions disagree on the homeserver name".to_string(),
1438 ));
1439 }
1440 }
1441 ensure_server_name(server_name)?;
1442 let bus = Arc::new(LocalBus::open(&prepared)?);
1443 let peers: Vec<(String, String)> = prepared
1444 .iter()
1445 .map(|item| (item.nick.to_string(), item.user_id.clone()))
1446 .collect();
1447 let mut opened = Vec::with_capacity(prepared.len());
1448 for item in &prepared {
1449 let server_name = server_name_of(&item.user_id)?;
1450 let mut store = OpenedStore::open(
1451 &item.config.store_dir(),
1452 item.config.session_id(),
1453 item.device_id.clone(),
1454 &item.user_id,
1455 server_name,
1456 Arc::clone(&item.backend),
1457 item.bearer.as_str(),
1458 )?;
1459 store.set_registered_nick(item.nick.to_string());
1460 store.attach_bus(Arc::clone(&bus));
1461 store.set_local_peers(
1462 peers
1463 .iter()
1464 .filter(|(nick, _)| *nick != item.nick)
1465 .cloned()
1466 .collect(),
1467 );
1468 store.set_wake(SessionWake {
1469 routine_url: item.routine_url.clone(),
1470 routine_bearer: item.routine_bearer.clone(),
1471 leader_sock: None,
1472 leader_cwd: None,
1473 });
1474 if item.routine_url.is_none() {
1475 attach_detected_chain(&mut store, &item.config, &item.nick);
1476 }
1477 store.drive(1_000, false)?;
1478 store.abandon_inflight_sync(1_000)?;
1479 opened.push(store);
1480 }
1481 let tokens: Vec<String> = prepared
1482 .iter()
1483 .map(|item| item.bearer.as_str().to_string())
1484 .collect();
1485 let product_mode = true;
1486 let push = m4a_agent::engine::PushLink::open(&prepared[0].config.homeserver_url, prepared[0].backend.keep_prefix(), tokens, product_mode)?;
1487 Ok(Self {
1488 product_mode,
1489 sessions: opened,
1490 bus,
1491 push,
1492 agents_dir: None,
1493 gateway_file: None,
1494 gateway_token: None,
1495 last_agent_poll: std::time::Instant::now(),
1496 agent_rescan_secs: 0,
1497 store_root: None,
1498 skip_nicks: Vec::new(),
1499 profile_note: false,
1500 session_ids: Vec::new(),
1501 last_full_drive: std::time::Instant::now(),
1502 pending_wakes: Vec::new(),
1503 ready_agents: HashSet::new(),
1504 _locks: locks,
1505 send_listener: None,
1506 send_sock: None,
1507 send_queue: Vec::new(),
1508 homeserver_url: homeserver_url.to_string(),
1509 bus_next_user: prepared.len() as i64 + 1,
1510 failed_opens: HashMap::new(),
1511 })
1512 }
1513
1514 pub fn open_session_dir(
1517 homeserver_url: &str,
1518 store_root: &Path,
1519 sessions_dir: &Path,
1520 ) -> Result<Self, ShellError> {
1521 let sessions = load_session_records(sessions_dir)?;
1522 Self::open(homeserver_url, store_root, sessions)
1523 }
1524
1525 pub fn from_env() -> Result<Self, ShellError> {
1536 Self::from_env_filtered(None)
1537 }
1538
1539 pub fn from_env_for(nick: &str) -> Result<Self, ShellError> {
1543 Self::from_env_filtered(Some(nick))
1544 }
1545
1546 fn from_env_filtered(only: Option<&str>) -> Result<Self, ShellError> {
1547 let homeserver_url = std::env::var(HOMESERVER_URL_ENV)
1548 .ok()
1549 .filter(|value| !value.is_empty())
1550 .ok_or(ShellError::HomeserverUrl)?;
1551 let store_root = std::env::var(STORE_ROOT_ENV)
1552 .ok()
1553 .filter(|value| !value.is_empty())
1554 .ok_or(ShellError::StoreRoot)?;
1555 let mut get = |key: &str| -> Option<String> {
1556 std::env::var(key).ok().filter(|value| !value.is_empty())
1557 };
1558 let agents_dir = explicit_agents_dir(&mut get);
1559 let (sessions, agents_dir) = if let Some(dir) = agents_dir {
1560 (load_web_agents(&dir, &mut get)?, Some(dir))
1561 } else {
1562 let sessions_dir = get(SESSIONS_DIR_ENV)
1563 .ok_or_else(|| ShellError::SessionList("session directory is unset".to_string()))?;
1564 (load_web_sessions(Path::new(&sessions_dir), &mut get)?, None)
1565 };
1566 let sessions = match only {
1567 Some(nick) => {
1568 let needle = crate::nick::lookup_nick(nick)?;
1569 let kept: Vec<HostSession> = sessions
1570 .into_iter()
1571 .filter(|session| {
1572 routine_name_for(session)
1573 .is_some_and(|name| name.eq_ignore_ascii_case(&needle))
1574 })
1575 .collect();
1576 if kept.is_empty() {
1577 return Err(ShellError::UnknownNick);
1578 }
1579 kept
1580 }
1581 None => sessions,
1582 };
1583 let gateway_file = get(GATEWAY_FILE_ENV).map(PathBuf::from);
1584 let gateway_token = get(GATEWAY_TOKEN_ENV);
1585 let agent_rescan_secs = get(AGENT_RESCAN_SECS_ENV)
1586 .and_then(|value| value.parse::<u64>().ok())
1587 .unwrap_or(0);
1588 let mut pending_wakes = Vec::new();
1589 let mut ready_agents = HashSet::new();
1590 for session in &sessions {
1591 let Some(agent_id) = session.agent_id.clone() else {
1592 continue;
1593 };
1594 if session.routine_url.is_some() && session.routine_bearer.is_some() {
1595 ready_agents.insert(agent_id);
1596 } else {
1597 pending_wakes.push(PendingWake {
1598 store_dir: crate::session_store_dir(
1599 Path::new(&store_root),
1600 &session.session_id,
1601 ),
1602 agent_id,
1603 });
1604 }
1605 }
1606 let options = WakeOptions::from_lookup(&mut get);
1607 let mut client = Self::open_with(&homeserver_url, Path::new(&store_root), sessions, true)?;
1608 client.store_root = Some(PathBuf::from(&store_root));
1609 client.skip_nicks = options.skip_nicks;
1610 client.profile_note = options.profile_note;
1611 client.session_ids = options.session_ids;
1612 client.pending_wakes = pending_wakes;
1613 client.ready_agents = ready_agents;
1614 client.agents_dir = agents_dir;
1615 client.gateway_file = gateway_file;
1616 client.gateway_token = gateway_token;
1617 client.agent_rescan_secs = agent_rescan_secs;
1618 client.last_agent_poll = std::time::Instant::now();
1619 Ok(client)
1620 }
1621
1622 pub fn poll_agent_directory(&mut self) -> Result<Vec<RoutineReport>, ShellError> {
1633 let Some(agents_dir) = self.agents_dir.clone() else {
1634 return Ok(Vec::new());
1635 };
1636 if self.agent_rescan_secs > 0 {
1637 let elapsed = self.last_agent_poll.elapsed().as_secs();
1638 if elapsed < self.agent_rescan_secs {
1639 return Ok(Vec::new());
1640 }
1641 }
1642 self.last_agent_poll = std::time::Instant::now();
1643 self.open_new_agent_sessions(&agents_dir);
1644 let gateway = self
1645 .gateway_file
1646 .clone()
1647 .unwrap_or_else(|| under_home(DEFAULT_GATEWAY_FILE));
1648 let Some(gate) = open_gateway(&gateway, self.gateway_token.as_deref())? else {
1649 return Ok(Vec::new());
1650 };
1651 let mut sessions = drop_skipped(load_agents_dir(&agents_dir)?, &self.skip_nicks);
1652 apply_session_ids(&mut sessions, &self.session_ids);
1653 let mut reports = Vec::new();
1654 for session in &sessions {
1655 let Some(agent_id) = session.agent_id.clone() else {
1656 continue;
1657 };
1658 let Some(nick) = routine_name_for(session) else {
1659 continue;
1660 };
1661 let folder_id = crate::nick::routine_folder_id(&nick).unwrap_or_default();
1662 if self.ready_agents.contains(&agent_id) {
1663 reports.push(RoutineReport {
1664 agent_id,
1665 nick,
1666 folder_id,
1667 status: WakeStatus::Ready,
1668 });
1669 continue;
1670 }
1671 let outcome = ensure_wake(&gate, &agent_id, &nick);
1672 log_outcome(&nick, &folder_id, &outcome);
1673 if self.profile_note && matches!(outcome, WakeOutcome::AwaitingBackend) {
1674 note_profile(&gate, &agents_dir, &agent_id, &nick);
1675 }
1676 if let WakeOutcome::Ready { url, key } = &outcome {
1677 if let Some(root) = &self.store_root {
1678 save_wake(root, &session.session_id, &folder_id, url, key);
1679 }
1680 if let Some(index) = self
1681 .pending_wakes
1682 .iter()
1683 .position(|pending| pending.agent_id == agent_id)
1684 {
1685 let pending = self.pending_wakes.remove(index);
1686 if let Some(store) = self
1687 .sessions
1688 .iter_mut()
1689 .find(|store| store.store_dir() == pending.store_dir)
1690 {
1691 store.set_wake(SessionWake {
1692 routine_url: Some(url.clone()),
1693 routine_bearer: Some(key.clone()),
1694 leader_sock: None,
1695 leader_cwd: None,
1696 });
1697 }
1698 }
1699 self.ready_agents.insert(agent_id.clone());
1700 if let Some(user_id) = self
1701 .sessions
1702 .iter()
1703 .find(|store| {
1704 store
1705 .nick()
1706 .map(|n| n.eq_ignore_ascii_case(&nick))
1707 .unwrap_or(false)
1708 })
1709 .map(|store| store.user_id().to_string())
1710 {
1711 self.announce_peer_joined(&nick, &user_id);
1712 }
1713 }
1714 reports.push(RoutineReport {
1715 agent_id,
1716 nick,
1717 folder_id,
1718 status: outcome.status(),
1719 });
1720 }
1721 Ok(reports)
1722 }
1723
1724 fn open_new_agent_sessions(&mut self, agents_dir: &Path) {
1730 let Some(store_root) = self.store_root.clone() else {
1731 return;
1732 };
1733 let found = match load_agents_dir(agents_dir) {
1734 Ok(found) => found,
1735 Err(err) => {
1736 eprintln!("mail4agent: agent rescan: {err}");
1737 return;
1738 }
1739 };
1740 let held: Vec<(PathBuf, Option<String>)> = self
1741 .sessions
1742 .iter()
1743 .map(|store| {
1744 (
1745 store.store_dir().to_path_buf(),
1746 store.nick().map(str::to_string),
1747 )
1748 })
1749 .collect();
1750 let fresh = unopened_agent_sessions(
1751 found,
1752 &self.skip_nicks,
1753 &self.session_ids,
1754 &store_root,
1755 &held,
1756 );
1757 for (session, nick) in fresh {
1758 if let Some(failed_at) = self.failed_opens.get(&session.session_id) {
1759 if failed_at.elapsed().as_secs() < LATE_OPEN_RETRY_SECS {
1760 continue;
1761 }
1762 }
1763 let session_id = session.session_id.clone();
1764 match self.open_late_session(&store_root, session, &nick) {
1765 Ok(()) => {
1766 self.failed_opens.remove(&session_id);
1767 println!("mail4agent: session {nick} registered and open");
1768 }
1769 Err(err) => {
1770 eprintln!("mail4agent: session {nick} not opened: {err}");
1771 self.failed_opens
1772 .insert(session_id, std::time::Instant::now());
1773 }
1774 }
1775 }
1776 }
1777
1778 fn open_late_session(
1779 &mut self,
1780 store_root: &Path,
1781 mut session: HostSession,
1782 nick: &str,
1783 ) -> Result<(), ShellError> {
1784 if session.routine_url.is_none() || session.routine_bearer.is_none() {
1785 if let Some(folder) = crate::nick::routine_folder_id(nick) {
1786 if let Some((url, key)) = load_wake(store_root, &session.session_id, &folder) {
1787 session.routine_url = Some(url);
1788 session.routine_bearer = Some(key);
1789 }
1790 }
1791 }
1792 let config = session.config(&self.homeserver_url, store_root)?;
1793 let lock = lock_store(&config.store_dir())?;
1794 let registered = register_session(&config)?;
1795 if let Some(first) = self.sessions.first() {
1796 if server_name_of(®istered.user_id)? != server_name_of(first.user_id())? {
1797 return Err(ShellError::SessionList(
1798 "sessions disagree on the homeserver name".to_string(),
1799 ));
1800 }
1801 }
1802 let item = Prepared {
1803 config,
1804 nick: registered.nick,
1805 user_id: registered.user_id,
1806 device_id: registered.device_id,
1807 bearer: registered.bearer,
1808 backend: registered.backend,
1809 routine_url: session.routine_url.clone(),
1810 routine_bearer: session.routine_bearer.clone(),
1811 };
1812 self.bus.seed(self.bus_next_user, &item)?;
1813 self.bus_next_user += 1;
1814 let mut store = OpenedStore::open(
1815 &item.config.store_dir(),
1816 item.config.session_id(),
1817 item.device_id.clone(),
1818 &item.user_id,
1819 server_name_of(&item.user_id)?,
1820 Arc::clone(&item.backend),
1821 item.bearer.as_str(),
1822 )?;
1823 store.set_registered_nick(item.nick.to_string());
1824 store.attach_bus(Arc::clone(&self.bus));
1825 store.set_wake(SessionWake {
1826 routine_url: item.routine_url.clone(),
1827 routine_bearer: item.routine_bearer.clone(),
1828 leader_sock: None,
1829 leader_cwd: None,
1830 });
1831 if item.routine_url.is_none() {
1832 attach_detected_chain(&mut store, &item.config, &item.nick);
1833 }
1834 store.drive(1_000, false)?;
1835 store.abandon_inflight_sync(1_000)?;
1836 self.sessions.push(store);
1837 self._locks.push(lock);
1838 self.refresh_local_peers();
1839 let tokens: Vec<String> = self
1840 .sessions
1841 .iter()
1842 .map(|store| store.device_bearer().to_string())
1843 .collect();
1844 match m4a_agent::engine::PushLink::open(&self.homeserver_url, self.sessions.first().is_some_and(|s| s.keep_prefix()), tokens, self.product_mode) {
1845 Ok(push) => {
1846 let old = std::mem::replace(&mut self.push, push);
1847 for (recipient, event) in old.drain() {
1848 if let Some(store) = self
1849 .sessions
1850 .iter_mut()
1851 .find(|store| store.user_id() == recipient)
1852 {
1853 store.record_push(event);
1854 }
1855 }
1856 }
1857 Err(err) => {
1858 eprintln!("mail4agent: push socket not reopened for {nick}: {err}");
1859 }
1860 }
1861 if let Some(agent_id) = session.agent_id.clone() {
1862 if item.routine_url.is_some() && item.routine_bearer.is_some() {
1863 self.ready_agents.insert(agent_id);
1864 self.announce_peer_joined(nick, &item.user_id);
1865 } else {
1866 self.ready_agents.remove(&agent_id);
1868 if !self.pending_wakes.iter().any(|p| p.agent_id == agent_id) {
1869 self.pending_wakes.push(PendingWake {
1870 store_dir: item.config.store_dir(),
1871 agent_id,
1872 });
1873 }
1874 }
1875 }
1876 Ok(())
1877 }
1878
1879 fn refresh_local_peers(&mut self) {
1881 let peers: Vec<(String, String)> = self
1882 .sessions
1883 .iter()
1884 .filter_map(|store| Some((store.nick()?.to_string(), store.user_id().to_string())))
1885 .collect();
1886 for store in self.sessions.iter_mut() {
1887 let own = store.nick().unwrap_or("").to_string();
1888 store.set_local_peers(
1889 peers
1890 .iter()
1891 .filter(|(nick, _)| *nick != own)
1892 .cloned()
1893 .collect(),
1894 );
1895 }
1896 }
1897
1898 fn announce_peer_joined(&self, joined_nick: &str, joined_user_id: &str) {
1902 let body = serde_json::json!({
1903 "kind": "peer_joined",
1904 "nick": joined_nick,
1905 "user_id": joined_user_id,
1906 });
1907 for store in &self.sessions {
1908 let Some(nick) = store.nick() else {
1909 continue;
1910 };
1911 if nick.eq_ignore_ascii_case(joined_nick) {
1912 continue;
1913 }
1914 if !store.has_routine() {
1915 continue;
1916 }
1917 let Some((url, bearer)) = store.routine_target() else {
1918 continue;
1919 };
1920 match crate::post_routine_json(&url, &body, bearer.as_deref()) {
1921 Ok(()) => {
1922 eprintln!("mail4agent: peer_joined {joined_nick} -> {nick} status=200")
1923 }
1924 Err(err) => {
1925 eprintln!("mail4agent: peer_joined {joined_nick} -> {nick}: {err}")
1926 }
1927 }
1928 }
1929 }
1930
1931 pub fn pending_wake_agents(&self) -> Vec<String> {
1933 self.pending_wakes
1934 .iter()
1935 .map(|pending| pending.agent_id.clone())
1936 .collect()
1937 }
1938
1939 pub fn set_local_delivery(&self, enabled: bool) {
1950 self.bus.set_local_only(enabled);
1951 }
1952
1953 pub fn homeserver_hits(&self) -> u64 {
1957 self.bus.hits()
1958 }
1959
1960 pub fn session_mut(&mut self, name_or_nick: &str) -> Result<&mut OpenedStore, ShellError> {
1962 let needle = crate::nick::lookup_nick(name_or_nick)?;
1963 self.sessions
1964 .iter_mut()
1965 .find(|store| {
1966 store
1967 .nick()
1968 .is_some_and(|nick| nick.eq_ignore_ascii_case(&needle))
1969 })
1970 .ok_or(ShellError::UnknownNick)
1971 }
1972
1973 pub fn store_dir(&self, name_or_nick: &str) -> Result<PathBuf, ShellError> {
1975 let needle = crate::nick::lookup_nick(name_or_nick)?;
1976 self.sessions
1977 .iter()
1978 .find(|store| {
1979 store
1980 .nick()
1981 .is_some_and(|nick| nick.eq_ignore_ascii_case(&needle))
1982 })
1983 .map(|store| store.store_dir().to_path_buf())
1984 .ok_or(ShellError::UnknownNick)
1985 }
1986
1987 pub fn deliver_pushed(&mut self) -> usize {
1991 let batch = self.push.drain();
1992 let mut delivered = 0;
1993 for (recipient, event) in batch {
1994 let Some(session) = self
1995 .sessions
1996 .iter_mut()
1997 .find(|store| store.user_id() == recipient)
1998 else {
1999 continue;
2000 };
2001 session.record_push(event);
2002 delivered += 1;
2003 }
2004 delivered
2005 }
2006
2007 pub fn tick(&mut self, now_ms: i64, full_drive_secs: u64) -> TickReport {
2017 let mut report = TickReport::default();
2018 let mut due: Vec<usize> = Vec::new();
2019 for (recipient, event) in self.push.drain() {
2020 let Some(index) = self
2021 .sessions
2022 .iter()
2023 .position(|store| store.user_id() == recipient)
2024 else {
2025 continue;
2026 };
2027 let nick = self.sessions[index].nick().unwrap_or("").to_string();
2028 report.pushed.push((nick, event.event_id.clone()));
2029 self.sessions[index].record_push(event);
2030 if !due.contains(&index) {
2031 due.push(index);
2032 }
2033 }
2034 let full = self.last_full_drive.elapsed().as_secs() >= full_drive_secs;
2035 if full {
2036 self.last_full_drive = std::time::Instant::now();
2037 }
2038 for index in 0..self.sessions.len() {
2039 let pushed = due.contains(&index);
2040 if !pushed && !full {
2041 continue;
2042 }
2043 let store = &mut self.sessions[index];
2044 let nick = store.nick().unwrap_or("").to_string();
2045 let drove = store.drive(now_ms, pushed);
2046 for text in store.take_security_alerts() {
2047 report.alerts.push((nick.clone(), text));
2048 }
2049 if let Err(err) = drove {
2050 report.errors.push((nick.clone(), err.to_string()));
2051 continue;
2052 }
2053 match store.accept_direct_invites(now_ms) {
2054 Ok(joined) => {
2055 for room in joined {
2056 report.joined.push((nick.clone(), room));
2057 }
2058 }
2059 Err(err) => report.errors.push((nick, err.to_string())),
2060 }
2061 }
2062 self.serve_sends(now_ms, &mut report);
2063 report
2064 }
2065
2066 pub fn listen_for_sends(&mut self, path: &Path) -> Result<(), ShellError> {
2074 let listener = SendListener::bind(path).map_err(|err| {
2075 if err.kind() == std::io::ErrorKind::AlreadyExists {
2076 ShellError::SessionList(
2077 "another client already listens on the send socket".to_string(),
2078 )
2079 } else {
2080 ShellError::Io(err)
2081 }
2082 })?;
2083 listener.set_nonblocking(true)?;
2084 self.send_listener = Some(listener);
2085 self.send_sock = Some(path.to_path_buf());
2086 Ok(())
2087 }
2088
2089 pub fn listen_for_sends_from_env(&mut self) -> Result<PathBuf, ShellError> {
2092 let root = self.store_root.clone().ok_or(ShellError::StoreRoot)?;
2093 let path = crate::send_sock_path(
2094 |key| std::env::var(key).ok().filter(|value| !value.is_empty()),
2095 &root,
2096 );
2097 self.listen_for_sends(&path)?;
2098 Ok(path)
2099 }
2100
2101 pub fn send_blocking(
2105 &mut self,
2106 as_nick: &str,
2107 to: &str,
2108 text: &str,
2109 wait: std::time::Duration,
2110 ) -> crate::SendReply {
2111 let started = std::time::Instant::now();
2112 let mut room = None;
2113 let mut peer = None;
2114 loop {
2115 let now = now_ms();
2116 match self.try_send(as_nick, to, text, now, &mut room, &mut peer) {
2117 Some(reply) => return reply,
2118 None if started.elapsed() >= wait => {
2119 return crate::SendReply {
2120 room,
2121 ..crate::SendReply::failed(format!("{to} has not joined the DM yet"))
2122 }
2123 }
2124 None => {
2125 if let Ok(store) = self.session_mut(as_nick) {
2126 let _ = store.drive(now, false);
2127 }
2128 std::thread::sleep(std::time::Duration::from_millis(500));
2129 }
2130 }
2131 }
2132 }
2133
2134 fn try_send(
2136 &mut self,
2137 as_nick: &str,
2138 to: &str,
2139 text: &str,
2140 now_ms: i64,
2141 room: &mut Option<String>,
2142 peer: &mut Option<String>,
2143 ) -> Option<crate::SendReply> {
2144 let store = match self.session_mut(as_nick) {
2145 Ok(store) => store,
2146 Err(_) => {
2147 return Some(crate::SendReply::failed(format!(
2148 "{as_nick} is not a session on this client"
2149 )))
2150 }
2151 };
2152 if peer.is_none() {
2153 let mut last_err = None;
2156 for attempt in 0..3 {
2157 if attempt > 0 {
2158 let _ = store.drive(now_ms, false);
2159 }
2160 match store.find_nick(to, now_ms) {
2161 Ok(found) => {
2162 *peer = Some(found.user_id);
2163 last_err = None;
2164 break;
2165 }
2166 Err(err) => last_err = Some(err),
2167 }
2168 }
2169 if let Some(err) = last_err {
2170 return Some(crate::SendReply::failed(format!("find {to}: {err}")));
2171 }
2172 }
2173 if room.is_none() {
2174 match store.ensure_dm(to, now_ms) {
2175 Ok(room_id) => *room = Some(room_id),
2176 Err(err) => return Some(crate::SendReply::failed(format!("open DM: {err}"))),
2177 }
2178 }
2179 let (room_id, peer_id) = (room.clone()?, peer.clone()?);
2180 if !store.member_joined(&room_id, &peer_id) {
2181 return None;
2182 }
2183 match store.write_to_nick(to, text, now_ms) {
2184 Ok(room_id) => {
2185 let event_id = store
2186 .texts()
2187 .into_iter()
2188 .rev()
2189 .find(|row| row.room_id == room_id && row.body == text)
2190 .and_then(|row| row.event_id);
2191 Some(crate::SendReply {
2192 ok: true,
2193 room: Some(room_id),
2194 event_id,
2195 error: None,
2196 })
2197 }
2198 Err(err) => Some(crate::SendReply {
2199 room: Some(room_id),
2200 ..crate::SendReply::failed(format!("send: {err}"))
2201 }),
2202 }
2203 }
2204
2205 fn serve_sends(&mut self, now_ms: i64, report: &mut TickReport) {
2206 let mut cmds: Vec<(crate::ipc::SendStream, crate::CmdRequest)> = Vec::new();
2207 if let Some(listener) = &self.send_listener {
2208 loop {
2209 match listener.accept() {
2210 Ok(mut stream) => match crate::send::read_incoming(&mut stream) {
2211 Ok(crate::send::Incoming::Send(request)) => self.send_queue.push(PendingSend {
2212 stream,
2213 request,
2214 started: std::time::Instant::now(),
2215 room: None,
2216 peer: None,
2217 }),
2218 Ok(crate::send::Incoming::Cmd(cmd)) => cmds.push((stream, cmd)),
2219 Err(err) => {
2220 crate::send::write_reply(&mut stream, &crate::SendReply::failed(err))
2221 }
2222 },
2223 Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => break,
2224 Err(_) => break,
2225 }
2226 }
2227 }
2228 for (mut stream, cmd) in cmds {
2229 let reply = match self.sessions.iter_mut().find(|store| {
2230 store.nick().is_some_and(|n| n.eq_ignore_ascii_case(cmd.as_nick.trim()))
2231 }) {
2232 Some(store) => store.run_command(&cmd, now_ms),
2233 None => crate::CmdReply::failed(format!("{} is not a session on this client", cmd.as_nick)),
2234 };
2235 crate::send::write_cmd_reply(&mut stream, &reply);
2236 }
2237 let queue = std::mem::take(&mut self.send_queue);
2238 for mut pending in queue {
2239 let (as_nick, to, text) = (
2240 pending.request.as_nick.clone(),
2241 pending.request.to.clone(),
2242 pending.request.text.clone(),
2243 );
2244 let outcome = self.try_send(
2245 &as_nick,
2246 &to,
2247 &text,
2248 now_ms,
2249 &mut pending.room,
2250 &mut pending.peer,
2251 );
2252 let reply = match outcome {
2253 Some(reply) => reply,
2254 None if pending.started.elapsed().as_secs() >= SEND_JOIN_WAIT_SECS => {
2255 crate::SendReply {
2256 room: pending.room.clone(),
2257 ..crate::SendReply::failed(format!("{to} has not joined the DM yet"))
2258 }
2259 }
2260 None => {
2261 self.send_queue.push(pending);
2262 continue;
2263 }
2264 };
2265 crate::send::write_reply(&mut pending.stream, &reply);
2266 report.sent.push((as_nick, to, reply));
2267 }
2268 }
2269
2270 pub fn wake_log(&self) -> Vec<(String, crate::WakeAttempt)> {
2273 self.sessions
2274 .iter()
2275 .flat_map(|store| {
2276 let nick = store.nick().unwrap_or("").to_string();
2277 store
2278 .wake_log()
2279 .iter()
2280 .cloned()
2281 .map(move |attempt| (nick.clone(), attempt))
2282 })
2283 .collect()
2284 }
2285
2286 pub fn holds(&self, name_or_nick: &str) -> bool {
2288 let Ok(needle) = crate::nick::lookup_nick(name_or_nick) else {
2289 return false;
2290 };
2291 self.sessions.iter().any(|store| {
2292 store
2293 .nick()
2294 .is_some_and(|nick| nick.eq_ignore_ascii_case(&needle))
2295 })
2296 }
2297}
2298
2299fn server_name_of(mxid: &str) -> Result<&str, ShellError> {
2300 mxid.split_once(':')
2301 .map(|(_, server)| server)
2302 .filter(|server| !server.is_empty())
2303 .ok_or_else(|| ShellError::Register("user id has no server".to_string()))
2304}
2305
2306
2307fn attach_detected_chain(store: &mut OpenedStore, config: &SessionConfig, nick: &str) {
2313 if let Some((session, chain)) = crate::provider::chain::detected_web_chain(
2314 config.session_id(),
2315 nick,
2316 &config.store_root,
2317 ) {
2318 store.set_wake_chain(session, chain);
2319 }
2320}
2321
2322#[cfg(test)]
2323mod tests {
2324 #[cfg(unix)]
2325 #[test]
2326 fn a_routine_file_gives_a_session_without_an_agent_id_its_wake_and_leaks_nothing() {
2327 use std::os::unix::fs::PermissionsExt;
2328 let dir = tempfile::tempdir().expect("dir");
2329 let file = dir.path().join("wake.json");
2330 let write = |text: &str, mode: u32| {
2331 std::fs::write(&file, text).expect("write");
2332 std::fs::set_permissions(&file, std::fs::Permissions::from_mode(mode)).expect("mode");
2333 };
2334 let mut set = vec![HostSession::new("Courier", "web-courier").with_routine_file(&file)];
2335
2336 write(r#"{"url":"http://127.0.0.1:9/hook","key":"k-secret"}"#, 0o600);
2338 attach_routine_files(&mut set);
2339 assert_eq!(set[0].routine_url.as_deref(), Some("http://127.0.0.1:9/hook"));
2340 assert_eq!(set[0].routine_bearer.as_deref(), Some("k-secret"));
2341 assert!(!format!("{:?}", set[0]).contains("k-secret"), "Debug must not show the key");
2342
2343 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)] {
2345 write(text, mode);
2346 let err = read_routine_file(&file).expect_err("refused");
2347 assert!(!err.contains("k-secret") && !err.contains("127.0.0.1"), "{err}");
2348 let mut one = vec![HostSession::new("Courier", "web-courier").with_routine_file(&file)];
2349 attach_routine_files(&mut one);
2350 assert!(one[0].routine_url.is_none() && one[0].routine_bearer.is_none());
2351 }
2352 write(r#"{"url":"http://127.0.0.1:9/other","key":"k2"}"#, 0o600);
2354 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()))];
2355 attach_routine_files(&mut held);
2356 assert_eq!(held[0].routine_url.as_deref(), Some("http://127.0.0.1:9/mine"));
2357 }
2358
2359 #[test]
2360 fn a_session_record_may_name_a_routine_file_but_never_carry_the_secret() {
2361 let dir = tempfile::tempdir().expect("dir");
2362 std::fs::write(dir.path().join("a.json"), r#"{"bot_name":"Courier","session_id":"web-courier","routine_file":"/some/where/wake.json"}"#).expect("write");
2363 let loaded = load_session_records(dir.path()).expect("records");
2364 assert_eq!(loaded[0].routine_file.as_deref(), Some(Path::new("/some/where/wake.json")));
2365 assert!(loaded[0].routine_url.is_none());
2366 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");
2367 assert!(load_session_records(dir.path()).is_err(), "a URL in the record is still refused");
2368 }
2369
2370 use super::*;
2371
2372 #[test]
2373 fn web_client_agents_dir_is_only_the_env_path() {
2374 assert!(explicit_agents_dir(|_| None).is_none());
2375 let missing = std::env::temp_dir().join(format!(
2376 "m4a-missing-agents-{}",
2377 std::process::id()
2378 ));
2379 let _ = std::fs::remove_dir_all(&missing);
2380 assert!(explicit_agents_dir(|key| {
2381 (key == AGENTS_DIR_ENV).then(|| missing.display().to_string())
2382 })
2383 .is_none());
2384 let present = std::env::temp_dir().join(format!(
2385 "m4a-present-agents-{}-{}",
2386 std::process::id(),
2387 std::time::SystemTime::now()
2388 .duration_since(std::time::UNIX_EPOCH)
2389 .expect("clock")
2390 .as_nanos()
2391 ));
2392 std::fs::create_dir_all(&present).expect("dir");
2393 let got = explicit_agents_dir(|key| {
2394 (key == AGENTS_DIR_ENV).then(|| present.display().to_string())
2395 });
2396 assert_eq!(got.as_deref(), Some(present.as_path()));
2397 let _ = std::fs::remove_dir_all(&present);
2398 }
2399
2400 #[test]
2401 fn rescan_opens_only_bots_without_a_session() {
2402 let root = Path::new("/tmp/m4a-rescan-test");
2403 let agent = |name: &str, id: &str| {
2404 let mut session = HostSession::new(name, id);
2405 session.agent_id = Some(id.to_string());
2406 session
2407 };
2408 let found = vec![
2409 agent("alice", "a-hatch"),
2410 agent("m4a-proba2", "a-proba2"),
2411 agent("skipme", "a-skip"),
2412 agent("aliased", "a-alias"),
2413 ];
2414 let held = vec![
2415 (crate::session_store_dir(root, "a-hatch"), Some("alice".to_string())),
2416 (crate::session_store_dir(root, "old-alias"), None),
2417 ];
2418 let fresh = unopened_agent_sessions(
2419 found,
2420 &["skipme".to_string()],
2421 &[("a-alias".to_string(), "old-alias".to_string())],
2422 root,
2423 &held,
2424 );
2425 let nicks: Vec<&str> = fresh.iter().map(|(_, nick)| nick.as_str()).collect();
2426 assert_eq!(nicks, vec!["m4a-proba2"]);
2427 assert_eq!(fresh[0].0.session_id, "a-proba2");
2428 }
2429
2430 #[test]
2431 fn session_directory_is_name_and_id_only() {
2432 let dir = std::env::temp_dir().join(format!(
2433 "m4a-sessions-{}-{}",
2434 std::process::id(),
2435 std::time::SystemTime::now()
2436 .duration_since(std::time::UNIX_EPOCH)
2437 .expect("clock")
2438 .as_nanos()
2439 ));
2440 std::fs::create_dir_all(&dir).expect("dir");
2441 std::fs::write(
2442 dir.join("alice.json"),
2443 r#"{"bot_name":"Alice","session_id":"web-alice"}"#,
2444 )
2445 .expect("write");
2446 std::fs::write(
2447 dir.join("chief.json"),
2448 "{\"bot_name\":\"Привет мир\",\"session_id\":\"web-chief\"}",
2449 )
2450 .expect("write");
2451 let loaded = load_session_records(&dir).expect("records");
2452 assert_eq!(loaded.len(), 2);
2453 assert_eq!(loaded[1].bot_name, "Привет мир");
2454 assert_eq!(loaded[0].bot_name, "Alice");
2455 assert!(loaded[1].agent_id.is_none());
2456 assert!(loaded[0].agent_id.is_none());
2457 assert!(loaded[1].routine_url.is_none());
2458 assert!(loaded[1].routine_bearer.is_none());
2459 assert!(loaded[1].invite.is_none());
2460
2461 std::fs::write(
2462 dir.join("leaked.json"),
2463 r#"{"bot_name":"Courier","session_id":"web-courier","routine_url":"http://127.0.0.1/hook","routine_bearer":"not-a-file"}"#,
2464 )
2465 .expect("write");
2466 let refused = load_session_records(&dir).expect_err("webhook file");
2467 let text = refused.to_string();
2468 assert!(!text.contains("not-a-file"));
2469 assert!(!text.contains("127.0.0.1/hook"));
2470 let _ = std::fs::remove_dir_all(&dir);
2471 }
2472
2473 #[test]
2474 fn web_client_does_not_copy_one_routine_onto_every_session_and_node_cli_refuses_it() {
2475 let dir = std::env::temp_dir().join(format!(
2476 "m4a-split-{}-{}",
2477 std::process::id(),
2478 std::time::SystemTime::now()
2479 .duration_since(std::time::UNIX_EPOCH)
2480 .expect("clock")
2481 .as_nanos()
2482 ));
2483 std::fs::create_dir_all(&dir).expect("dir");
2484 let record = dir.join("alice.json");
2485 let body = r#"{"bot_name":"Alice","session_id":"web-alice"}"#;
2486 std::fs::write(&record, body).expect("write");
2487 let routine = "http://127.0.0.1:9/routine";
2488 let bearer = "host-injected-bearer";
2489 let sessions = load_web_sessions(&dir, |key| match key {
2490 crate::ROUTINE_URL_ENV => Some(routine.to_string()),
2491 crate::ROUTINE_BEARER_ENV => Some(bearer.to_string()),
2492 crate::LEADER_SOCK_ENV => Some("/tmp/leader.sock".to_string()),
2493 _ => None,
2494 })
2495 .expect("web sessions");
2496 assert_eq!(sessions.len(), 1);
2497 assert!(sessions[0].routine_url.is_none());
2498 assert!(sessions[0].routine_bearer.is_none());
2499 assert_eq!(std::fs::read_to_string(&record).expect("reread"), body);
2500 let wake = crate::SessionWake::web_from_lookup(|key| match key {
2501 crate::ROUTINE_URL_ENV => Some(routine.to_string()),
2502 crate::LEADER_SOCK_ENV => Some("/tmp/leader.sock".to_string()),
2503 _ => None,
2504 });
2505 assert!(wake.leader_sock.is_none());
2506 assert!(wake.leader_cwd.is_none());
2507
2508 std::fs::write(
2509 dir.join("leaked.json"),
2510 r#"{"bot_name":"Courier","session_id":"web-courier","routine_url":"http://127.0.0.1:9/from-file","routine_bearer":"file-bearer"}"#,
2511 )
2512 .expect("leak");
2513 let refused = load_web_sessions(&dir, |_| None).expect_err("json routine");
2514 let text = refused.to_string();
2515 assert!(!text.contains("from-file"));
2516 assert!(!text.contains("file-bearer"));
2517
2518 let node = crate::OpenedStore::connect_node_from_lookup(
2519 |key| match key {
2520 crate::ROUTINE_URL_ENV => Some(routine.to_string()),
2521 crate::ROUTINE_BEARER_ENV => Some(bearer.to_string()),
2522 crate::LEADER_SOCK_ENV => Some("/tmp/leader.sock".to_string()),
2523 _ => None,
2524 },
2525 None,
2526 );
2527 let err = match node {
2528 Ok(_) => panic!("node cli accepted a routine url"),
2529 Err(err) => err,
2530 };
2531 assert!(matches!(err, crate::ShellError::NodeRoutine));
2532 let text = err.to_string();
2533 assert!(!text.contains(routine));
2534 assert!(!text.contains(bearer));
2535 assert!(!text.contains("leader.sock"));
2536
2537 let node = crate::SessionWake::node_from_lookup(|key| match key {
2538 crate::LEADER_SOCK_ENV => Some("/tmp/node-leader.sock".to_string()),
2539 crate::LEADER_CWD_ENV => Some("/tmp/node".to_string()),
2540 _ => None,
2541 })
2542 .expect("node leader");
2543 assert!(node.routine_url.is_none());
2544 assert!(node.routine_bearer.is_none());
2545 assert_eq!(
2546 node.leader_sock.as_deref(),
2547 Some(std::path::Path::new("/tmp/node-leader.sock"))
2548 );
2549 assert_eq!(node.leader_cwd.as_deref(), Some("/tmp/node"));
2550 let _ = std::fs::remove_dir_all(&dir);
2551 }
2552
2553 #[test]
2554 fn mirrors_follow_the_nick_folder_and_keys_wait_for_the_bot_routine() {
2555 use std::collections::HashSet;
2556 use std::io::{Read, Write};
2557 use std::net::TcpListener;
2558 use std::sync::atomic::{AtomicBool, Ordering};
2559 use std::sync::{Arc, Mutex};
2560 use std::thread;
2561
2562 struct Card {
2563 agent: String,
2564 id: String,
2565 name: String,
2566 trigger: &'static str,
2567 enabled: bool,
2568 }
2569 #[derive(Default)]
2570 struct Gateway {
2571 creates: usize,
2572 deletes: usize,
2573 cards: Vec<Card>,
2574 backend: HashSet<(String, String)>,
2576 clash: HashSet<String>,
2578 agents_seen: HashSet<String>,
2579 profiles: Vec<(String, serde_json::Value)>,
2580 }
2581
2582 fn read_http(sock: &mut std::net::TcpStream) -> Option<(String, Vec<u8>)> {
2583 let _ = sock.set_read_timeout(Some(std::time::Duration::from_secs(2)));
2584 let mut buf = Vec::new();
2585 let mut tmp = [0u8; 2048];
2586 loop {
2587 let n = sock.read(&mut tmp).unwrap_or(0);
2588 if n == 0 {
2589 return None;
2590 }
2591 buf.extend_from_slice(&tmp[..n]);
2592 let Some(end) = buf.windows(4).position(|w| w == b"\r\n\r\n") else {
2593 continue;
2594 };
2595 let headers = String::from_utf8_lossy(&buf[..end]).to_string();
2596 let length = headers
2597 .lines()
2598 .find_map(|line| {
2599 let (name, value) = line.split_once(':')?;
2600 name.eq_ignore_ascii_case("content-length")
2601 .then(|| value.trim().parse::<usize>().ok())
2602 .flatten()
2603 })
2604 .unwrap_or(0);
2605 if buf.len() >= end + 4 + length {
2606 return Some((headers, buf[end + 4..end + 4 + length].to_vec()));
2607 }
2608 }
2609 }
2610 fn reply(status: &str, body: &serde_json::Value) -> Vec<u8> {
2611 let payload = serde_json::to_vec(body).expect("json");
2612 let mut out = format!(
2613 "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
2614 payload.len()
2615 )
2616 .into_bytes();
2617 out.extend(payload);
2618 out
2619 }
2620 fn cards_of(gate: &Gateway, agent: &str) -> serde_json::Value {
2621 serde_json::Value::Array(
2622 gate.cards
2623 .iter()
2624 .filter(|card| card.agent == agent)
2625 .map(|card| {
2626 serde_json::json!({
2627 "id": card.id,
2628 "name": card.name,
2629 "trigger": {"type": card.trigger},
2630 "isEnabled": card.enabled,
2631 })
2632 })
2633 .collect(),
2634 )
2635 }
2636
2637 let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
2638 listener.set_nonblocking(true).expect("nonblocking");
2639 let port = listener.local_addr().expect("addr").port();
2640 let state = Arc::new(Mutex::new(Gateway::default()));
2641 {
2642 let mut gate = state.lock().expect("gate");
2643 gate.backend.insert(("agent-h".into(), "alice".into()));
2644 gate.clash.insert("agent-x".into());
2645 gate.cards.push(Card {
2646 agent: "agent-k".into(),
2647 id: "cron-bot".into(),
2648 name: "cron_bot".into(),
2649 trigger: "cron",
2650 enabled: true,
2651 });
2652 }
2653 let shared = Arc::clone(&state);
2654 let token = "gw-test-token";
2655 let done = Arc::new(AtomicBool::new(false));
2656 let flag = Arc::clone(&done);
2657 let server = thread::spawn(move || {
2658 while !flag.load(Ordering::Relaxed) {
2659 let (mut sock, _) = match listener.accept() {
2660 Ok(pair) => pair,
2661 Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
2662 thread::sleep(std::time::Duration::from_millis(5));
2663 continue;
2664 }
2665 Err(_) => break,
2666 };
2667 let _ = sock.set_nonblocking(false);
2668 let Some((headers, body)) = read_http(&mut sock) else {
2669 continue;
2670 };
2671 let lower = headers.to_ascii_lowercase();
2672 let authorized = lower.contains(&format!("authorization: bearer {token}"));
2673 let request: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
2674 let agent = request["id"].as_str().unwrap_or("").to_string();
2675 let mut gate = shared.lock().expect("gate");
2676 gate.agents_seen.insert(agent.clone());
2677 let response = if !authorized {
2678 reply("401 Unauthorized", &serde_json::json!({}))
2679 } else if lower.starts_with("post /api/getagentautomations ") {
2680 reply("200 OK", &cards_of(&gate, &agent))
2681 } else if lower.starts_with("post /api/createagentautomation ") {
2682 let spec = &request["spec"];
2683 assert_eq!(spec["trigger"]["type"], "webhook");
2684 assert_eq!(spec["isEnabled"], false, "mirror must be disabled");
2685 let name = spec["name"].as_str().unwrap_or("").to_string();
2686 let prompt = spec["prompt"].as_str().unwrap_or("");
2687 assert!(!prompt.is_empty());
2688 assert!(!prompt.contains("http"));
2689 gate.creates += 1;
2690 let folder = crate::nick::routine_folder_id(&name).expect("slug");
2691 let id = if gate.clash.contains(&agent) {
2692 format!("{folder}-2")
2693 } else {
2694 folder
2695 };
2696 gate.cards.push(Card {
2697 agent: agent.clone(),
2698 id,
2699 name,
2700 trigger: "webhook",
2701 enabled: false,
2702 });
2703 reply("200 OK", &cards_of(&gate, &agent))
2704 } else if lower.starts_with("post /api/updateagent ") {
2705 gate.profiles
2706 .push((agent.clone(), request["profile"].clone()));
2707 reply("200 OK", &serde_json::json!({"id": agent}))
2708 } else if lower.starts_with("post /api/deleteagentautomation ") {
2709 let id = request["automationId"].as_str().unwrap_or("");
2710 gate.deletes += 1;
2711 gate.cards
2712 .retain(|card| !(card.agent == agent && card.id == id));
2713 reply("200 OK", &cards_of(&gate, &agent))
2714 } else if lower.starts_with("post /api/getautomationwebhookcredential ") {
2715 let id = request["automationId"].as_str().unwrap_or("").to_string();
2716 let local = gate
2717 .cards
2718 .iter()
2719 .find(|card| card.agent == agent && card.id == id);
2720 match local {
2721 None => reply(
2722 "500 Internal Server Error",
2723 &serde_json::json!({"error": format!("Automation not found: {id}")}),
2724 ),
2725 Some(card) if card.trigger != "webhook" => reply(
2726 "500 Internal Server Error",
2727 &serde_json::json!({"error": "Automation is not webhook-triggered"}),
2728 ),
2729 Some(_) => {
2730 let minted = gate.backend.contains(&(agent.clone(), id.clone()));
2731 reply(
2732 "200 OK",
2733 &serde_json::json!({
2734 "url": format!("https://backend.invalid/automations/webhook/{agent}-{id}"),
2735 "key": minted.then(|| format!("key-{agent}-{id}")),
2736 }),
2737 )
2738 }
2739 }
2740 } else {
2741 reply("404 Not Found", &serde_json::json!({}))
2742 };
2743 drop(gate);
2744 let _ = sock.write_all(&response);
2745 }
2746 });
2747
2748 let dir = std::env::temp_dir().join(format!(
2749 "m4a-mirror-{}-{}",
2750 std::process::id(),
2751 std::time::SystemTime::now()
2752 .duration_since(std::time::UNIX_EPOCH)
2753 .expect("clock")
2754 .as_nanos()
2755 ));
2756 let agents = dir.join("agents");
2757 let store_root = dir.join("stores");
2758 let describe = |id: &str| {
2759 if id == "agent-c" {
2760 "about the chief"
2761 } else {
2762 ""
2763 }
2764 };
2765 for (id, name) in [
2766 ("agent-h", "Alice"),
2767 ("agent-c", "Привет мир"),
2768 ("agent-s", "Свой браузер"),
2769 ("agent-x", "Clash Bot"),
2770 ("agent-k", "Cron Bot"),
2771 ] {
2772 std::fs::create_dir_all(agents.join(id)).expect("agent dir");
2773 std::fs::write(
2774 agents.join(id).join("profile.json"),
2775 serde_json::to_vec(&serde_json::json!({"name": name, "description": describe(id)}))
2776 .expect("profile"),
2777 )
2778 .expect("profile");
2779 }
2780 let gateway = dir.join("gateway-file.json");
2781 std::fs::write(
2782 &gateway,
2783 format!(r#"{{"host":"192.0.2.1","port":{port},"scheme":"http","token":"{token}"}}"#),
2784 )
2785 .expect("gateway");
2786 let skip = parse_skip_nicks(" svoi-brauzer , ");
2787 assert_eq!(skip, vec!["svoi-brauzer".to_string()]);
2788 let options = WakeOptions {
2789 skip_nicks: skip.clone(),
2790 store_root: Some(store_root.clone()),
2791 profile_note: false,
2792 session_ids: Vec::new(),
2793 };
2794
2795 let status_of = |reports: &[RoutineReport], agent: &str| {
2796 reports
2797 .iter()
2798 .find(|report| report.agent_id == agent)
2799 .map(|report| report.status.clone())
2800 };
2801 let first =
2802 ensure_agent_webhook_routines(&agents, &gateway, None, &options).expect("first pass");
2803 assert_eq!(status_of(&first, "agent-h"), Some(WakeStatus::Ready));
2804 assert_eq!(
2805 status_of(&first, "agent-c"),
2806 Some(WakeStatus::AwaitingBackend)
2807 );
2808 assert!(matches!(
2809 status_of(&first, "agent-x"),
2810 Some(WakeStatus::Failed(_))
2811 ));
2812 assert!(matches!(
2813 status_of(&first, "agent-k"),
2814 Some(WakeStatus::Failed(_))
2815 ));
2816 assert_eq!(status_of(&first, "agent-s"), None);
2817 let chief = first
2818 .iter()
2819 .find(|r| r.agent_id == "agent-c")
2820 .expect("chief");
2821 assert_eq!(chief.nick, "privet-mir");
2822 assert_eq!(chief.folder_id, "privet-mir");
2823 let shown = format!("{first:?}");
2824 assert!(!shown.contains("key-"));
2825 assert!(!shown.contains("automations/webhook"));
2826 {
2827 let gate = state.lock().expect("gate");
2828 assert_eq!(gate.creates, 3);
2831 assert_eq!(gate.deletes, 1);
2832 assert!(!gate.agents_seen.contains("agent-s"));
2833 assert!(gate.cards.iter().all(|card| card.agent != "agent-x"));
2834 let cron = gate
2835 .cards
2836 .iter()
2837 .find(|card| card.agent == "agent-k")
2838 .expect("cron");
2839 assert_eq!(cron.trigger, "cron");
2840 assert!(cron.enabled);
2841 let mirror = gate
2842 .cards
2843 .iter()
2844 .find(|card| card.agent == "agent-h")
2845 .expect("mirror");
2846 assert_eq!(
2847 (mirror.id.as_str(), mirror.name.as_str()),
2848 ("alice", "alice")
2849 );
2850 let chief_mirror = gate
2851 .cards
2852 .iter()
2853 .find(|card| card.agent == "agent-c")
2854 .expect("chief mirror");
2855 assert_eq!(chief_mirror.name, "privet-mir");
2857 assert_eq!(chief_mirror.id, "privet-mir");
2858 assert!(!mirror.enabled);
2859 }
2860 let alice_file = keychain_path(&store_root, "agent-h");
2862 let stored: serde_json::Value =
2863 serde_json::from_slice(&std::fs::read(&alice_file).expect("keychain"))
2864 .expect("keychain json");
2865 assert_eq!(stored["folder_id"], "alice");
2866 assert_eq!(stored["key"], "key-agent-h-alice");
2867 #[cfg(unix)]
2868 {
2869 use std::os::unix::fs::PermissionsExt;
2870 let mode = std::fs::metadata(&alice_file)
2871 .expect("meta")
2872 .permissions()
2873 .mode();
2874 assert_eq!(mode & 0o777, 0o600);
2875 }
2876 assert!(!keychain_path(&store_root, "agent-c").exists());
2877
2878 let second =
2880 ensure_agent_webhook_routines(&agents, &gateway, None, &options).expect("second pass");
2881 assert_eq!(
2882 status_of(&second, "agent-c"),
2883 Some(WakeStatus::AwaitingBackend)
2884 );
2885 assert_eq!(
2886 state.lock().expect("gate").creates,
2887 4,
2888 "only the clash bot retries"
2889 );
2890 assert!(state.lock().expect("gate").profiles.is_empty());
2891 let noted = WakeOptions {
2894 profile_note: true,
2895 ..options.clone()
2896 };
2897 ensure_agent_webhook_routines(&agents, &gateway, None, ¬ed).expect("note pass");
2898 {
2899 let gate = state.lock().expect("gate");
2900 assert_eq!(gate.profiles.len(), 1);
2901 let (agent, profile) = &gate.profiles[0];
2902 assert_eq!(agent, "agent-c");
2903 assert_eq!(profile["name"], "Привет мир");
2904 let description = profile["description"].as_str().unwrap_or("");
2905 assert!(description.starts_with("about the chief"));
2906 assert!(description.contains("routine named \"privet-mir\""));
2907 assert!(!description.contains("http"));
2908 }
2909 state.lock().expect("gate").creates = 4;
2910 state
2913 .lock()
2914 .expect("gate")
2915 .backend
2916 .insert(("agent-c".into(), "privet-mir".into()));
2917 let third =
2918 ensure_agent_webhook_routines(&agents, &gateway, None, &options).expect("third pass");
2919 assert_eq!(status_of(&third, "agent-c"), Some(WakeStatus::Ready));
2920 assert_eq!(state.lock().expect("gate").creates, 5);
2921 assert!(keychain_path(&store_root, "agent-c").exists());
2922
2923 let gateway_path = gateway.display().to_string();
2925 let root_text = store_root.display().to_string();
2926 let sessions = load_web_agents(&agents, |key| match key {
2927 GATEWAY_FILE_ENV => Some(gateway_path.clone()),
2928 SKIP_NICKS_ENV => Some("svoi-brauzer".to_string()),
2929 crate::STORE_ROOT_ENV => Some(root_text.clone()),
2930 _ => None,
2931 })
2932 .expect("web agents");
2933 assert_eq!(sessions.len(), 4);
2934 let alice = sessions
2935 .iter()
2936 .find(|s| s.session_id == "agent-h")
2937 .expect("h");
2938 assert!(alice
2939 .routine_url
2940 .as_deref()
2941 .unwrap_or("")
2942 .starts_with("https://"));
2943 assert!(alice.routine_bearer.is_some());
2944 assert!(!format!("{alice:?}").contains("key-"));
2945
2946 let mut offline = vec![HostSession::new("Alice", "agent-h")];
2948 offline[0].agent_id = Some("agent-h".to_string());
2949 let mut renamed = HostSession::new("Alice Two", "agent-h");
2950 renamed.agent_id = Some("agent-h".to_string());
2951 offline.push(renamed);
2952 attach_webhook_routines(
2953 &mut offline,
2954 &dir.join("absent.json"),
2955 None,
2956 Some(&store_root),
2957 )
2958 .expect("offline");
2959 assert_eq!(
2960 offline[0].routine_bearer.as_deref(),
2961 Some("key-agent-h-alice")
2962 );
2963 assert!(offline[1].routine_url.is_none());
2964 let mut bare = vec![HostSession::new("Alice", "agent-h")];
2965 attach_webhook_routines(&mut bare, &dir.join("absent.json"), None, None).expect("bare");
2966 assert!(bare[0].routine_url.is_none());
2967
2968 done.store(true, Ordering::Relaxed);
2969 let _ = server.join();
2970 let _ = std::fs::remove_dir_all(&dir);
2971 }
2972
2973 #[test]
2974 fn wake_note_is_appended_once_and_replaced_in_place() {
2975 let first = with_wake_note("Runs the alice.", "alice").expect("added");
2976 assert!(first.starts_with("Runs the alice.\n\n<!-- mail4agent:wake -->"));
2977 assert!(first.ends_with("<!-- /mail4agent:wake -->"));
2978 assert!(with_wake_note(&first, "alice").is_none());
2979 let renamed = with_wake_note(&first, "alice-two").expect("replaced");
2980 assert_eq!(renamed.matches("<!-- mail4agent:wake -->").count(), 1);
2981 assert!(renamed.contains("\"alice-two\""));
2982 assert!(!renamed.contains("\"alice\""));
2983 assert!(renamed.starts_with("Runs the alice."));
2984 assert_eq!(with_wake_note("", "carol").expect("empty"), wake_note("carol"));
2985 }
2986
2987 #[test]
2988 fn agents_directory_becomes_sessions_without_hardcoded_ids() {
2989 let dir = std::env::temp_dir().join(format!(
2990 "m4a-agents-{}-{}",
2991 std::process::id(),
2992 std::time::SystemTime::now()
2993 .duration_since(std::time::UNIX_EPOCH)
2994 .expect("clock")
2995 .as_nanos()
2996 ));
2997 let alice = dir.join("agent-alice");
2998 let chief = dir.join("agent-chief");
2999 std::fs::create_dir_all(&alice).expect("dir");
3000 std::fs::create_dir_all(&chief).expect("dir");
3001 std::fs::write(
3002 alice.join("profile.json"),
3003 r#"{"name":"Alice","description":"x"}"#,
3004 )
3005 .expect("profile");
3006 std::fs::write(
3007 chief.join("profile.json"),
3008 concat!(
3009 "{\"name\":\"",
3010 "Привет мир",
3011 "\",\"description\":\"x\"}"
3012 ),
3013 )
3014 .expect("profile");
3015 std::fs::write(dir.join("active-agent.json"), "{}").expect("skip file");
3016 let loaded = load_agents_dir(&dir).expect("agents");
3017 assert_eq!(loaded.len(), 2);
3018 assert_eq!(loaded[1].session_id, "agent-chief");
3019 assert_eq!(loaded[1].agent_id.as_deref(), Some("agent-chief"));
3020 assert_eq!(loaded[1].bot_name, "Привет мир");
3021 assert_eq!(
3022 routine_name_for(&loaded[1]).as_deref(),
3023 Some("privet-mir")
3024 );
3025 assert_ne!(
3026 routine_name_for(&loaded[1]).as_deref(),
3027 Some(loaded[1].session_id.as_str())
3028 );
3029 assert_eq!(loaded[0].bot_name, "Alice");
3030 assert_eq!(routine_name_for(&loaded[0]).as_deref(), Some("alice"));
3031 assert!(loaded.iter().all(|session| session.routine_url.is_none()));
3032 let _ = std::fs::remove_dir_all(&dir);
3033 }
3034}