Skip to main content

agentmux_srv/backend/blockcontroller/shell/
ssh_pane.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! How a plain SSH pane ends (remote terminals spec ยง5.2; #4513).
5//!
6//! A pane whose `ssh` had no connection, or lost it, is not a shell the user
7//! ended: it stays, with a line under ssh's own reason, instead of closing as
8//! an `exit` does; Enter starts ssh again (a finished pane's restart). srv
9//! stopping the pane itself (close, restart) makes ssh exit 255 too, and is
10//! not a lost connection.
11
12use std::path::Path;
13use std::sync::Arc;
14
15use portable_pty::CommandBuilder;
16
17use super::file_ops::handle_append_block_file;
18use crate::backend::mps::Broker;
19use crate::backend::remote::conn::SshDest;
20use crate::backend::remote::{askpass, link, ssh};
21use crate::backend::storage::filestore::FileStore;
22
23/// Pane `block_id`'s `ssh` (`ssh_path`) to `dest`, running `cmd`
24/// (`cmd_args`) in `cwd` there, sharing the channel's connection to it.
25/// `askpass_secret`: an agent's SSH shell, whose every ssh prompt goes to the
26/// user through the askpass bridge, never into this terminal, which the agent
27/// reads.
28pub(super) fn command(
29    ssh_path: &Path,
30    dest: &SshDest,
31    (cmd, cmd_args, cwd): (&str, &[String], &str),
32    askpass_secret: Option<&str>,
33    auth_key: &str,
34    block_id: &str,
35) -> CommandBuilder {
36    let mut args = ssh::launch(dest, cmd, cmd_args, cwd, ssh::shared_control_dir().as_deref());
37    ssh::set_pane_env(&mut args, block_id);
38    tracing::info!(dest = %dest.destination, args = ?args, "ssh spawn path");
39    let mut c = CommandBuilder::new(ssh_path);
40    c.args(args.iter().map(String::as_str));
41    c.env("TERM", "xterm-256color");
42    c.env("COLORTERM", "truecolor");
43    // Here as well as on every pane's command in lifecycle.rs: it is
44    // idempotent, and askpass's variables are on its keep list.
45    crate::backend::pane_env::sanitize_pty_command(&mut c);
46    if let (Some(secret), Some(program)) = (askpass_secret, askpass::program()) {
47        let local_url = std::env::var("AGENTMUX_LOCAL_URL").unwrap_or_default();
48        for (k, v) in askpass::ssh_env(secret, &program, &local_url, auth_key) {
49            c.env(k, v);
50        }
51    }
52    c
53}
54
55/// An SSH pane's `ssh` (to `dest`, on connection `conn`) ended with
56/// `raw_exit_code`. Tells the connection, and returns the line the pane shows
57/// when it stays because the connection is gone. `stopped_by_srv`: srv ended
58/// it itself (`stop()` and `stop_for_replace()` set the status before they
59/// signal the process).
60pub(super) fn ended(conn: &str, dest: &str, raw_exit_code: Option<u32>, stopped_by_srv: bool) -> Option<String> {
61    let lost = ssh::pane_lost_connection(raw_exit_code, stopped_by_srv);
62    let note = lost.then(|| {
63        if link::held(conn) {
64            link::disconnected_note(conn)
65        } else {
66            ssh::lost_note(conn)
67        }
68    });
69    link::pane_ended(conn, lost.then(|| ssh::lost_message(dest)).as_deref());
70    note
71}
72
73/// Put `text` in pane `block_id`'s output (`pane_note`), after ssh's own last
74/// words: the caller has let the output drain first.
75pub(super) async fn write_note(broker: Arc<Broker>, filestore: Option<Arc<FileStore>>, block_id: String, text: String) {
76    let _ = tokio::task::spawn_blocking(move || {
77        let note = super::super::pane_note(&text);
78        handle_append_block_file(&broker, &block_id, "term", &note, filestore.as_ref(), None)
79    })
80    .await;
81}