Skip to main content

agentmux_srv/backend/remote/
helper_consent.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Asking before AgentMux's helper is installed on a host
5//! (SPEC_REMOTES_PANE_2026_10_05.md §4.9).
6//!
7//! The helper is software put on someone's machine, so nothing is uploaded
8//! until the user says so: in the approval window (crates/cef `ssh_approval`),
9//! which agents can neither reach nor answer. `conn:helper` for the host, else
10//! the global `conn:helper`, says `ask` (the default), `always` or `never`.
11//! An install an agent's pane sets off is always asked about, whatever the
12//! setting: letting an agent use a host is not letting it install software
13//! there.
14//!
15//! The server installs the window and the settings ([`install`]) once srv is
16//! up; until then (and in tests) there is no one to ask, so nothing installs.
17
18use std::collections::{HashMap, HashSet};
19use std::future::Future;
20use std::pin::Pin;
21use std::sync::{Mutex, OnceLock};
22
23use tokio::sync::watch;
24
25/// What the settings say for a host.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Policy {
28    Ask,
29    Always,
30    Never,
31}
32
33impl Policy {
34    /// `always` and `never` as stored; anything else (unset included) is ask.
35    pub fn parse(value: &str) -> Policy {
36        match value.trim().to_ascii_lowercase().as_str() {
37            "always" => Policy::Always,
38            "never" => Policy::Never,
39            _ => Policy::Ask,
40        }
41    }
42
43    /// The host's own setting, else the global one.
44    pub fn resolve(host: &str, global: &str) -> Policy {
45        if host.trim().is_empty() {
46            Policy::parse(global)
47        } else {
48            Policy::parse(host)
49        }
50    }
51}
52
53/// What to do before installing.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum Step {
56    Install,
57    Refuse,
58    Ask,
59}
60
61/// `Always` installs and `Never` refuses without asking, for the user's own
62/// panes only; an agent's always asks.
63pub fn step(policy: Policy, by_agent: bool) -> Step {
64    match (policy, by_agent) {
65        (_, true) | (Policy::Ask, false) => Step::Ask,
66        (Policy::Always, false) => Step::Install,
67        (Policy::Never, false) => Step::Refuse,
68    }
69}
70
71/// The user's answer in the approval window.
72#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
73pub struct Answer {
74    /// False when the window was closed, timed out or could not open.
75    pub answered: bool,
76    /// Install.
77    pub approve: bool,
78    /// "Don't ask again for this host".
79    pub remember: bool,
80}
81
82/// What an answer does: install or not, and what to store for the host.
83pub fn outcome(answer: Answer) -> (bool, Option<&'static str>) {
84    let install = answer.answered && answer.approve;
85    let remember = (answer.answered && answer.remember).then_some(if install { "always" } else { "never" });
86    (install, remember)
87}
88
89type BoxFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
90
91/// What [`allow_install`] needs from the server.
92pub struct Deps {
93    /// Ask the question in the approval window over the window showing the
94    /// pane (`block_id`; empty: the main window).
95    pub ask: Box<dyn Fn(String, serde_json::Value) -> BoxFuture<Result<Answer, String>> + Send + Sync>,
96    /// `conn:helper` for the host, and the global one.
97    pub settings: Box<dyn Fn(&str) -> (String, String) + Send + Sync>,
98    /// Store `conn:helper` for the host.
99    pub remember: Box<dyn Fn(&str, &str) -> Result<(), String> + Send + Sync>,
100}
101
102static DEPS: OnceLock<Deps> = OnceLock::new();
103
104/// Called once, when srv is up.
105pub fn install(deps: Deps) {
106    let _ = DEPS.set(deps);
107}
108
109/// For tests that install the helper: every question is answered Install.
110#[cfg(test)]
111pub fn install_answering_yes() {
112    install(Deps {
113        ask: Box::new(|_, _| Box::pin(async { Ok(Answer { answered: true, approve: true, remember: false }) })),
114        settings: Box::new(|_| (String::new(), String::new())),
115        remember: Box::new(|_, _| Ok(())),
116    });
117}
118
119/// The most agent panes remembered; past it the oldest go (a pane that old
120/// has long since installed the helper, or not).
121const MAX_AGENT_PANES: usize = 512;
122
123/// Panes an agent opened on a host, and the agent: an install from one of
124/// them is the agent's doing. Kept in memory, not in the pane's meta, which
125/// any caller holding srv's key could clear. `opening` counts the agent
126/// `pane.open`s in flight per host: until one returns its pane isn't noted
127/// yet, so any install on that host asks.
128#[derive(Default)]
129struct AgentPanes {
130    by_block: HashMap<String, String>,
131    order: std::collections::VecDeque<String>,
132    opening: HashMap<String, u32>,
133}
134
135fn agent_panes() -> &'static Mutex<AgentPanes> {
136    static PANES: OnceLock<Mutex<AgentPanes>> = OnceLock::new();
137    PANES.get_or_init(|| Mutex::new(AgentPanes::default()))
138}
139
140fn lock() -> std::sync::MutexGuard<'static, AgentPanes> {
141    agent_panes().lock().unwrap_or_else(|e| e.into_inner())
142}
143
144/// Hosts (by [`host_key`]) remembered until srv restarts.
145type Hosts = OnceLock<Mutex<HashSet<String>>>;
146
147fn hosts(set: &'static Hosts) -> std::sync::MutexGuard<'static, HashSet<String>> {
148    set.get_or_init(Default::default).lock().unwrap_or_else(|e| e.into_inner())
149}
150
151/// Hosts the user answered "Not now" for since srv started.
152fn declined() -> std::sync::MutexGuard<'static, HashSet<String>> {
153    static DECLINED: Hosts = OnceLock::new();
154    hosts(&DECLINED)
155}
156
157/// Hosts whose terminals were durable by default ([`Mode::Default`]) and whose helper
158/// didn't get installed since srv started: the question went unanswered, or
159/// the install failed. Their panes are plain until then, unless durable is
160/// asked for.
161///
162/// [`Mode::Default`]: crate::backend::blockcontroller::durable_mode::Mode::Default
163fn not_installed_hosts() -> std::sync::MutexGuard<'static, HashSet<String>> {
164    static NOT_INSTALLED: Hosts = OnceLock::new();
165    hosts(&NOT_INSTALLED)
166}
167
168/// Whether `connection`'s helper didn't get installed for a terminal durable by default, since srv started.
169pub fn not_installed(connection: &str) -> bool {
170    not_installed_hosts().contains(&host_key(connection))
171}
172
173/// `connection`'s helper didn't get installed for a terminal durable by default.
174pub fn note_not_installed(connection: &str) {
175    not_installed_hosts().insert(host_key(connection));
176}
177
178/// Whether a durable pane on `connection` runs as a plain SSH terminal
179/// instead, because the user doesn't want the helper there (§4.9: "Not now,
180/// or Never: the pane falls back"): the host is set to Never, or the user
181/// answered "Not now" for it since srv started. Set to Always, never. Also
182/// a host the helper can't run on (a Windows host), whatever the setting.
183pub fn plain_instead(connection: &str) -> bool {
184    if unsupported_host(connection) {
185        return true;
186    }
187    let Some(deps) = DEPS.get() else {
188        return false;
189    };
190    let (host, global) = (deps.settings)(connection);
191    plain_for(
192        Policy::resolve(&host, &global),
193        declined().contains(&host_key(connection)),
194    )
195}
196
197/// Hosts found since srv started to be ones the helper can't run on.
198fn unsupported() -> std::sync::MutexGuard<'static, HashSet<String>> {
199    static UNSUPPORTED: Hosts = OnceLock::new();
200    hosts(&UNSUPPORTED)
201}
202
203/// Whether `connection`'s host was found to be one the helper can't run on.
204pub fn unsupported_host(connection: &str) -> bool {
205    unsupported().contains(&host_key(connection))
206}
207
208/// `connection`'s host can't run the helper (a Windows host, spec §6.4 of
209/// the remote-terminals spec): its durable panes run as plain SSH terminals.
210pub fn note_unsupported(connection: &str) {
211    unsupported().insert(host_key(connection));
212}
213
214/// [`plain_instead`] from the host's policy and whether the user answered
215/// "Not now" for it since srv started.
216fn plain_for(policy: Policy, declined: bool) -> bool {
217    match policy {
218        Policy::Always => false,
219        Policy::Never => true,
220        Policy::Ask => declined,
221    }
222}
223
224/// Hosts the user answered Install for since srv started: the host's other
225/// terminals install without asking again, the first maybe still uploading.
226fn approved() -> std::sync::MutexGuard<'static, HashSet<String>> {
227    static APPROVED: Hosts = OnceLock::new();
228    hosts(&APPROVED)
229}
230
231/// The helper was removed from `connection`'s host: what was answered for it
232/// since srv started (Install, Not now, a failed try) no longer holds, and
233/// its next need asks again. A stored Always or Never is a setting, and stays.
234pub fn forget_answers(connection: &str) {
235    let key = host_key(connection);
236    approved().remove(&key);
237    declined().remove(&key);
238    not_installed_hosts().remove(&key);
239}
240
241/// Remember an answer until srv restarts: "Not now" for [`plain_instead`],
242/// Install so the host isn't asked again; each undoes the other. A closed
243/// window is no answer.
244pub(crate) fn note_answer(connection: &str, answer: Answer) {
245    if !answer.answered {
246        return;
247    }
248    let key = host_key(connection);
249    if answer.approve {
250        declined().remove(&key);
251        approved().insert(key);
252    } else {
253        approved().remove(&key);
254        declined().insert(key);
255    }
256}
257
258/// One host, however it is spelled.
259fn host_key(connection: &str) -> String {
260    super::ConnTarget::parse(connection)
261        .map(|t| t.name())
262        .unwrap_or_else(|_| connection.trim().to_string())
263}
264
265/// `block_id` was opened by `agent` (a verified agent's `pane.open` on a host).
266pub fn note_agent_pane(block_id: &str, agent: &str) {
267    lock().note(block_id, agent);
268}
269
270impl AgentPanes {
271    /// [`note_agent_pane`] in this set: past [`MAX_AGENT_PANES`] the oldest go.
272    fn note(&mut self, block_id: &str, agent: &str) {
273        if block_id.is_empty() {
274            return;
275        }
276        if self.by_block.insert(block_id.to_string(), agent.to_string()).is_none() {
277            self.order.push_back(block_id.to_string());
278        }
279        while self.order.len() > MAX_AGENT_PANES {
280            if let Some(old) = self.order.pop_front() {
281                self.by_block.remove(&old);
282            }
283        }
284    }
285}
286
287/// An agent's `pane.open` on a host is in flight while this lives.
288pub struct AgentOpening(String);
289
290impl Drop for AgentOpening {
291    fn drop(&mut self) {
292        let mut panes = lock();
293        if let Some(n) = panes.opening.get_mut(&self.0) {
294            *n = n.saturating_sub(1);
295            if *n == 0 {
296                panes.opening.remove(&self.0);
297            }
298        }
299    }
300}
301
302/// Call before an agent's `pane.open` on `connection`; hold the guard until
303/// the pane is noted ([`note_agent_pane`]).
304pub fn begin_agent_open(connection: &str) -> AgentOpening {
305    let key = host_key(connection);
306    *lock().opening.entry(key.clone()).or_default() += 1;
307    AgentOpening(key)
308}
309
310fn agent_of(block_id: Option<&str>) -> Option<String> {
311    let block_id = block_id.filter(|b| !b.is_empty())?;
312    lock().by_block.get(block_id).cloned()
313}
314
315fn agent_opening(connection: &str) -> bool {
316    lock().opening.contains_key(&host_key(connection))
317}
318
319/// The question (§4.9).
320pub fn question(connection: &str, agent: Option<&str>) -> serde_json::Value {
321    let who = match agent {
322        Some(a) => format!("{a} is using a pane on {connection} that needs it. "),
323        None => String::new(),
324    };
325    serde_json::json!({
326        "kind": "consent",
327        "title": format!("Install AgentMux's helper on {connection}?"),
328        "message": format!(
329            "{who}The helper enables file browsing and terminals that survive disconnects. \
330             It is about 500 KB, in ~/.agentmux-remote on that machine; it needs no root, \
331             and you can remove it any time from Connectors > Remotes."
332        ),
333        "checkbox": format!("Don't ask again for {connection}"),
334        "ok_label": "Install",
335        "cancel_label": "Not now",
336    })
337}
338
339/// What needs the helper on a host: it decides what an answer given earlier
340/// for the host (since srv started) means now.
341#[derive(Debug, Clone, Copy, PartialEq, Eq)]
342pub enum Need {
343    /// The host's files: after a Not now, Reconnect asks again.
344    Files,
345    /// A terminal: a Not now holds until srv restarts, and so does an earlier
346    /// failed try when it is durable only `by_default`.
347    Terminal { by_default: bool },
348}
349
350/// Whether the helper may be installed on `connection` now, for the pane
351/// `block_id` (`None`: no pane, which counts as an agent's doing). `Err`
352/// says why not, for the pane to show.
353pub async fn allow_install(connection: &str, block_id: Option<&str>, need: Need) -> Result<(), String> {
354    let deps = DEPS
355        .get()
356        .ok_or_else(|| "AgentMux could not ask whether to install its helper".to_string())?;
357    allow_install_with(deps, connection, block_id, need).await
358}
359
360/// The user's question per host while it is open: their other panes on that
361/// host wait for its answer instead of asking again.
362fn open_questions() -> std::sync::MutexGuard<'static, HashMap<String, watch::Receiver<Option<Answer>>>> {
363    static OPEN: OnceLock<Mutex<HashMap<String, watch::Receiver<Option<Answer>>>>> = OnceLock::new();
364    OPEN.get_or_init(Default::default).lock().unwrap_or_else(|e| e.into_inner())
365}
366
367/// What was answered for the host since srv started, as it holds for
368/// `need`: `None` to ask. Read with the open questions locked, so a question
369/// closing meanwhile can't be missed (its answer is noted before it closes).
370fn held(connection: &str, key: &str, need: Need) -> Option<Result<(), String>> {
371    // A failed try first: an Install answered before it doesn't make the
372    // upload work this time.
373    if need == (Need::Terminal { by_default: true }) && not_installed_hosts().contains(key) {
374        return Some(Err("it didn't install on this host earlier".to_string()));
375    }
376    if approved().contains(key) {
377        return Some(Ok(()));
378    }
379    match need {
380        Need::Terminal { .. } if declined().contains(key) => {
381            Some(Err(format!("you chose not to install AgentMux's helper on {connection}")))
382        }
383        _ => None,
384    }
385}
386
387/// Ask the user about `connection` once: a question already open for the
388/// host answers this pane too, and so does an answer given earlier
389/// ([`held`]). A failed question counts as no answer.
390async fn ask_once(deps: &Deps, connection: &str, block_id: &str, need: Need) -> Result<(), String> {
391    let key = host_key(connection);
392    let tx = loop {
393        let open = {
394            let mut open = open_questions();
395            match open.get(&key) {
396                Some(rx) => Err(rx.clone()),
397                None => {
398                    if let Some(earlier) = held(connection, &key, need) {
399                        return earlier;
400                    }
401                    let (tx, rx) = watch::channel(None);
402                    open.insert(key.clone(), rx);
403                    Ok(tx)
404                }
405            }
406        };
407        match open {
408            Ok(tx) => break tx,
409            Err(mut rx) => match rx.wait_for(Option::is_some).await {
410                Ok(answer) => return verdict(connection, answer.unwrap_or_default()),
411                // The asking pane went away without an answer (closed, or
412                // stopped): this pane asks instead. The user said nothing
413                // about it.
414                Err(_) => continue,
415            },
416        }
417    };
418    // However this ends, even dropped with its pane, the question closes.
419    let _open = OpenQuestion(key);
420    let answer = match (deps.ask)(block_id.to_string(), question(connection, None)).await {
421        Ok(answer) => answer,
422        Err(e) => {
423            tracing::warn!(connection = %connection, error = %e, "helper install: could not ask");
424            Answer::default()
425        }
426    };
427    record(deps, connection, None, answer);
428    // Unanswered, for a terminal durable only by default: a failed try,
429    // noted before the question closes so a terminal arriving now sees it.
430    if !answer.answered && need == (Need::Terminal { by_default: true }) {
431        note_not_installed(connection);
432    }
433    let _ = tx.send(Some(answer));
434    verdict(connection, answer)
435}
436
437/// What an answer means for the pane: install, or why not.
438fn verdict(connection: &str, answer: Answer) -> Result<(), String> {
439    if outcome(answer).0 {
440        Ok(())
441    } else if answer.answered {
442        Err(format!("you chose not to install AgentMux's helper on {connection}"))
443    } else {
444        Err(format!("nobody answered whether to install AgentMux's helper on {connection}"))
445    }
446}
447
448/// A host's open question, closed on drop.
449struct OpenQuestion(String);
450
451impl Drop for OpenQuestion {
452    fn drop(&mut self) {
453        open_questions().remove(&self.0);
454    }
455}
456
457/// Store and log an answer once, where it was asked.
458fn record(deps: &Deps, connection: &str, agent: Option<&str>, answer: Answer) {
459    let (install, remember) = outcome(answer);
460    if let Some(value) = remember {
461        if let Err(e) = (deps.remember)(connection, value) {
462            tracing::warn!(connection = %connection, error = %e, "helper install: could not store the answer");
463        }
464    }
465    tracing::info!(connection = %connection, agent = ?agent, install, remember = ?remember, "helper install: asked");
466    note_answer(connection, answer);
467}
468
469/// [`allow_install`] with its dependencies given.
470async fn allow_install_with(deps: &Deps, connection: &str, block_id: Option<&str>, need: Need) -> Result<(), String> {
471    let agent = agent_of(block_id);
472    let by_agent = agent.is_some() || block_id.is_none_or(str::is_empty) || agent_opening(connection);
473    let (host, global) = (deps.settings)(connection);
474    match step(Policy::resolve(&host, &global), by_agent) {
475        Step::Install => return Ok(()),
476        Step::Refuse => {
477            return Err(format!(
478                "installing AgentMux's helper on {connection} is set to Never (change it in Connectors > Remotes)"
479            ))
480        }
481        Step::Ask => {}
482    }
483    if !by_agent {
484        return ask_once(deps, connection, block_id.unwrap_or_default(), need).await;
485    }
486    // An agent's question names the agent, and is its own.
487    let answer = (deps.ask)(
488        block_id.unwrap_or_default().to_string(),
489        question(connection, agent.as_deref()),
490    )
491    .await?;
492    record(deps, connection, agent.as_deref(), answer);
493    verdict(connection, answer)
494}
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499
500    /// A restart brings back all of a host's terminals at once: they share
501    /// one question rather than each opening its own window.
502    #[tokio::test]
503    async fn a_hosts_panes_share_one_question() {
504        use std::sync::atomic::{AtomicUsize, Ordering};
505        let asked = std::sync::Arc::new(AtomicUsize::new(0));
506        let deps = {
507            let asked = asked.clone();
508            Deps {
509                ask: Box::new(move |_, _| {
510                    asked.fetch_add(1, Ordering::SeqCst);
511                    Box::pin(async {
512                        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
513                        Ok(Answer { answered: true, approve: true, remember: false })
514                    })
515                }),
516                settings: Box::new(|_| (String::new(), String::new())),
517                remember: Box::new(|_, _| Ok(())),
518            }
519        };
520        let (a, b, c) = tokio::join!(
521            allow_install_with(&deps, "me@shared-question-host", Some("blk-shared-1"), Need::Files),
522            allow_install_with(&deps, " me@shared-question-host ", Some("blk-shared-2"), Need::Files),
523            allow_install_with(&deps, "me@other-question-host", Some("blk-shared-3"), Need::Files),
524        );
525        assert_eq!((a, b, c), (Ok(()), Ok(()), Ok(())));
526        assert_eq!(asked.load(Ordering::SeqCst), 2, "one question per host");
527
528        // A terminal that gets to the host after the question closed (the
529        // first is still uploading the helper) doesn't ask again.
530        let later = allow_install_with(&deps, "me@shared-question-host", Some("blk-shared-4"), Need::Files).await;
531        assert_eq!(later, Ok(()));
532        assert_eq!(asked.load(Ordering::SeqCst), 2, "Install holds until srv restarts");
533
534        // Removing the helper (Remotes) undoes it: the next need asks again.
535        forget_answers(" me@shared-question-host ");
536        let after_removal = allow_install_with(&deps, "me@shared-question-host", Some("blk-shared-5"), Need::Files).await;
537        assert_eq!(after_removal, Ok(()));
538        assert_eq!(asked.load(Ordering::SeqCst), 3, "asked again after the helper was removed");
539
540    }
541
542    /// A terminal holds a Not now (and, durable only by default, an earlier
543    /// failed try) until srv restarts, read in the same step that would open a
544    /// question; Files asks again (its Reconnect).
545    #[tokio::test]
546    async fn what_an_earlier_answer_means_depends_on_the_need() {
547        use std::sync::atomic::{AtomicUsize, Ordering};
548        static ASKED: AtomicUsize = AtomicUsize::new(0);
549        static DEPS_COUNTS: OnceLock<Deps> = OnceLock::new();
550        let deps = DEPS_COUNTS.get_or_init(|| Deps {
551            ask: Box::new(|_, _| {
552                ASKED.fetch_add(1, Ordering::SeqCst);
553                Box::pin(async { Ok(Answer { answered: true, approve: false, remember: false }) })
554            }),
555            settings: Box::new(|_| (String::new(), String::new())),
556            remember: Box::new(|_, _| Ok(())),
557        });
558        let terminal = Need::Terminal { by_default: true };
559        note_answer("me@need-declined-host", Answer { answered: true, approve: false, remember: false });
560        assert!(allow_install_with(deps, "me@need-declined-host", Some("blk-need-1"), terminal).await.is_err());
561        assert_eq!(ASKED.load(Ordering::SeqCst), 0, "a terminal isn't asked again after Not now");
562        let _ = allow_install_with(deps, "me@need-declined-host", Some("blk-need-2"), Need::Files).await;
563        assert_eq!(ASKED.load(Ordering::SeqCst), 1, "Files' Reconnect asks again");
564
565        note_not_installed("me@need-failed-host");
566        let failed = allow_install_with(deps, "me@need-failed-host", Some("blk-need-3"), terminal).await;
567        assert_eq!(failed, Err("it didn't install on this host earlier".to_string()));
568        assert_eq!(ASKED.load(Ordering::SeqCst), 1);
569        let chosen = Need::Terminal { by_default: false };
570        let _ = allow_install_with(deps, "me@need-failed-host", Some("blk-need-4"), chosen).await;
571        assert_eq!(ASKED.load(Ordering::SeqCst), 2, "a terminal set to durable asks");
572    }
573
574    /// Install was answered but the upload failed: a terminal durable only by
575    /// default doesn't try again (Files may). And a question closed
576    /// unanswered is noted as a failed try before it closes, so no terminal
577    /// slips in between and asks again.
578    #[tokio::test]
579    async fn a_failed_or_unanswered_try_holds_for_default_terminals() {
580        use std::sync::atomic::{AtomicUsize, Ordering};
581        static ASKED: AtomicUsize = AtomicUsize::new(0);
582        static DEPS_NO_ANSWER: OnceLock<Deps> = OnceLock::new();
583        let deps = DEPS_NO_ANSWER.get_or_init(|| Deps {
584            ask: Box::new(|_, _| {
585                ASKED.fetch_add(1, Ordering::SeqCst);
586                Box::pin(async { Ok(Answer::default()) })
587            }),
588            settings: Box::new(|_| (String::new(), String::new())),
589            remember: Box::new(|_, _| Ok(())),
590        });
591        let terminal = Need::Terminal { by_default: true };
592        note_answer("me@approved-failed-host", Answer { answered: true, approve: true, remember: false });
593        note_not_installed("me@approved-failed-host");
594        let retry = allow_install_with(deps, "me@approved-failed-host", Some("blk-af-1"), terminal).await;
595        assert!(retry.is_err(), "a failed install outweighs the Install answer");
596        let files = allow_install_with(deps, "me@approved-failed-host", Some("blk-af-2"), Need::Files).await;
597        assert_eq!(files, Ok(()), "Files tries again on the Install answer");
598        assert_eq!(ASKED.load(Ordering::SeqCst), 0);
599
600        let unanswered = allow_install_with(deps, "me@unanswered-host", Some("blk-af-3"), terminal).await;
601        assert!(unanswered.is_err());
602        assert!(not_installed("me@unanswered-host"), "noted before the question closed");
603    }
604
605    /// The pane asking is closed mid-question: a pane waiting on its answer
606    /// asks instead, rather than taking the closed pane's "no answer".
607    #[tokio::test]
608    async fn a_waiting_pane_asks_when_the_asking_pane_closes() {
609        use std::sync::atomic::{AtomicUsize, Ordering};
610        static ASKED: AtomicUsize = AtomicUsize::new(0);
611        static DEPS_SECOND_ANSWERS: OnceLock<Deps> = OnceLock::new();
612        let deps = DEPS_SECOND_ANSWERS.get_or_init(|| Deps {
613            ask: Box::new(|_, _| {
614                if ASKED.fetch_add(1, Ordering::SeqCst) == 0 {
615                    Box::pin(std::future::pending())
616                } else {
617                    Box::pin(async { Ok(Answer { answered: true, approve: true, remember: false }) })
618                }
619            }),
620            settings: Box::new(|_| (String::new(), String::new())),
621            remember: Box::new(|_, _| Ok(())),
622        });
623        let asking = tokio::spawn(allow_install_with(deps, "me@handover-host", Some("blk-handover-1"), Need::Files));
624        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
625        let waiting = tokio::spawn(allow_install_with(deps, "me@handover-host", Some("blk-handover-2"), Need::Files));
626        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
627        asking.abort();
628        let waited = tokio::time::timeout(std::time::Duration::from_secs(5), waiting).await;
629        assert_eq!(waited.expect("the waiting pane gets an answer").unwrap(), Ok(()));
630        assert_eq!(ASKED.load(Ordering::SeqCst), 2, "asked again, by the pane still open");
631    }
632
633    /// The pane asking is closed mid-question: the question doesn't stay
634    /// open for the host, answering every later pane "no answer".
635    #[tokio::test]
636    async fn a_question_whose_pane_closed_is_not_left_open() {
637        static DEPS_NEVER_ANSWERS: OnceLock<Deps> = OnceLock::new();
638        let deps = DEPS_NEVER_ANSWERS.get_or_init(|| Deps {
639            ask: Box::new(|_, _| Box::pin(std::future::pending())),
640            settings: Box::new(|_| (String::new(), String::new())),
641            remember: Box::new(|_, _| Ok(())),
642        });
643        let asking = tokio::spawn(allow_install_with(deps, "me@closed-question-host", Some("blk-closed"), Need::Files));
644        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
645        assert!(open_questions().contains_key("me@closed-question-host"));
646        asking.abort();
647        let _ = asking.await;
648        assert!(!open_questions().contains_key("me@closed-question-host"));
649    }
650
651    #[test]
652    fn the_hosts_setting_wins_over_the_global_one() {
653        assert_eq!(Policy::resolve("", ""), Policy::Ask);
654        assert_eq!(Policy::resolve("", "Always"), Policy::Always);
655        assert_eq!(Policy::resolve("never", "always"), Policy::Never);
656        assert_eq!(Policy::resolve("ask", "never"), Policy::Ask);
657        assert_eq!(Policy::resolve("nonsense", "always"), Policy::Ask);
658    }
659
660    #[test]
661    fn an_agents_install_is_always_asked_about() {
662        assert_eq!(step(Policy::Always, false), Step::Install);
663        assert_eq!(step(Policy::Never, false), Step::Refuse);
664        assert_eq!(step(Policy::Ask, false), Step::Ask);
665        for p in [Policy::Ask, Policy::Always, Policy::Never] {
666            assert_eq!(step(p, true), Step::Ask, "{p:?}");
667        }
668    }
669
670    #[test]
671    fn the_four_answers() {
672        let a = |approve, remember| Answer { answered: true, approve, remember };
673        assert_eq!(outcome(a(true, false)), (true, None), "Install");
674        assert_eq!(outcome(a(true, true)), (true, Some("always")), "Always for this host");
675        assert_eq!(outcome(a(false, false)), (false, None), "Not now");
676        assert_eq!(outcome(a(false, true)), (false, Some("never")), "Never for this host");
677        let closed = Answer { answered: false, approve: false, remember: true };
678        assert_eq!(outcome(closed), (false, None), "a closed window stores nothing");
679    }
680
681    #[test]
682    fn a_host_the_user_doesnt_want_the_helper_on_gets_a_plain_terminal() {
683        assert!(plain_for(Policy::Never, false), "Never");
684        assert!(plain_for(Policy::Ask, true), "Not now, since srv started");
685        assert!(!plain_for(Policy::Ask, false), "not asked yet");
686        assert!(!plain_for(Policy::Always, true), "Always wins over an earlier Not now");
687    }
688
689    #[test]
690    fn not_now_is_remembered_until_an_install_and_a_closed_window_is_no_answer() {
691        let a = |answered, approve| Answer { answered, approve, remember: false };
692        let host = "me@declined-host";
693        note_answer(host, a(false, false));
694        assert!(!declined().contains(host), "a closed window");
695        note_answer(&format!(" {host} "), a(true, false));
696        assert!(declined().contains(host), "Not now, however the host is spelled");
697        note_answer(host, a(true, true));
698        assert!(!declined().contains(host), "a later Install");
699    }
700
701    #[test]
702    fn a_host_the_helper_cant_run_on_gets_a_plain_terminal_whatever_the_setting() {
703        assert!(!plain_instead("me@windows-host"));
704        note_unsupported(" me@windows-host ");
705        assert!(plain_instead("me@windows-host"));
706    }
707
708    #[test]
709    fn the_question_names_the_host_and_the_agent() {
710        let q = question("db1", Some("korp"));
711        assert_eq!(q["kind"], "consent");
712        assert!(q["title"].as_str().unwrap().contains("db1"));
713        assert!(q["message"].as_str().unwrap().starts_with("korp is using"));
714        assert_eq!(q["ok_label"], "Install");
715        assert!(!question("db1", None)["message"].as_str().unwrap().contains("is using"));
716    }
717
718    #[test]
719    fn a_pane_an_agent_opened_is_the_agents() {
720        note_agent_pane("blk-agent-opened", "korp");
721        assert_eq!(agent_of(Some("blk-agent-opened")).as_deref(), Some("korp"));
722        assert_eq!(agent_of(Some("blk-users-own")), None);
723        assert_eq!(agent_of(None), None);
724    }
725
726    #[test]
727    fn a_host_an_agent_is_opening_a_pane_on_asks() {
728        assert!(!agent_opening("me@opening-host"));
729        let first = begin_agent_open("me@opening-host");
730        let second = begin_agent_open(" me@opening-host ");
731        assert!(agent_opening("me@opening-host"), "however it is spelled");
732        assert!(!agent_opening("me@other-host"));
733        drop(first);
734        assert!(agent_opening("me@opening-host"), "one still in flight");
735        drop(second);
736        assert!(!agent_opening("me@opening-host"));
737    }
738
739    /// In a set of its own: filling the shared one evicted what the test
740    /// above had just noted, when the two ran at once.
741    #[test]
742    fn the_agent_panes_kept_are_bounded() {
743        let mut panes = AgentPanes::default();
744        for i in 0..MAX_AGENT_PANES + 10 {
745            panes.note(&format!("blk-bound-{i}"), "korp");
746        }
747        assert!(panes.by_block.len() <= MAX_AGENT_PANES);
748        assert_eq!(panes.by_block.len(), panes.order.len());
749        assert!(!panes.by_block.contains_key("blk-bound-0"), "the oldest went");
750    }
751}