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