1use std::collections::BTreeMap;
20use std::io::Write;
21use std::path::PathBuf;
22use std::time::{Duration, Instant};
23
24use anyhow::{bail, Context, Result};
25use serde::{Deserialize, Serialize};
26
27pub const WAKE_TIMEOUT_SECS: u64 = 30;
29
30const WEBHOOK_TIMEOUT_SECS: u64 = 10;
32
33#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
35pub struct Wake {
36 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub command: Option<String>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub webhook: Option<String>,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub key_file: Option<String>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub key_env: Option<String>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub timeout: Option<u64>,
52}
53
54#[derive(Debug, Clone, Default, PartialEq, Eq)]
56pub struct Event {
57 pub seat: String,
58 pub from: String,
59 pub msg: String,
60 pub issue: Option<String>,
61 pub interrupt: bool,
62}
63
64#[must_use]
66pub fn path() -> PathBuf {
67 super::harnesses_path().with_file_name("wake.toml")
68}
69
70#[must_use]
72pub fn load() -> BTreeMap<String, Wake> {
73 std::fs::read_to_string(path())
74 .ok()
75 .and_then(|text| toml::from_str(&text).ok())
76 .unwrap_or_default()
77}
78
79fn save(table: &BTreeMap<String, Wake>) -> Result<()> {
80 let path = path();
81 if let Some(dir) = path.parent() {
82 std::fs::create_dir_all(dir)?;
83 }
84 let text = format!(
85 "# Wakes `ljos send` starts when mail reaches a seat. `ljos wake --help` sets them.\n{}",
86 toml::to_string(table)?
87 );
88 std::fs::write(&path, text).with_context(|| format!("wake: {}", path.display()))
89}
90
91#[must_use]
95pub fn judge(command: &str) -> Option<super::Rule> {
96 super::seat_guard(command)
97 .or_else(|| super::tcb_verdict(command))
98 .or_else(|| super::ruled_verdict(&super::rules_for_gate(), command))
99}
100
101fn refusal(command: &str, rule: &super::Rule) -> String {
102 format!(
103 "the guard says {} on `{command}`: {} (rule `{}`). A wake runs with nobody to ask, so it does not run",
104 rule.verdict, rule.reason, rule.pattern
105 )
106}
107
108fn check_webhook(url: &str) -> Result<()> {
109 let loopback = ["http://127.0.0.1", "http://localhost", "http://[::1]"]
110 .iter()
111 .any(|p| url.starts_with(p));
112 if !url.starts_with("https://") && !loopback {
113 bail!("wake: {url} is not https, nor http on this machine");
114 }
115 Ok(())
116}
117
118pub fn set_command(seat: &str, command: &str, timeout: Option<u64>) -> Result<String> {
124 let command = command.trim();
125 if command.is_empty() {
126 bail!("wake: empty command");
127 }
128 if let Some(rule) = judge(command) {
129 bail!("wake: {}", refusal(command, &rule));
130 }
131 let mut table = load();
132 table.insert(
133 seat.to_string(),
134 Wake {
135 command: Some(command.to_string()),
136 timeout,
137 ..Wake::default()
138 },
139 );
140 save(&table)?;
141 Ok(format!(
142 "wake {seat}: runs `{command}` when mail reaches it\n"
143 ))
144}
145
146pub fn set_webhook(
152 seat: &str,
153 url: &str,
154 key_file: Option<&str>,
155 key_env: Option<&str>,
156) -> Result<String> {
157 let url = url.trim();
158 check_webhook(url)?;
159 let mut table = load();
160 table.insert(
161 seat.to_string(),
162 Wake {
163 webhook: Some(url.to_string()),
164 key_file: key_file.map(str::to_string),
165 key_env: key_env.map(str::to_string),
166 ..Wake::default()
167 },
168 );
169 save(&table)?;
170 Ok(format!(
171 "wake {seat}: posts to {url} when mail reaches it\n"
172 ))
173}
174
175pub fn clear(seat: &str) -> Result<String> {
181 let mut table = load();
182 if table.remove(seat).is_none() {
183 return Ok(format!("wake {seat}: none set\n"));
184 }
185 save(&table)?;
186 Ok(format!("wake {seat}: removed\n"))
187}
188
189#[must_use]
191pub fn show(seat: &str) -> String {
192 match load().get(seat) {
193 Some(Wake {
194 command: Some(c), ..
195 }) => format!("wake {seat}: runs `{c}`\n"),
196 Some(Wake {
197 webhook: Some(u), ..
198 }) => format!("wake {seat}: posts to {u}\n"),
199 _ => format!(
200 "wake {seat}: none set, so mail waits for its next prompt. `ljos wake --command CMD` sets one\n"
201 ),
202 }
203}
204
205fn log_path() -> PathBuf {
206 std::env::var_os("XDG_STATE_HOME")
207 .map(PathBuf::from)
208 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state")))
209 .unwrap_or_else(|| PathBuf::from(".local/state"))
210 .join("ljos")
211 .join("mail")
212 .join("wake.log")
213}
214
215fn log(line: &str) {
216 let path = log_path();
217 if let Some(dir) = path.parent() {
218 let _ = std::fs::create_dir_all(dir);
219 }
220 if let Ok(mut f) = std::fs::OpenOptions::new()
221 .create(true)
222 .append(true)
223 .open(&path)
224 {
225 let _ = writeln!(f, "{} {line}", super::now_utc());
226 }
227}
228
229fn wake_bin() -> Option<String> {
231 if let Some(bin) = std::env::var_os("LJOS_WAKE_BIN").filter(|b| !b.is_empty()) {
232 return Some(bin.to_string_lossy().into_owned());
233 }
234 let me = std::env::current_exe().ok();
235 let beside = me.as_ref().map(|m| m.with_file_name("ljos"));
236 me.filter(|m| m.file_stem().is_some_and(|s| s == "ljos"))
237 .or(beside.filter(|p| p.is_file()))
238 .or_else(|| which::which("ljos").ok())
239 .map(|p| p.display().to_string())
240}
241
242#[must_use]
246pub fn spawn(events: &[Event]) -> Vec<String> {
247 let table = load();
248 let mut woken = Vec::new();
249 for event in events.iter().filter(|e| table.contains_key(&e.seat)) {
250 let Some(bin) = wake_bin() else {
251 log(&format!("{}: no ljos to run the wake", event.seat));
252 continue;
253 };
254 let mut args = vec![
255 "wake".to_string(),
256 event.seat.clone(),
257 "--fire".into(),
258 "--from".into(),
259 event.from.clone(),
260 "--msg".into(),
261 event.msg.clone(),
262 ];
263 if let Some(issue) = &event.issue {
264 args.push("--issue".into());
265 args.push(issue.clone());
266 }
267 if event.interrupt {
268 args.push("--interrupt".into());
269 }
270 match super::detach(&bin, &args, std::path::Path::new("/dev/null")) {
273 Ok(()) => woken.push(event.seat.clone()),
274 Err(e) => log(&format!("{}: the wake did not start: {e:#}", event.seat)),
275 }
276 }
277 woken
278}
279
280#[must_use]
282pub fn envelope(event: &Event) -> serde_json::Value {
283 serde_json::json!({
284 "event": "ljos-mail",
285 "seat": event.seat,
286 "from": event.from,
287 "msg": event.msg,
288 "issue": event.issue,
289 "interrupt": event.interrupt,
290 "read": format!("LJOS_SEAT={} ljos inbox", event.seat),
291 })
292}
293
294fn webhook_key(wake: &Wake) -> Option<String> {
295 let from_file = wake.key_file.as_deref().and_then(|p| {
296 let path = p
297 .strip_prefix("~/")
298 .and_then(|rest| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(rest)))
299 .unwrap_or_else(|| PathBuf::from(p));
300 std::fs::read_to_string(path).ok()
301 });
302 from_file
303 .or_else(|| wake.key_env.as_deref().and_then(|v| std::env::var(v).ok()))
304 .map(|k| k.trim().to_string())
305 .filter(|k| !k.is_empty())
306}
307
308fn run_command(command: &str, event: &Event, timeout: Duration) -> Result<String> {
309 if let Some(rule) = judge(command) {
310 bail!("{}", refusal(command, &rule));
311 }
312 let mut cmd = std::process::Command::new("sh");
313 cmd.arg("-c")
314 .arg(command)
315 .env("LJOS_SEAT", &event.seat)
316 .env("LJOS_WAKE_SEAT", &event.seat)
317 .env("LJOS_WAKE_FROM", &event.from)
318 .env("LJOS_WAKE_MSG", &event.msg)
319 .env("LJOS_WAKE_ISSUE", event.issue.as_deref().unwrap_or(""))
320 .env(
321 "LJOS_WAKE_INTERRUPT",
322 if event.interrupt { "1" } else { "0" },
323 )
324 .stdin(std::process::Stdio::null());
325 let path = log_path();
328 if let Some(dir) = path.parent() {
329 let _ = std::fs::create_dir_all(dir);
330 }
331 if let Ok(out) = std::fs::OpenOptions::new()
332 .create(true)
333 .append(true)
334 .open(&path)
335 {
336 if let Ok(err) = out.try_clone() {
337 cmd.stdout(out).stderr(err);
338 }
339 }
340 let mut child = cmd.spawn().context("start the wake command")?;
341 let started = Instant::now();
342 loop {
343 if let Some(status) = child.try_wait()? {
344 if status.success() {
345 return Ok(format!("ran in {} ms", started.elapsed().as_millis()));
346 }
347 bail!("exited {status}");
348 }
349 if started.elapsed() >= timeout {
350 let _ = child.kill();
352 let _ = child.wait();
353 bail!("stopped after {} ms", timeout.as_millis());
354 }
355 std::thread::sleep(Duration::from_millis(20));
356 }
357}
358
359fn post(url: &str, wake: &Wake, event: &Event) -> Result<String> {
360 check_webhook(url)?;
361 let agent = ureq::AgentBuilder::new()
362 .timeout(Duration::from_secs(WEBHOOK_TIMEOUT_SECS))
363 .build();
364 let mut req = agent.post(url).set("Content-Type", "application/json");
365 if let Some(key) = webhook_key(wake) {
366 req = req.set("Authorization", &format!("Bearer {key}"));
367 }
368 let started = Instant::now();
369 let resp = req.send_string(&envelope(event).to_string()).map_err(|e| {
370 anyhow::anyhow!("POST {url}: {}", e.to_string().replace(url, "the webhook"))
371 })?;
372 Ok(format!(
373 "posted, {} in {} ms",
374 resp.status(),
375 started.elapsed().as_millis()
376 ))
377}
378
379pub fn fire(event: &Event) -> Result<String> {
387 let table = load();
388 let Some(wake) = table.get(&event.seat) else {
389 bail!("wake: {} has no wake set", event.seat);
390 };
391 let outcome = if let Some(command) = &wake.command {
392 let timeout = Duration::from_secs(wake.timeout.unwrap_or(WAKE_TIMEOUT_SECS).max(1));
393 run_command(command, event, timeout)
394 } else if let Some(url) = &wake.webhook {
395 post(url, wake, event)
396 } else {
397 Err(anyhow::anyhow!("neither a command nor a webhook"))
398 };
399 match outcome {
400 Ok(said) => {
401 log(&format!("{} {}: {said}", event.seat, event.msg));
402 Ok(format!("wake {}: {said}\n", event.seat))
403 }
404 Err(e) => {
405 log(&format!("{} {}: {e:#}", event.seat, event.msg));
406 Err(e).context(format!("wake {}", event.seat))
407 }
408 }
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414
415 #[test]
416 fn a_wake_reads_back_as_written_and_keeps_no_key() {
417 let mut table = BTreeMap::new();
418 table.insert(
419 "grok".to_string(),
420 Wake {
421 webhook: Some("https://api2.cursor.sh/automations/webhook/abc".into()),
422 key_file: Some("~/.config/ljos/grok.key".into()),
423 ..Wake::default()
424 },
425 );
426 table.insert(
427 "claude-review".to_string(),
428 Wake {
429 command: Some("claude -p --resume \"$SESSION\" 'ljos inbox'".into()),
430 timeout: Some(120),
431 ..Wake::default()
432 },
433 );
434 let text = toml::to_string(&table).unwrap();
435 assert!(!text.contains("key ="), "{text}");
436 let back: BTreeMap<String, Wake> = toml::from_str(&text).unwrap();
437 assert_eq!(back, table);
438 }
439
440 #[test]
441 fn a_webhook_gets_the_envelope_and_not_the_body() {
442 let event = Event {
443 seat: "grok".into(),
444 from: "saddle".into(),
445 msg: "m1".into(),
446 issue: Some("Software-d9n6".into()),
447 interrupt: true,
448 };
449 let v = envelope(&event);
450 assert_eq!(v["seat"], "grok");
451 assert_eq!(v["msg"], "m1");
452 assert_eq!(v["interrupt"], true);
453 assert!(v.get("body").is_none() && v.get("text").is_none(), "{v}");
454 assert!(check_webhook("http://example.com/hook").is_err());
455 assert!(check_webhook("https://api2.cursor.sh/automations/webhook/x").is_ok());
456 assert!(check_webhook("http://127.0.0.1:9/hook").is_ok());
457 }
458
459 #[test]
460 fn a_wake_that_runs_past_its_timeout_is_stopped() {
461 let event = Event {
462 seat: "slow".into(),
463 ..Event::default()
464 };
465 let started = Instant::now();
466 let err = run_command("sleep 5", &event, Duration::from_millis(300)).unwrap_err();
467 assert!(err.to_string().contains("stopped after"), "{err}");
468 assert!(started.elapsed() < Duration::from_secs(3));
469 let ok = run_command("test \"$LJOS_SEAT\" = slow", &event, Duration::from_secs(5));
470 assert!(ok.is_ok(), "{ok:?}");
471 }
472}