Skip to main content

mail4agent_messenger_shell/cli/
wake.rs

1//! `m4a-agent wake <status|import|enable|disable|mode> --as <nick> | --session <id>`
2//!
3//! The owner's control of a session's wake, without a restart of anything and without a secret on
4//! a command line or in a printout:
5//!
6//! * `status`: policy, the wake target's state and last answer, where the target comes from.
7//! * `import --file <path> [--delete-source]`: reads the owner-placed `{"url","key"}` file (mode
8//!   0600) into the vault. The running client picks it up at its next start.
9//! * `enable` / `disable`: the policy switch (`wake.json` in the session's store directory).
10//! * `mode off|dm|mention|all`: when to wake (`mention` is the default: DMs and messages that address
11//!   the session).
12//!
13//! `M4A_WAKE=off` in the daemon's environment silences every session of that process.
14
15use super::session_arg::{resolve, take_flag, take_switch, Target};
16use crate::wake_policy::{globally_off, WakeMode, WakePolicy, WakeStatusFile};
17
18pub fn run(args: Vec<String>) {
19    match go(args) {
20        Ok(text) => println!("{text}"),
21        Err(e) => {
22            eprintln!("wake: {e}");
23            std::process::exit(if e.starts_with("usage") { 64 } else { 1 });
24        }
25    }
26}
27
28const USAGE: &str = "usage: m4a-agent wake <status|import|enable|disable|mode> (--as <nick>|--session <id>) [--file <path> [--delete-source]] [off|dm|mention|all]";
29
30pub(crate) fn go(mut args: Vec<String>) -> Result<String, String> {
31    if args.is_empty() {
32        return Err(USAGE.into());
33    }
34    let sub = args.remove(0);
35    let file = take_flag(&mut args, "--file")?;
36    let delete_source = take_switch(&mut args, "--delete-source");
37    let target = resolve(&mut args)?;
38    let dir = target.store_dir();
39    match sub.as_str() {
40        "status" if args.is_empty() => Ok(status(&target)),
41        "enable" | "disable" if args.is_empty() => {
42            let mut p = WakePolicy::load(&dir);
43            p.enabled = sub == "enable";
44            p.save(&dir).map_err(|e| format!("cannot write the policy: {e}"))?;
45            Ok(format!("wake {}: {} (mode {})", label(&target), if p.enabled { "enabled" } else { "disabled" }, p.mode.name()))
46        }
47        "mode" if args.len() == 1 => {
48            let mode = WakeMode::parse(&args[0]).ok_or_else(|| "mode is off, dm, mention or all".to_string())?;
49            let mut p = WakePolicy::load(&dir);
50            p.mode = mode;
51            p.save(&dir).map_err(|e| format!("cannot write the policy: {e}"))?;
52            Ok(format!("wake {}: mode {} ({})", label(&target), mode.name(), if p.enabled { "enabled" } else { "disabled" }))
53        }
54        "import" if args.is_empty() => {
55            let file = file.ok_or("import needs --file <path>")?;
56            let path = std::path::PathBuf::from(&file);
57            let (url, key) = crate::machine::read_routine_file(&path)?;
58            let vault = crate::store_key::vault(&target.store_root).map_err(|e| e.to_string())?;
59            let body = serde_json::to_vec(&serde_json::json!({ "url": url, "key": key })).map_err(|e| e.to_string())?;
60            vault.put(&crate::machine::wake_label(&target.session_id), &body).map_err(|e| format!("key vault: {e}"))?;
61            crate::wake_policy::note_state(&dir, "ready", "vault");
62            let mut out = format!("wake {}: imported into the vault; a running client takes it at its next start", label(&target));
63            if delete_source {
64                std::fs::remove_file(&path).map_err(|_| "imported, but the source file could not be deleted".to_string())?;
65                out.push_str("; source file deleted");
66            }
67            Ok(out)
68        }
69        _ => Err(USAGE.into()),
70    }
71}
72
73fn label(t: &Target) -> String {
74    t.nick.clone().unwrap_or_else(|| t.session_id.clone())
75}
76
77fn status(t: &Target) -> String {
78    let dir = t.store_dir();
79    let p = WakePolicy::load(&dir);
80    let s = WakeStatusFile::load(&dir);
81    let in_vault = crate::store_key::vault(&t.store_root).ok().and_then(|v| v.get(&crate::machine::wake_label(&t.session_id)).ok().flatten()).is_some();
82    let rec = t.record.as_ref();
83    let mut o = vec![format!("session: {} ({})", t.session_id, label(t))];
84    o.push(format!("policy: {} / mode {}", if p.enabled { "enabled" } else { "disabled" }, p.mode.name()));
85    o.push(format!("global M4A_WAKE: {}", if globally_off() { "off (in THIS process; the daemon has its own environment)" } else { "not off here" }));
86    o.push(format!("target in vault: {}", if in_vault { "yes" } else { "no" }));
87    o.push(format!("record names a routine file: {}", if rec.is_some_and(|r| r.routine_file.is_some()) { "yes" } else { "no" }));
88    o.push(format!("record names an agent for the gateway: {}", if rec.is_some_and(|r| r.agent_id.is_some()) { "yes" } else { "no" }));
89    match s {
90        Some(s) => {
91            o.push(format!("target: {} (from {})", s.state, s.source));
92            o.push(format!("last wake answer: {}", s.last_status.map(|c| c.to_string()).unwrap_or_else(|| "none".into())));
93        }
94        None => o.push("target: no state recorded yet (the client has not run with this session since 0.4.4)".into()),
95    }
96    o.join("\n")
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[cfg(unix)]
104    #[test]
105    fn the_owner_controls_a_wake_without_a_secret_ever_being_printed() {
106        use std::os::unix::fs::PermissionsExt;
107        let _g = super::super::session_arg::ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
108        let d = tempfile::tempdir().unwrap();
109        let (root, sessions) = (d.path().join("store"), d.path().join("sessions"));
110        std::fs::create_dir_all(&sessions).unwrap();
111        std::fs::write(sessions.join("a.json"), r#"{"bot_name":"Courier","session_id":"web-courier"}"#).unwrap();
112        std::env::set_var(crate::STORE_ROOT_ENV, &root);
113        std::env::set_var(crate::machine::SESSIONS_DIR_ENV, &sessions);
114        std::env::remove_var(crate::wake_policy::WAKE_ENV);
115        std::env::remove_var(m4a_agent::vault::VAULT_KEY_ENV);
116        let say = |a: &[&str]| go(a.iter().map(|s| s.to_string()).collect());
117
118        let out = say(&["status", "--as", "courier"]).unwrap();
119        assert!(out.contains("enabled / mode mention") && out.contains("target in vault: no"), "{out}");
120        assert!(say(&["mode", "--as", "courier", "dm"]).unwrap().contains("mode dm"));
121        assert!(say(&["disable", "--as", "courier"]).unwrap().contains("disabled"));
122        assert!(say(&["status", "--session", "web-courier"]).unwrap().contains("disabled / mode dm"));
123        assert!(say(&["enable", "--as", "courier"]).unwrap().contains("enabled"));
124        assert!(say(&["mode", "--as", "courier", "sometimes"]).is_err());
125        assert!(say(&["status"]).is_err() && say(&["status", "--as", "nobody"]).is_err());
126
127        // Import: refuses a readable file, takes a private one, never echoes the value, can delete the source.
128        let f = d.path().join("w.json");
129        std::fs::write(&f, r#"{"url":"http://127.0.0.1:9/h","key":"k-very-secret"}"#).unwrap();
130        std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o644)).unwrap();
131        let e = say(&["import", "--as", "courier", "--file", f.to_str().unwrap()]).unwrap_err();
132        assert!(!e.contains("k-very-secret") && e.contains("0600"), "{e}");
133        std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o600)).unwrap();
134        let out = say(&["import", "--as", "courier", "--file", f.to_str().unwrap(), "--delete-source"]).unwrap();
135        assert!(!out.contains("k-very-secret") && !out.contains("127.0.0.1") && out.contains("source file deleted"), "{out}");
136        assert!(!f.exists());
137        let st = say(&["status", "--as", "courier"]).unwrap();
138        assert!(st.contains("target in vault: yes") && st.contains("ready (from vault)") && !st.contains("k-very-secret"), "{st}");
139
140        // The record picks the vault wake up at open.
141        let mut set = vec![crate::machine::HostSession::new("Courier", "web-courier")];
142        crate::machine::attach_vault_wakes_for_test(&mut set, &root);
143        assert_eq!(set[0].routine_url.as_deref(), Some("http://127.0.0.1:9/h"));
144        assert_eq!(set[0].routine_bearer.as_deref(), Some("k-very-secret"));
145    }
146}