Skip to main content

agentmux_srv/backend/remote/
sessions.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The durable sessions on an SSH host (spec §7.6): what the helper's daemon
5//! holds there, which pane (if any) each belongs to, and ending one. Behind
6//! Connectors → Remotes' "Sessions…".
7
8use std::time::Duration;
9
10use serde::Serialize;
11
12use super::host::HostSsh;
13use crate::backend::blockcontroller::durable_ssh::{
14    helper_path, valid_session_id, META_KEY_SESSION_ID,
15};
16use crate::backend::obj::{self, Block};
17use crate::backend::storage::store::Store;
18
19/// `ssh` exits 127 when the remote shell cannot find the command: no helper.
20const EXIT_COMMAND_NOT_FOUND: i32 = 127;
21
22/// How long listing or ending may take. ssh may first ask the user something
23/// (askpass: a password, a passphrase, a new host key), and its dialog waits
24/// up to two minutes (`connections::ask_user`), so this covers that plus the
25/// command itself.
26const LIMIT: Duration = Duration::from_secs(150);
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, ts_rs::TS)]
29#[ts(export, export_to = "../../../frontend/types/rpc/")]
30pub struct HostSession {
31    /// The session id (`remote:session_id` of the pane that made it).
32    pub id: String,
33    /// How much output the session has produced, in bytes.
34    #[ts(type = "number")]
35    pub bytes: u64,
36    /// The shell's exit code, once it has exited (the daemon keeps an exited
37    /// session for a few minutes so a reattach still sees how it ended).
38    #[serde(skip_serializing_if = "Option::is_none")]
39    #[ts(optional)]
40    pub exited: Option<i32>,
41    /// The pane on this connection holding the session; none for an orphan.
42    #[serde(skip_serializing_if = "Option::is_none")]
43    #[ts(optional)]
44    pub blockid: Option<String>,
45}
46
47/// The helper's `list`: one `id end exited` line per session, `exited` a code
48/// or `-`. Lines that are not that are skipped, never guessed at.
49pub fn parse_list(text: &str) -> Vec<HostSession> {
50    text.lines()
51        .filter_map(|line| {
52            let mut words = line.split_whitespace();
53            let (id, end, exited) = (words.next()?, words.next()?, words.next()?);
54            if words.next().is_some() || !valid_session_id(id) {
55                return None;
56            }
57            let exited = match exited {
58                "-" => None,
59                code => Some(code.parse().ok()?),
60            };
61            Some(HostSession {
62                id: id.to_string(),
63                bytes: end.parse().ok()?,
64                exited,
65                blockid: None,
66            })
67        })
68        .collect()
69}
70
71/// Mark each session with the pane on `conn` that holds it.
72pub fn attribute(sessions: &mut [HostSession], blocks: &[Block], conn: &str) {
73    for s in sessions.iter_mut() {
74        s.blockid = blocks
75            .iter()
76            .find(|b| {
77                obj::meta_get_string(&b.meta, META_KEY_SESSION_ID, "") == s.id
78                    && super::conn::same_connection(
79                        &obj::meta_get_string(
80                            &b.meta,
81                            crate::backend::blockcontroller::META_KEY_CONNECTION,
82                            "",
83                        ),
84                        conn,
85                    )
86            })
87            .map(|b| b.oid.clone());
88    }
89}
90
91/// Where ssh's prompts go: the user's pane `block_id` (its window), with
92/// srv's auth key for the askpass helper.
93#[derive(Debug, Clone, Copy)]
94pub struct AskIn<'a> {
95    pub block_id: &'a str,
96    pub auth_key: &'a str,
97}
98
99fn host_for(
100    conn: &str,
101    ask: Option<AskIn<'_>>,
102) -> Result<(HostSsh, Option<super::askpass::Revoke>), String> {
103    let mut host = HostSsh::for_connection(conn)?;
104    let grant = ask.and_then(|a| host.ask_user_in(a.block_id, conn, a.auth_key));
105    Ok((host, grant))
106}
107
108/// The sessions on `conn`'s host, each with its pane. A host without this
109/// version's helper has none.
110pub async fn list(
111    store: &Store,
112    conn: &str,
113    ask: Option<AskIn<'_>>,
114) -> Result<Vec<HostSession>, String> {
115    let (host, _grant) = host_for(conn, ask)?;
116    let out = host
117        .run(&format!("{} list", helper_path()), None, LIMIT)
118        .await?;
119    if out.code == Some(EXIT_COMMAND_NOT_FOUND) {
120        return Ok(Vec::new());
121    }
122    let mut sessions = parse_list(&out.ok()?);
123    let blocks = store.get_all::<Block>().map_err(|e| e.to_string())?;
124    attribute(&mut sessions, &blocks, conn);
125    Ok(sessions)
126}
127
128/// End session `id` on `conn`'s host: its shell and everything it started.
129/// `false` if the host had no such session.
130pub async fn end(conn: &str, id: &str, ask: Option<AskIn<'_>>) -> Result<bool, String> {
131    if !valid_session_id(id) {
132        return Err(format!("{id:?} is not a session id"));
133    }
134    let (host, _grant) = host_for(conn, ask)?;
135    let out = host
136        .run(
137            &format!("{} end --session {id}", helper_path()),
138            None,
139            LIMIT,
140        )
141        .await?;
142    if out.code == Some(EXIT_COMMAND_NOT_FOUND) {
143        return Ok(false);
144    }
145    Ok(out.ok()?.trim() == "ok")
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn the_helper_list_is_read_line_by_line() {
154        let got =
155            parse_list("amx-1 1024 -\namx-2 7 0\n\ngarbage\namx-3 x -\na;b 1 -\namx-4 1 - extra\n");
156        assert_eq!(
157            got,
158            vec![
159                HostSession {
160                    id: "amx-1".into(),
161                    bytes: 1024,
162                    exited: None,
163                    blockid: None
164                },
165                HostSession {
166                    id: "amx-2".into(),
167                    bytes: 7,
168                    exited: Some(0),
169                    blockid: None
170                },
171            ]
172        );
173    }
174
175    #[test]
176    fn a_session_belongs_to_the_pane_on_its_connection() {
177        let block = |oid: &str, conn: &str, sid: &str| {
178            let mut b = Block {
179                oid: oid.into(),
180                ..Default::default()
181            };
182            b.meta.insert("connection".into(), serde_json::json!(conn));
183            b.meta
184                .insert(META_KEY_SESSION_ID.into(), serde_json::json!(sid));
185            b
186        };
187        let blocks = vec![
188            block("b-other-host", "other", "amx-1"),
189            block("b-here", "user@box", "amx-1"),
190            block("b-none", "user@box", "amx-9"),
191        ];
192        let mut sessions = parse_list("amx-1 5 -\namx-2 5 -\n");
193        attribute(&mut sessions, &blocks, "user@box");
194        assert_eq!(sessions[0].blockid.as_deref(), Some("b-here"));
195        // An orphan: no pane holds it.
196        assert_eq!(sessions[1].blockid, None);
197    }
198}