Skip to main content

agentmux_srv/backend/blockcontroller/
durable_mode.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Whether a shell pane runs durable (SPEC_REMOTE_TERMINALS_AND_DURABLE_SESSIONS_2026_10_02.md
5//! §7.1): plain, durable because a setting says so, or durable by default.
6
7use crate::backend::obj::{self, MetaMapType};
8
9use super::durable_ssh::META_KEY_DURABLE;
10
11/// How a shell pane runs (spec §7.1).
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Mode {
14    Plain,
15    /// Durable because the pane, its connection or Settings say so: if the
16    /// helper can't be installed (the question closed, the install failed)
17    /// the pane stops and says why. The user declining the helper (Not now,
18    /// Never) still makes it plain: that answer about software on the host
19    /// wins over a setting about all SSH terminals (#4429).
20    Chosen,
21    /// Durable by default (nothing set): on a host without the helper,
22    /// installing it asks first (`helper_consent`), and if it isn't installed
23    /// the pane becomes a plain SSH terminal rather than stopping.
24    Default,
25}
26
27/// Whether a shell pane runs durable ([`mode`]).
28pub fn wants(meta: &MetaMapType, config: Option<&crate::backend::wconfig::FullConfigType>) -> bool {
29    mode(meta, config) != Mode::Plain
30}
31
32/// How a shell pane runs. Durable only on an SSH connection: the pane's own
33/// `term:durable` first, then its connection's settings, then the global
34/// setting; with none of them set, durable by default, unless the helper
35/// failed to get onto the host since srv started.
36pub fn mode(meta: &MetaMapType, config: Option<&crate::backend::wconfig::FullConfigType>) -> Mode {
37    let conn = obj::meta_get_string(meta, super::META_KEY_CONNECTION, "");
38    if !matches!(
39        crate::backend::remote::ConnTarget::parse(&conn),
40        Ok(crate::backend::remote::ConnTarget::Ssh(_))
41    ) {
42        return Mode::Plain;
43    }
44    // An agent's SSH shell (`PtyShell`, marked by http_pty_shell.rs) is never
45    // durable, whatever the defaults: its ssh's prompts must reach the user as
46    // the agent's (its own askpass grant), and the agent tools do not reattach
47    // a session yet (the `Shell` tool's `durable`, spec §8.1, still to come).
48    if !obj::meta_get_string(
49        meta,
50        crate::backend::remote::askpass::META_KEY_AGENT_BLOCK,
51        "",
52    )
53    .is_empty()
54    {
55        return Mode::Plain;
56    }
57    // The user doesn't want the helper on this host (Never, or "Not now"
58    // since srv started) and it isn't there: a plain SSH terminal
59    // (SPEC_REMOTES_PANE_2026_10_05.md §4.9), whatever `term:durable` says,
60    // since that answer is the more specific one ([`Mode::Chosen`]).
61    if crate::backend::remote::helper_consent::plain_instead(&conn)
62        && !crate::backend::remote::helper_hosts::known(&conn)
63    {
64        return Mode::Plain;
65    }
66    // A host found to be one the helper can't run on (Windows) is plain even
67    // when a stale record says it has the helper and couldn't be forgotten.
68    if crate::backend::remote::helper_consent::unsupported_host(&conn) {
69        return Mode::Plain;
70    }
71    let conn_setting = config.and_then(|c| {
72        c.connections
73            .iter()
74            .find(|(name, _)| crate::backend::remote::conn::same_connection(name, &conn))
75            .and_then(|(_, k)| k.term_durable)
76    });
77    resolve_durable(
78        meta.get(META_KEY_DURABLE).and_then(|v| v.as_bool()),
79        conn_setting,
80        config.and_then(|c| c.settings.term_durable),
81        || crate::backend::remote::helper_hosts::known(&conn),
82        || crate::backend::remote::helper_consent::not_installed(&conn),
83    )
84}
85
86/// §7.1's order: pane, connection, global, then whether the host has the
87/// helper, or failed to get it since srv started (each asked only when
88/// nothing is set).
89pub fn resolve_durable(
90    pane: Option<bool>,
91    connection: Option<bool>,
92    global: Option<bool>,
93    host_has_helper: impl FnOnce() -> bool,
94    helper_not_installed: impl FnOnce() -> bool,
95) -> Mode {
96    match pane.or(connection).or(global) {
97        Some(true) => Mode::Chosen,
98        Some(false) => Mode::Plain,
99        None if host_has_helper() => Mode::Default,
100        None if helper_not_installed() => Mode::Plain,
101        None => Mode::Default,
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    /// "Not now" to the helper makes the host's durable panes plain SSH
110    /// terminals until srv restarts; an Install puts them back
111    /// (SPEC_REMOTES_PANE_2026_10_05.md §4.9).
112    #[test]
113    fn a_host_the_user_declined_the_helper_on_is_not_durable() {
114        use crate::backend::remote::helper_consent::{install_answering_yes, note_answer, Answer};
115        install_answering_yes();
116        let mut meta = MetaMapType::new();
117        meta.insert(super::super::META_KEY_CONNECTION.into(), serde_json::json!("declined-durable-host"));
118        meta.insert(META_KEY_DURABLE.into(), serde_json::json!(true));
119        assert!(wants(&meta, None));
120
121        let answer = |approve| Answer { answered: true, approve, remember: false };
122        note_answer("declined-durable-host", answer(false));
123        assert!(!wants(&meta, None), "Not now: a plain SSH terminal");
124        note_answer("declined-durable-host", answer(true));
125        assert!(wants(&meta, None), "Install: durable again");
126    }
127
128    #[test]
129    fn only_an_ssh_pane_with_durable_on_is_durable() {
130        let meta = |conn: &str, durable: Option<bool>| {
131            let mut m = MetaMapType::new();
132            m.insert(
133                super::super::META_KEY_CONNECTION.into(),
134                serde_json::json!(conn),
135            );
136            if let Some(d) = durable {
137                m.insert(META_KEY_DURABLE.into(), serde_json::json!(d));
138            }
139            m
140        };
141        assert!(wants(&meta("area54", Some(true)), None));
142        assert!(!wants(&meta("area54", Some(false)), None));
143        assert!(!wants(&meta("local", Some(true)), None));
144        assert!(!wants(&meta("wsl://Ubuntu", Some(true)), None));
145
146        // The connection's setting, then the global one, under the pane's.
147        let mut config = crate::backend::wconfig::FullConfigType::default();
148        config.settings.term_durable = Some(false);
149        config.connections.insert(
150            "durable-test-host".into(),
151            crate::backend::wconfig::ConnKeywords {
152                term_durable: Some(true),
153                ..Default::default()
154            },
155        );
156        assert!(wants(&meta("durable-test-host", None), Some(&config)));
157        assert!(!wants(
158            &meta("durable-test-host", Some(false)),
159            Some(&config)
160        ));
161        assert!(!wants(&meta("durable-test-other", None), Some(&config)));
162
163        // An agent's SSH shell: never, not even when asked or by default.
164        let mut agent = meta("durable-test-host", Some(true));
165        agent.insert(
166            crate::backend::remote::askpass::META_KEY_AGENT_BLOCK.into(),
167            serde_json::json!("agent-block"),
168        );
169        assert!(!wants(&agent, Some(&config)));
170    }
171
172    #[test]
173    fn durable_is_the_pane_then_the_connection_then_global_then_the_helper() {
174        let never = || panic!("asked the host only when nothing is set");
175        assert_eq!(resolve_durable(Some(true), Some(false), Some(false), never, never), Mode::Chosen);
176        assert_eq!(resolve_durable(None, Some(false), Some(true), never, never), Mode::Plain);
177        assert_eq!(resolve_durable(None, None, Some(true), never, never), Mode::Chosen);
178        assert_eq!(resolve_durable(None, None, None, || true, never), Mode::Default);
179        // Nothing set and no helper yet: durable is offered (finding 7 of
180        // #4513), unless the helper couldn't be put there since srv started.
181        assert_eq!(resolve_durable(None, None, None, || false, || false), Mode::Default);
182        assert_eq!(resolve_durable(None, None, None, || false, || true), Mode::Plain);
183    }
184
185    #[test]
186    fn a_new_hosts_terminal_offers_durable_by_default() {
187        let mut meta = MetaMapType::new();
188        meta.insert(super::super::META_KEY_CONNECTION.into(), serde_json::json!("me@offered-host"));
189        assert_eq!(mode(&meta, None), Mode::Default);
190        assert!(wants(&meta, None));
191    }
192}