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