mail4agent_messenger_shell/cli/
wake.rs1use 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 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 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}