Skip to main content

agentmux_srv/server/app_api/
connections.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The connection RPCs the frontend's connection UI already calls
5//! (`conntypeahead.tsx`, `blockframe.tsx`'s `ConnStatusOverlay`), which had no
6//! handler until now (P0 of SPEC_REMOTE_TERMINALS_AND_DURABLE_SESSIONS_2026_10_02.md).
7//!
8//! A connection AgentMux cannot run is refused with a status the pane shows,
9//! instead of the pane silently starting a local shell under a remote name:
10//! WSL off Windows or for a distro that is not installed (P1), and SSH where
11//! there is no `ssh` (P2).
12
13use super::*;
14use crate::backend::mps::Broker;
15use crate::backend::remote::remotes;
16use crate::backend::remote::sessions;
17use crate::backend::remote::status::{self, state};
18use crate::backend::remote::ConnTarget;
19
20pub fn register(engine: &Arc<WshRpcEngine>, state: &AppState) {
21    // Lists. One call each, with the constant spelled out: the RPC contract
22    // test resolves every registered command name statically.
23    //
24    // The picker's "Remote" section: the user's ssh config hosts and any other
25    // SSH connection used this session. The typeahead adds `local` and the
26    // WSL distros itself.
27    //
28    // The requests are `serde_json::Value`: these handlers read what they need
29    // loosely (a missing field is ""), and `Value` takes anything, so typing them
30    // only records the response (docs/reports/REPORT_MIGRATION_WRAPUP_STATUS_2026_09_16.md
31    // §5.4c). The frontend stubs keep their precise request types
32    // (generated-manifest.json `dataType`).
33    engine.register_typed(COMMAND_CONN_LIST, |_data: serde_json::Value, _ctx| async move {
34        let hosts = tokio::task::spawn_blocking(crate::backend::remote::ssh_config::hosts)
35            .await
36            .unwrap_or_default();
37        Ok(remote_names(&hosts, &status::all()))
38    });
39    engine.register_typed(COMMAND_CONN_LIST_AWS, |_data: serde_json::Value, _ctx| async move {
40        Ok(Vec::<String>::new())
41    });
42    // Installed WSL distributions (empty off Windows, or with no WSL).
43    engine.register_typed(COMMAND_WSL_LIST, |_data: serde_json::Value, _ctx| async move {
44        Ok(crate::backend::remote::wsl::list().await)
45    });
46
47    let broker = state.broker.clone();
48    engine.register_typed(COMMAND_CONN_ENSURE, move |data: serde_json::Value, _ctx| {
49        let broker = broker.clone();
50        // `{ connname, logblockid? }`
51        async move { ensure(&broker, &str_field(&data, "connname")).await }
52    });
53
54    let app = state.clone();
55    engine.register_typed(COMMAND_CONN_CONNECT, move |data: serde_json::Value, _ctx| {
56        let app = app.clone();
57        // `{ host, keywords?, logblockid? }`
58        async move { connect(&app, &str_field(&data, "host"), &str_field(&data, "logblockid")).await }
59    });
60
61    let broker = state.broker.clone();
62    engine.register_typed(COMMAND_CONN_DISCONNECT, move |data: serde_json::Value, _ctx| {
63        let broker = broker.clone();
64        async move {
65                // The connection name, as a bare string.
66                let name = data.as_str().unwrap_or_default().to_string();
67                match ConnTarget::parse(&name) {
68                    // Closes the shared connection: the sessions on it end,
69                    // and the next pane logs in again.
70                    Ok(ConnTarget::Ssh(_)) => crate::backend::remote::link::disconnect(&name).await,
71                    Ok(ConnTarget::Wsl(_)) => {
72                        status::set(Some(&broker), &name, state::DISCONNECTED, None);
73                    }
74                    _ => {}
75                }
76                Ok(())
77        }
78    });
79
80    // A host's durable sessions, for Connectors → Remotes' "Sessions…"
81    // (spec §7.6). Listing runs one fixed, read-only command over ssh, as the
82    // UI's other connection calls do. Ending one is the user's decision,
83    // made in the host's own window (`ask_user`): anything holding srv's
84    // auth key can send this RPC, an agent included, but none can answer
85    // that window.
86    let (store, auth_key) = (state.mstore.clone(), state.auth_key.clone());
87    engine.register_typed(COMMAND_CONN_SESSIONS, move |data: serde_json::Value, _ctx| {
88        let (store, auth_key) = (store.clone(), auth_key.clone());
89        async move {
90                let (conn, block) = (str_field(&data, "connname"), str_field(&data, "blockid"));
91                let ask = (!block.is_empty()).then_some(sessions::AskIn {
92                    block_id: &block,
93                    auth_key: &auth_key,
94                });
95                let list = sessions::list(&store, &conn, ask).await?;
96                // Connectors → Remotes shows the count without opening ssh itself.
97                remotes::note_sessions(&conn, list.len());
98                Ok(list)
99        }
100    });
101    let app = state.clone();
102    engine.register_typed(COMMAND_CONN_SESSION_END, move |data: serde_json::Value, _ctx| {
103        let app = app.clone();
104        async move {
105                let (conn, id, block) = (
106                    str_field(&data, "connname"),
107                    str_field(&data, "sessionid"),
108                    str_field(&data, "blockid"),
109                );
110                if block.is_empty() {
111                    return Err("ending a session needs the pane asking, to ask the user in".into());
112                }
113                if !crate::backend::blockcontroller::durable_ssh::valid_session_id(&id) {
114                    return Err(format!("{:?} is not a session id", one_line(&id, 80)));
115                }
116                confirm_session_end(&app, &block, &conn, &id).await?;
117                let ask = sessions::AskIn {
118                    block_id: &block,
119                    auth_key: &app.auth_key,
120                };
121                let ended = sessions::end(&conn, &id, Some(ask)).await?;
122                Ok(ended)
123        }
124    });
125
126    // Connectors → Remotes (docs/specs/SPEC_REMOTES_PANE_2026_10_05.md §4.6): its list,
127    // a remote's settings, and forgetting a recent one.
128    let config = state.config_watcher.clone();
129    engine.register_typed(COMMAND_REMOTES_LIST, move |_cmd: CommandRemotesListData, _ctx| {
130        let config = config.clone();
131        async move { Ok(remotes::list(config.get_full_config().connections.clone()).await) }
132    });
133    let (config, event_bus, broker) = (state.config_watcher.clone(), state.event_bus.clone(), state.broker.clone());
134    engine.register_typed(COMMAND_REMOTE_SET_CONFIG, move |cmd: CommandRemoteSetConfigData, _ctx| {
135        let (config, event_bus, broker) = (config.clone(), event_bus.clone(), broker.clone());
136        async move {
137            let name = remote_name(&cmd.connection)?;
138            crate::backend::config_watcher_fs::set_connection_values(&config, &event_bus, &name, &cmd.values)?;
139            remotes::publish_change(&broker);
140            Ok(())
141        }
142    });
143    let broker = state.broker.clone();
144    engine.register_typed(COMMAND_REMOTE_FORGET, move |cmd: CommandRemoteForgetData, _ctx| {
145        let broker = broker.clone();
146        async move {
147            let name = remote_name(&cmd.connection)?;
148            if remotes::forget_recent(&name).map_err(|e| format!("could not forget {name}: {e}"))? {
149                remotes::publish_change(&broker);
150            }
151            Ok(())
152        }
153    });
154    // Removing the helper ends the host's sessions, so it is the user's
155    // decision, made in the approval window, as ending one session is.
156    let app = state.clone();
157    engine.register_typed(COMMAND_REMOTE_HELPER_REMOVE, move |cmd: CommandRemoteHelperRemoveData, _ctx| {
158        let app = app.clone();
159        async move { remove_helper(&app, &cmd.connection, &cmd.blockid).await }
160    });
161    // Adding a host writes the user's ssh config, which every ssh on the
162    // machine reads (git's included), so it is the user's decision, made in
163    // the approval window: a new alias could otherwise send another tool's
164    // ssh somewhere else.
165    let app = state.clone();
166    engine.register_typed(COMMAND_REMOTE_ADD, move |cmd: CommandRemoteAddData, _ctx| {
167        let app = app.clone();
168        async move { add_remote(&app, cmd).await }
169    });
170    // Testing logs in with the user's keys, so only to a host AgentMux
171    // already lists (the user's own ssh config, settings or a connection
172    // they made), never one an RPC names.
173    let app = state.clone();
174    engine.register_typed(COMMAND_REMOTE_TEST, move |cmd: CommandRemoteTestData, _ctx| {
175        let app = app.clone();
176        async move { test_remote(&app, &cmd.connection, &cmd.blockid).await }
177    });
178    // Revoking only takes access away (the agent is asked again on its next
179    // use of the host), so it needs no question.
180    let broker = state.broker.clone();
181    engine.register_typed(COMMAND_REMOTE_AGENT_REVOKE, move |cmd: CommandRemoteAgentRevokeData, _ctx| {
182        let broker = broker.clone();
183        async move {
184            let name = ssh_host(&cmd.connection, NOT_SSH)?;
185            let revoked = crate::backend::remote::agent_access::revoke(&cmd.agent, &name)
186                .map_err(|e| format!("could not revoke {}'s access to {name}: {e}", one_line(&cmd.agent, 60)))?;
187            if revoked {
188                tracing::info!(agent = %one_line(&cmd.agent, 60), connection = %name, "agent ssh access: revoked");
189                remotes::publish_change(&broker);
190            }
191            Ok(())
192        }
193    });
194    engine.register_typed(COMMAND_REMOTE_SSH_LOCATE, move |cmd: CommandRemoteSshLocateData, _ctx| async move {
195        let found = tokio::task::spawn_blocking(move || {
196            let (config, ssh_dir) = crate::backend::remote::ssh_config_edit::user_paths()?;
197            crate::backend::remote::ssh_config_edit::locate_in(&config, &ssh_dir, &cmd.connection)
198        })
199        .await
200        .map_err(|e| e.to_string())?;
201        Ok(found.map(|(path, line)| RemoteSshLocation {
202            path: path.to_string_lossy().into_owned(),
203            line: line as u32,
204        }))
205    });
206}
207
208/// Add a host to the user's ssh config (§4.4 of SPEC_REMOTES_PANE_2026_10_05.md),
209/// once they confirm the exact block in the window of pane `cmd.blockid`.
210async fn add_remote(state: &AppState, cmd: CommandRemoteAddData) -> Result<(), String> {
211    use crate::backend::remote::ssh_config_edit::{self, NewHost};
212    if cmd.blockid.is_empty() {
213        return Err("adding a remote needs the pane asking, to ask the user in".into());
214    }
215    let host = NewHost {
216        alias: cmd.alias,
217        hostname: cmd.hostname,
218        user: cmd.user,
219        port: cmd.port,
220        identity_file: cmd.identityfile,
221        proxy_jump: cmd.proxyjump,
222    };
223    let block = ssh_config_edit::host_block(&host)?;
224    let (config, ssh_dir) =
225        ssh_config_edit::user_paths().ok_or_else(|| "there is no home folder for an ssh config".to_string())?;
226    let question = serde_json::json!({
227        "kind": "consent",
228        "title": format!("Add {} to your ssh config", one_line(&host.alias, 80)),
229        "message": format!(
230            "Add this to the end of {}?\n\n{}\nEvery ssh on this computer will use it.",
231            config.display(),
232            block
233        ),
234        "ok_label": "Add",
235        "cancel_label": "Cancel",
236    });
237    let answer = ask_user(state, &cmd.blockid, question).await?;
238    if !(answer.answered && answer.approve) {
239        return Err("kept: the user chose not to add it".into());
240    }
241    tokio::task::spawn_blocking(move || ssh_config_edit::add_host_in(&config, &ssh_dir, &host))
242        .await
243        .map_err(|e| e.to_string())??;
244    remotes::publish_change(&state.broker);
245    Ok(())
246}
247
248const NOT_SSH: &str = "is not an SSH host";
249
250/// `connection`'s canonical name if it is an SSH host; otherwise
251/// "<connection> `otherwise`".
252fn ssh_host(connection: &str, otherwise: &str) -> Result<String, String> {
253    match ConnTarget::parse(connection)? {
254        t @ ConnTarget::Ssh(_) => Ok(t.name()),
255        _ => Err(format!("{} {otherwise}", one_line(connection, 80))),
256    }
257}
258
259/// [`ssh_host`] for a host AgentMux already lists (the user's ssh config,
260/// settings, or a connection they made), for what logs in with the user's
261/// keys: never to one an RPC names, since anything holding srv's key can send
262/// one, an agent included.
263async fn known_ssh_host(state: &AppState, connection: &str) -> Result<String, String> {
264    let name = ssh_host(connection, NOT_SSH)?;
265    let known = remotes::list(state.config_watcher.get_full_config().connections.clone())
266        .await
267        .iter()
268        .any(|r| r.kind == "ssh" && r.name == name);
269    if !known {
270        return Err(format!("{} isn't one of your remotes", one_line(&name, 80)));
271    }
272    Ok(name)
273}
274
275/// Log in to SSH host `name` (a [`known_ssh_host`]) and run `true`, with
276/// ssh's prompts going to the user in the window of pane `block`, if any.
277/// The connection's status follows (`link::login`), and the connection stays
278/// up a while after (`ControlPersist`), so the next pane on it asks nothing.
279async fn log_in(state: &AppState, name: &str, block: &str) -> Result<(), String> {
280    use crate::backend::remote::{host::HostSsh, link};
281    let mut host = HostSsh::for_connection(name)?;
282    let _grant = (!block.is_empty()).then(|| host.ask_user_in(block, name, &state.auth_key)).flatten();
283    link::login(name, async {
284        host.run("true", None, SSH_WITH_PROMPTS).await.and_then(|o| o.ok()).map(|_| ())
285    })
286    .await
287}
288
289/// What `connconnect` does (a Remotes row's Connect, a pane's Reconnect): log
290/// in to an SSH host; for anything else, what `connensure` does.
291async fn connect(state: &AppState, connection: &str, block: &str) -> Result<(), String> {
292    ensure(&state.broker, connection).await?;
293    if !matches!(ConnTarget::parse(connection)?, ConnTarget::Ssh(_)) {
294        return Ok(());
295    }
296    let name = known_ssh_host(state, connection).await?;
297    log_in(state, &name, block).await
298}
299
300/// Log in to `connection` and run `true` (§4.4): success, or ssh's message.
301async fn test_remote(state: &AppState, connection: &str, block: &str) -> Result<RemoteTestResult, String> {
302    let name = known_ssh_host(state, connection).await?;
303    Ok(match log_in(state, &name, block).await {
304        Ok(()) => RemoteTestResult { ok: true, message: String::new() },
305        Err(message) => RemoteTestResult { ok: false, message },
306    })
307}
308
309/// Remove AgentMux's helper from SSH host `connection` (Remotes, §4.9 of
310/// SPEC_REMOTES_PANE_2026_10_05.md), once the user confirms in the window of
311/// pane `block`, naming the sessions that will end.
312async fn remove_helper(state: &AppState, connection: &str, block: &str) -> Result<(), String> {
313    use crate::backend::remote::{files, helper_hosts, helper_install, host::HostSsh};
314    let name = ssh_host(connection, "has no AgentMux helper to remove")?;
315    if block.is_empty() {
316        return Err("removing the helper needs the pane asking, to ask the user in".into());
317    }
318    let ask = sessions::AskIn { block_id: block, auth_key: &state.auth_key };
319    let running = sessions::list(&state.mstore, &name, Some(ask)).await?;
320    let ends = match running.len() {
321        0 => "No sessions are running there.".to_string(),
322        1 => "The 1 session running there ends, with everything running in it.".to_string(),
323        n => format!("The {n} sessions running there end, with everything running in them."),
324    };
325    let question = serde_json::json!({
326        "kind": "consent",
327        "title": format!("Remove AgentMux's helper from {}", one_line(&name, 80)),
328        "message": format!(
329            "Remove AgentMux's helper from {}?\n\n{ends} It deletes ~/.agentmux-remote there; file browsing and terminals that survive disconnects need it installed again.",
330            one_line(&name, 80)
331        ),
332        "ok_label": "Remove",
333        "cancel_label": "Keep It",
334        // The window draws a destructive answer in the danger tone, not the
335        // loud attention fill.
336        "destructive": true,
337    });
338    let answer = ask_user(state, block, question).await?;
339    if !(answer.answered && answer.approve) {
340        return Err("kept: the user chose not to remove it".into());
341    }
342    let mut host = HostSsh::for_connection(&name)?;
343    let _grant = host.ask_user_in(block, &name, &state.auth_key);
344    let out = host
345        .run(&helper_install::remove_command(), None, SSH_WITH_PROMPTS)
346        .await?
347        .ok()?;
348    if out.trim() != "ok" {
349        return Err(format!("removing the helper from {name} did not finish ({})", one_line(&out, 200)));
350    }
351    files::close(&name).await;
352    if let Err(e) = helper_hosts::forget_helper(&name) {
353        tracing::warn!(connection = %name, error = %e, "could not forget the removed helper");
354    }
355    // Its next need asks again (spec §4.9), whatever was answered before.
356    crate::backend::remote::helper_consent::forget_answers(&name);
357    remotes::note_sessions(&name, 0);
358    remotes::publish_change(&state.broker);
359    tracing::info!(connection = %name, sessions = running.len(), "removed the helper");
360    Ok(())
361}
362
363/// Give the helper install its question (SPEC_REMOTES_PANE_2026_10_05.md
364/// §4.9): the approval window, and `conn:helper` from the settings.
365pub fn install_helper_consent(state: &AppState) {
366    use crate::backend::remote::helper_consent;
367    let app = state.clone();
368    let config = state.config_watcher.clone();
369    let (writer, event_bus, broker) = (state.config_watcher.clone(), state.event_bus.clone(), state.broker.clone());
370    helper_consent::install(helper_consent::Deps {
371        ask: Box::new(move |block, question| {
372            let app = app.clone();
373            Box::pin(async move {
374                let a = ask_user(&app, &block, question).await?;
375                Ok(helper_consent::Answer { answered: a.answered, approve: a.approve, remember: a.checkbox })
376            })
377        }),
378        settings: Box::new(move |connection| {
379            let full = config.get_full_config();
380            let host = full
381                .connections
382                .iter()
383                .find(|(name, _)| crate::backend::remote::conn::same_connection(name, connection))
384                .map(|(_, k)| k.conn_helper.clone())
385                .unwrap_or_default();
386            (host, full.settings.conn_helper.clone())
387        }),
388        remember: Box::new(move |connection, value| {
389            let mut values = serde_json::Map::new();
390            values.insert("conn:helper".into(), serde_json::json!(value));
391            crate::backend::config_watcher_fs::set_connection_values(&writer, &event_bus, connection, &values)?;
392            remotes::publish_change(&broker);
393            Ok(())
394        }),
395    });
396}
397
398/// A connection the Remotes commands may act on: a WSL distribution or an SSH
399/// destination, never `local` or something `ConnTarget` refuses.
400fn remote_name(connection: &str) -> Result<String, String> {
401    match ConnTarget::parse(connection)? {
402        ConnTarget::Local => Err("local is not a remote".to_string()),
403        _ => Ok(connection.trim().to_string()),
404    }
405}
406
407/// The user's yes to ending session `id` on `conn`, asked in the window of
408/// pane `block`. `Err` unless they said yes.
409async fn confirm_session_end(
410    state: &AppState,
411    block: &str,
412    conn: &str,
413    id: &str,
414) -> Result<(), String> {
415    // `consent`: a plain question of AgentMux's own, not a relayed ssh prompt.
416    let question = serde_json::json!({
417        "kind": "consent",
418        "title": format!("End a session on {}", one_line(conn, 80)),
419        "message": format!(
420            "End the durable session {id} on {}?\n\nIts shell, and everything running in it, stops. This cannot be undone.",
421            one_line(conn, 80)
422        ),
423        "ok_label": "End Session",
424        "cancel_label": "Keep It",
425        "destructive": true,
426    });
427    let answer = ask_user(state, block, question).await?;
428    if answer.answered && answer.approve {
429        Ok(())
430    } else {
431        Err("kept: the user chose not to end it".to_string())
432    }
433}
434
435fn str_field(data: &serde_json::Value, key: &str) -> String {
436    data.get(key)
437        .and_then(|v| v.as_str())
438        .unwrap_or_default()
439        .to_string()
440}
441
442/// What `connensure` does: accept `local`; accept a WSL distro that is
443/// installed (Windows only), which is then connected; accept an SSH
444/// destination where the system `ssh` is, claiming nothing (only a login
445/// connects it: a pane's ssh, or `connconnect`); refuse a bad name with the
446/// reason, recorded as the status the pane's overlay shows.
447pub(crate) async fn ensure(broker: &Broker, name: &str) -> Result<(), String> {
448    let (installed, ssh_found) = match ConnTarget::parse(name)? {
449        ConnTarget::Wsl(_) => (crate::backend::remote::wsl::list().await, false),
450        ConnTarget::Ssh(_) => (Vec::new(), crate::backend::remote::ssh::binary().is_some()),
451        ConnTarget::Local => (Vec::new(), false),
452    };
453    ensure_with(broker, name, &installed, cfg!(windows), ssh_found)
454}
455
456/// [`ensure`] with the installed distros, the platform and whether `ssh` was
457/// found given, so it is testable anywhere.
458pub(crate) fn ensure_with(
459    broker: &Broker,
460    name: &str,
461    wsl_installed: &[String],
462    on_windows: bool,
463    ssh_found: bool,
464) -> Result<(), String> {
465    let target = ConnTarget::parse(name)?;
466    let message = match &target {
467        ConnTarget::Local => return Ok(()),
468        ConnTarget::Wsl(_) if !on_windows => "WSL is only available on Windows".to_string(),
469        ConnTarget::Wsl(distro) if wsl_installed.iter().any(|d| d.eq_ignore_ascii_case(distro)) => {
470            status::set(Some(broker), name, state::CONNECTED, None);
471            return Ok(());
472        }
473        ConnTarget::Wsl(distro) => {
474            format!("WSL distribution '{distro}' is not installed (wsl.exe --list shows what is)")
475        }
476        ConnTarget::Ssh(_) if ssh_found => return Ok(()),
477        ConnTarget::Ssh(_) => crate::backend::remote::ssh::missing_binary_message(),
478    };
479    // Keyed by the name exactly as the pane's meta holds it, not the canonical
480    // form: the pane's overlay looks its status up by `meta.connection`, so
481    // " area54 " or "host:022" would otherwise never see their error (#4248).
482    status::set(Some(broker), name, state::ERROR, Some(&message));
483    Err(message)
484}
485
486/// Where an agent's `Shell` or `PtyShell` runs.
487#[derive(Debug, Clone, PartialEq, Eq)]
488pub(crate) enum AgentTarget {
489    Local,
490    /// An installed WSL distro.
491    Wsl(String),
492    /// An SSH destination: runs only with the user's consent
493    /// ([`consent_for_ssh`]), and every ssh prompt goes to the user
494    /// (`remote::askpass`), never to the agent.
495    Ssh(crate::backend::remote::conn::SshDest),
496}
497
498/// The connection an agent's `Shell` or `PtyShell` call asked for, checked the
499/// same way a pane's is ([`ensure`]), `Err` with the reason when it cannot run.
500pub(crate) async fn for_agent(broker: &Broker, name: Option<&str>) -> Result<AgentTarget, String> {
501    let name = name.unwrap_or_default();
502    ensure(broker, name).await?;
503    Ok(match ConnTarget::parse(name)? {
504        ConnTarget::Local => AgentTarget::Local,
505        ConnTarget::Wsl(distro) => AgentTarget::Wsl(distro),
506        ConnTarget::Ssh(dest) => AgentTarget::Ssh(dest),
507    })
508}
509
510/// How a dialog names an agent: its id, as one plain capped line.
511pub(crate) fn agent_label(agent: Option<&str>) -> String {
512    agent
513        .map(|a| one_line(a, 60))
514        .unwrap_or_else(|| "An agent with no AgentMux id".to_string())
515}
516
517/// One line of agent-supplied text for a dialog: shown as plain text, newlines
518/// folded and length capped, so it cannot lay out a message of its own.
519pub(crate) fn one_line(text: &str, max: usize) -> String {
520    let flat: String = text
521        .chars()
522        .map(|c| if c.is_control() { ' ' } else { c })
523        .collect();
524    let flat = flat.split_whitespace().collect::<Vec<_>>().join(" ");
525    if flat.chars().count() <= max {
526        flat
527    } else {
528        format!("{}…", flat.chars().take(max).collect::<String>())
529    }
530}
531
532/// How long the user has to answer a consent or askpass dialog.
533const DIALOG_TIMEOUT_MS: u64 = 120_000;
534
535/// How long an ssh run whose prompts go to the approval window may take: the
536/// dialog's own wait, and some to spare.
537const SSH_WITH_PROMPTS: std::time::Duration = std::time::Duration::from_millis(DIALOG_TIMEOUT_MS + 30_000);
538
539/// The user's answer to [`ask_user`]. `answered` is false when the window was
540/// closed, timed out, or could not be opened.
541#[derive(Debug, Default, Clone, serde::Deserialize)]
542pub(crate) struct UserAnswer {
543    #[serde(default)]
544    pub answered: bool,
545    #[serde(default)]
546    pub approve: bool,
547    /// For a `secret` question: never logged.
548    #[serde(default)]
549    pub text: String,
550    #[serde(default)]
551    pub checkbox: bool,
552}
553
554/// The host that can put a question to the user and relay their answer. Only
555/// the host can: agents hold the key srv's own routes take (see `ask_user`).
556/// With none connected (headless) there is no one to ask, and whatever needs
557/// the answer must not happen, nor wait for one.
558pub(crate) async fn host_to_ask_user(state: &AppState) -> Result<crate::server::HostIpc, String> {
559    state
560        .host_ipc
561        .lock()
562        .await
563        .clone()
564        .ok_or_else(|| "no AgentMux window is connected to ask the user in".to_string())
565}
566
567/// Ask the user a question about the agent in `agent_block_id`, in an approval
568/// subwindow the host opens over the window showing it (crates/cef
569/// `ssh_approval`), and wait for the answer.
570///
571/// Through the host's own IPC server and its token, which no agent holds, and
572/// answered in a window the browser API never resolves a pane into: an agent
573/// can neither click this answer nor forge it, unlike a modal in the main
574/// window answered through srv's own services (both reachable with the auth
575/// key every agent has). With no host connected (headless) there is no one to
576/// ask: `Err`, so whatever needed the answer does not happen.
577pub(crate) async fn ask_user(
578    state: &AppState,
579    agent_block_id: &str,
580    question: serde_json::Value,
581) -> Result<UserAnswer, String> {
582    let host = host_to_ask_user(state).await?;
583    // The agent's pane waits on the user until this returns: the waiting tone
584    // and notification for it (REPORT_AGENT_ATTENTION_CTA_CONTRAST_AND_TONE_2026_10_10.md §2).
585    let title = question.get("title").and_then(|t| t.as_str()).unwrap_or("An agent needs your answer").to_string();
586    let _asking = crate::backend::user_attention::Asking::start(&state.broker, &state.mstore, agent_block_id, "consent", &title);
587    let mut body = question;
588    body["block_id"] = serde_json::json!(agent_block_id);
589    body["timeout_ms"] = serde_json::json!(DIALOG_TIMEOUT_MS);
590    let url = format!("http://127.0.0.1:{}/agentmux/approval/ask", host.port);
591    let resp = state
592        .http_client
593        .post(&url)
594        .header("Authorization", format!("Bearer {}", host.token))
595        .timeout(std::time::Duration::from_millis(DIALOG_TIMEOUT_MS + 15_000))
596        .json(&body)
597        .send()
598        .await
599        .map_err(|e| format!("could not ask the user: {e}"))?;
600    if !resp.status().is_success() {
601        return Err(format!(
602            "could not ask the user: the window host answered HTTP {}",
603            resp.status()
604        ));
605    }
606    let v: serde_json::Value = resp
607        .json()
608        .await
609        .map_err(|e| format!("could not ask the user: {e}"))?;
610    if v.get("ok").and_then(|o| o.as_bool()) != Some(true) {
611        let e = v
612            .get("error")
613            .and_then(|e| e.as_str())
614            .unwrap_or("no answer");
615        return Err(format!("could not ask the user: {e}"));
616    }
617    serde_json::from_value(v.get("data").cloned().unwrap_or_default())
618        .map_err(|e| format!("could not ask the user: {e}"))
619}
620
621/// The agent an SSH request comes from, proven: its signed identity
622/// (`UiAutomationAuth`, made with its own jekt key, which no other agent
623/// holds) verified, and the pane derived from it, never taken from the
624/// request. `Err` unless that pane is `agent_block_id`: every agent holds
625/// srv's auth key, so a request body alone could name another agent's pane
626/// and borrow its "Always allow".
627pub(crate) fn verified_agent(
628    state: &AppState,
629    agent_block_id: &str,
630    auth: Option<&agentmux_common::api_types::UiAutomationAuth>,
631) -> Result<String, String> {
632    let auth = auth.ok_or_else(|| {
633        "an SSH connection needs the agent's signed identity, which this call did not carry (respawn the agent to give it a signing key)".to_string()
634    })?;
635    let pane = crate::server::ui_handlers::verified_block_id(state, None, auth)
636        .map_err(|e| format!("could not verify which agent is asking: {e}"))?;
637    if pane != agent_block_id {
638        return Err("this request names another agent's pane".to_string());
639    }
640    Ok(auth.agent_id.clone())
641}
642
643/// The user's consent for the agent `agent_id` (verified, [`verified_agent`])
644/// in `agent_block_id` to run `what` on the SSH `connection` with the user's
645/// identity (spec §8.2). `connection` is the canonical name
646/// (`ConnTarget::name`), so one host is one consent however it is spelled.
647/// "Always" for that agent and host is remembered (`remote::agent_access`)
648/// and asks nothing again; otherwise the user is asked ([`ask_user`]).
649pub(crate) async fn consent_for_ssh(
650    state: &AppState,
651    agent_block_id: &str,
652    agent_id: &str,
653    connection: &str,
654    what: &str,
655) -> Result<(), String> {
656    use crate::backend::remote::agent_access;
657    let agent_id = Some(agent_id.to_string()).filter(|a| !a.trim().is_empty());
658    let agent = agent_label(agent_id.as_deref());
659    let connection = connection.trim();
660    if agent_id
661        .as_deref()
662        .is_some_and(|id| agent_access::always_allowed(id, connection))
663    {
664        tracing::info!(agent = %agent, connection = %connection, what = %one_line(what, 200), "agent ssh access: allowed (always)");
665        return Ok(());
666    }
667    let question = serde_json::json!({
668        "kind": "consent",
669        "title": format!("Agent access to {}", one_line(connection, 80)),
670        "message": format!(
671            "{agent} wants to run this on {}, as you, with your SSH keys:\n\n{}\n\nAllow it?",
672            one_line(connection, 80),
673            one_line(what, 400)
674        ),
675        "checkbox": match &agent_id {
676            Some(_) => format!("Always allow {agent} on {}", one_line(connection, 80)),
677            None => String::new(),
678        },
679        "ok_label": "Allow",
680        "cancel_label": "Deny",
681    });
682    let answer = ask_user(state, agent_block_id, question)
683        .await
684        .map_err(|e| format!("{agent} needs the user's permission to use {connection}, and {e}"))?;
685    if !(answer.answered && answer.approve) {
686        tracing::info!(agent = %agent, connection = %connection, answered = answer.answered, "agent ssh access: not allowed");
687        return Err(if answer.answered {
688            format!("the user denied {agent} access to {connection}")
689        } else {
690            format!("the user did not answer whether {agent} may use {connection}")
691        });
692    }
693    let always = answer.checkbox && agent_id.is_some();
694    if let Some(id) = agent_id.as_deref().filter(|_| always) {
695        if let Err(e) = agent_access::remember(id, connection) {
696            tracing::warn!(error = %e, "agent ssh access: could not remember 'always'");
697        }
698    }
699    tracing::info!(agent = %agent, connection = %connection, what = %one_line(what, 200), always, "agent ssh access: allowed");
700    Ok(())
701}
702
703/// `POST /api/v1/askpass` body, from `agentmux-bashwrap` as ssh's askpass.
704#[derive(Debug, serde::Deserialize)]
705pub(crate) struct AskpassRequest {
706    secret: String,
707    prompt: String,
708    /// ssh's `SSH_ASKPASS_PROMPT`: `confirm`, `none`, or empty.
709    #[serde(default)]
710    hint: String,
711}
712
713/// `POST /api/v1/askpass`: show an ssh prompt to the user ([`ask_user`]) and
714/// return the answer (`remote::askpass`). Only a live secret is answered, and
715/// the secret, not anything in the request, says which agent and host the
716/// question names.
717pub(crate) async fn handle_askpass(
718    axum::extract::State(state): axum::extract::State<AppState>,
719    axum::Json(req): axum::Json<AskpassRequest>,
720) -> axum::response::Response {
721    use crate::backend::remote::askpass::{self, PromptKind};
722    use axum::http::StatusCode;
723    use axum::response::IntoResponse;
724    let refuse = |code: StatusCode, error: String| {
725        (code, axum::Json(serde_json::json!({ "error": error }))).into_response()
726    };
727    let Some(grant) = askpass::lookup(&req.secret) else {
728        return refuse(StatusCode::FORBIDDEN, "no such ssh prompt".to_string());
729    };
730    let kind = askpass::classify(&req.prompt, &req.hint);
731    // The grant's names are AgentMux's own, shown as plain capped lines all
732    // the same; ssh's prompt is ssh's text.
733    let question = serde_json::json!({
734        "kind": match kind {
735            PromptKind::YesNo => "yesno",
736            PromptKind::Info => "info",
737            PromptKind::Secret => "secret",
738        },
739        "title": format!("SSH: {}", one_line(&grant.connection, 80)),
740        "message": format!(
741            "ssh, connecting to {} for {}, {}:\n\n{}",
742            one_line(&grant.connection, 80),
743            if grant.user_pane {
744                "your terminal".to_string()
745            } else {
746                agent_label(Some(grant.agent.as_str()).filter(|a| !a.is_empty()))
747            },
748            if kind == PromptKind::Info { "says" } else { "asks" },
749            req.prompt.trim().chars().take(2000).collect::<String>()
750        ),
751        "ok_label": match kind {
752            PromptKind::YesNo => "Yes",
753            PromptKind::Info => "OK",
754            PromptKind::Secret => "OK",
755        },
756        "cancel_label": if kind == PromptKind::YesNo { "No" } else { "Cancel" },
757    });
758    // A notice ("touch your security key") is waited on like a question, but
759    // ssh does not wait for it: it kills askpass once done. That drops this
760    // request, which drops the request to the host, whose cleanup closes the
761    // window, so the notice goes exactly when ssh is done with it.
762    match ask_user(&state, &grant.agent_block_id, question).await {
763        Ok(answer) if answer.answered => {
764            // Never logged: the answer may be a password.
765            let text = match kind {
766                PromptKind::YesNo if answer.approve => "yes".to_string(),
767                PromptKind::YesNo => "no".to_string(),
768                PromptKind::Secret if answer.approve => answer.text,
769                PromptKind::Secret => {
770                    return refuse(StatusCode::GONE, "cancelled".to_string());
771                }
772                PromptKind::Info => String::new(),
773            };
774            axum::Json(serde_json::json!({ "answer": text })).into_response()
775        }
776        Ok(_) => refuse(StatusCode::GONE, "not answered".to_string()),
777        Err(e) => refuse(StatusCode::CONFLICT, e),
778    }
779}
780
781/// One row of the agent `ConnList` tool.
782#[derive(Debug, Clone, PartialEq, serde::Serialize)]
783pub(crate) struct ConnEntry {
784    pub connection: String,
785    /// `local`, `wsl` or `ssh`.
786    pub kind: &'static str,
787    /// `connected`, `available` (usable, not opened yet), or a status from
788    /// `remote::status` (`connecting`, `disconnected`, `error`).
789    pub status: String,
790    #[serde(skip_serializing_if = "String::is_empty")]
791    pub error: String,
792    /// Whether an agent can pass it as `connection`: every connection AgentMux
793    /// can run. An SSH host also asks the user for consent the first time
794    /// ([`consent_for_ssh`]).
795    pub agent_can_use: bool,
796}
797
798/// The picker's remote names: the ssh config hosts, then any other SSH
799/// connection known this session, each once.
800pub(crate) fn remote_names(ssh_hosts: &[String], known: &[ConnStatus]) -> Vec<String> {
801    use crate::backend::remote::conn::same_connection;
802    let mut out: Vec<String> = ssh_hosts.to_vec();
803    for s in known {
804        let is_ssh = matches!(ConnTarget::parse(&s.connection), Ok(ConnTarget::Ssh(_)));
805        if is_ssh && !out.iter().any(|n| same_connection(n, &s.connection)) {
806            out.push(s.connection.clone());
807        }
808    }
809    out
810}
811
812/// The connections there are: this machine, each installed WSL distro, the
813/// user's ssh config hosts, and any other connection a pane has used this
814/// session, with its status and whether an agent can use it.
815pub(crate) fn list(
816    wsl_installed: &[String],
817    ssh_hosts: &[String],
818    known: &[ConnStatus],
819) -> Vec<ConnEntry> {
820    use crate::backend::remote::conn::same_connection;
821    let status_of = |name: &str| known.iter().find(|s| same_connection(&s.connection, name));
822    let mut out = vec![ConnEntry {
823        connection: "local".to_string(),
824        kind: "local",
825        status: state::CONNECTED.to_string(),
826        error: String::new(),
827        agent_can_use: true,
828    }];
829    for distro in wsl_installed {
830        let name = ConnTarget::Wsl(distro.clone()).name();
831        let (status, error) = match status_of(&name) {
832            Some(s) => (s.status.clone(), s.error.clone()),
833            None => ("available".to_string(), String::new()),
834        };
835        out.push(ConnEntry {
836            connection: name,
837            kind: "wsl",
838            status,
839            error,
840            agent_can_use: true,
841        });
842    }
843    for host in ssh_hosts {
844        let (status, error) = match status_of(host) {
845            Some(s) => (s.status.clone(), s.error.clone()),
846            None => ("available".to_string(), String::new()),
847        };
848        out.push(ConnEntry {
849            connection: host.clone(),
850            kind: "ssh",
851            status,
852            error,
853            agent_can_use: true,
854        });
855    }
856    for s in known {
857        if out
858            .iter()
859            .any(|e| same_connection(&e.connection, &s.connection))
860        {
861            continue;
862        }
863        let kind = match ConnTarget::parse(&s.connection) {
864            Ok(ConnTarget::Wsl(_)) => "wsl",
865            Ok(ConnTarget::Ssh(_)) => "ssh",
866            _ => continue,
867        };
868        out.push(ConnEntry {
869            connection: s.connection.clone(),
870            kind,
871            status: s.status.clone(),
872            error: s.error.clone(),
873            agent_can_use: true,
874        });
875    }
876    out
877}
878
879/// `GET /api/v1/conn/list`: the agent `ConnList` tool (spec §8.1).
880pub(crate) async fn handle_conn_list() -> axum::Json<Vec<ConnEntry>> {
881    let installed = crate::backend::remote::wsl::list().await;
882    let hosts = tokio::task::spawn_blocking(crate::backend::remote::ssh_config::hosts)
883        .await
884        .unwrap_or_default();
885    axum::Json(list(&installed, &hosts, &status::all()))
886}
887
888#[cfg(test)]
889mod tests {
890    use super::*;
891
892    #[test]
893    fn local_is_accepted_and_remote_is_refused_with_a_status() {
894        let broker = Broker::new();
895        let none: &[String] = &[];
896        let ensure = |name: &str| ensure_with(&broker, name, none, true, false);
897        assert_eq!(ensure("local"), Ok(()));
898        assert_eq!(ensure(""), Ok(()));
899
900        let err = ensure("test-ensure-host").unwrap_err();
901        assert!(err.contains("need the ssh command"), "{err}");
902        // With ssh: accepted, and nothing claimed. Having an ssh command is
903        // not a login: the host showed connected before anything had
904        // connected (#4513). A pane's ssh, or a login, says how it is.
905        assert_eq!(
906            ensure_with(&broker, "test-ensure-ssh-ok", none, true, true),
907            Ok(())
908        );
909        assert!(!status::all()
910            .iter()
911            .any(|s| s.connection == "test-ensure-ssh-ok" && s.connected));
912        let st = status::all()
913            .into_iter()
914            .find(|s| s.connection == "test-ensure-host")
915            .unwrap();
916        assert_eq!(
917            (st.status.as_str(), st.error.as_str()),
918            (state::ERROR, err.as_str())
919        );
920
921        // A name that is not a connection says why, and records nothing.
922        assert!(ensure("-oProxyCommand=calc")
923            .unwrap_err()
924            .contains("cannot start with '-'"));
925        assert!(!status::all().iter().any(|s| s.connection.starts_with('-')));
926    }
927
928    #[test]
929    fn an_installed_wsl_distro_connects_and_a_missing_one_says_so() {
930        let broker = Broker::new();
931        let installed = vec!["Ubuntu".to_string(), "Debian".to_string()];
932        assert_eq!(
933            ensure_with(&broker, "wsl://ubuntu", &installed, true, false),
934            Ok(())
935        );
936        let st = status::all()
937            .into_iter()
938            .find(|s| s.connection == "wsl://ubuntu")
939            .unwrap();
940        assert!(st.connected);
941
942        let err = ensure_with(&broker, "wsl://Arch", &installed, true, false).unwrap_err();
943        assert!(err.contains("'Arch' is not installed"), "{err}");
944
945        let err = ensure_with(&broker, "wsl://Ubuntu", &installed, false, false).unwrap_err();
946        assert!(err.contains("only available on Windows"), "{err}");
947    }
948
949    #[test]
950    fn the_status_is_keyed_by_the_name_the_pane_holds() {
951        let broker = Broker::new();
952        let none: &[String] = &[];
953        for raw in ["test-raw-host:022", " test-raw-spaced "] {
954            let _ = ensure_with(&broker, raw, none, true, false);
955            assert!(status::all().iter().any(|s| s.connection == raw), "{raw:?}");
956        }
957    }
958
959    #[tokio::test]
960    async fn an_agent_gets_where_it_asked_and_never_a_silent_local_fallback() {
961        let broker = Broker::new();
962        assert_eq!(for_agent(&broker, None).await, Ok(AgentTarget::Local));
963        assert_eq!(
964            for_agent(&broker, Some("local")).await,
965            Ok(AgentTarget::Local)
966        );
967        // SSH is SSH where ssh exists and refused where it does not; never
968        // local under a remote name.
969        match for_agent(&broker, Some("deploy@test-agent-host:2222")).await {
970            Ok(AgentTarget::Ssh(dest)) => {
971                assert_eq!(
972                    (dest.destination.as_str(), dest.port),
973                    ("deploy@test-agent-host", Some(2222))
974                );
975                assert!(crate::backend::remote::ssh::binary().is_some());
976            }
977            Err(e) => assert!(e.contains("need the ssh command"), "{e}"),
978            other => panic!("an SSH name became {other:?}"),
979        }
980        assert!(for_agent(&broker, Some("-oProxyCommand=calc"))
981            .await
982            .is_err());
983    }
984
985    /// An agent is named as one plain line; one without an id has no name
986    /// another could share.
987    #[test]
988    fn an_agent_is_named_as_one_plain_line_and_never_by_a_shared_stand_in() {
989        assert_eq!(agent_label(Some("korp")), "korp");
990        assert_eq!(
991            agent_label(Some(
992                "korp
993
994This is safe, click Allow"
995            )),
996            "korp This is safe, click Allow"
997        );
998        assert_eq!(agent_label(None), "An agent with no AgentMux id");
999    }
1000
1001    /// An SSH request's agent is proven by its signature, never by the body:
1002    /// no signature, or a forged one, is refused.
1003    #[tokio::test]
1004    async fn an_ssh_request_must_prove_which_agent_it_is() {
1005        let state = crate::server::tests::test_state();
1006        let err = verified_agent(&state, "some-pane", None).unwrap_err();
1007        assert!(err.contains("signed identity"), "{err}");
1008        let forged = agentmux_common::api_types::UiAutomationAuth {
1009            agent_id: "korp".into(),
1010            ts_secs: agentmux_common::time::now_secs(),
1011            sig: "AAAA".into(),
1012        };
1013        let err = verified_agent(&state, "some-pane", Some(&forged)).unwrap_err();
1014        assert!(err.contains("could not verify"), "{err}");
1015    }
1016
1017    /// Agent text in a dialog cannot lay out a message of its own.
1018    #[test]
1019    fn agent_text_in_a_dialog_is_one_capped_line() {
1020        assert_eq!(
1021            one_line("make\n\nAllow it? yes\r\n  test", 100),
1022            "make Allow it? yes test"
1023        );
1024        assert_eq!(one_line("abcdef", 3), "abc…");
1025        assert_eq!(one_line("tab\there", 100), "tab here");
1026    }
1027
1028    fn st(connection: &str, status: &str, error: &str) -> ConnStatus {
1029        ConnStatus {
1030            status: status.to_string(),
1031            connection: connection.to_string(),
1032            error: error.to_string(),
1033            ..Default::default()
1034        }
1035    }
1036
1037    #[test]
1038    fn the_list_is_local_then_the_distros_then_the_ssh_hosts_then_what_panes_have_used() {
1039        let installed = vec!["Ubuntu".to_string(), "Debian".to_string()];
1040        let hosts = vec!["area54".to_string(), "nas".to_string()];
1041        let known = vec![
1042            // Accepted as typed; still the installed Ubuntu, listed once.
1043            st("wsl://ubuntu", state::CONNECTED, ""),
1044            st("area54", state::ERROR, "could not connect"),
1045            st("deploy@10.0.0.5", state::CONNECTED, ""),
1046            st("wsl://Arch", state::ERROR, "not installed"),
1047            // Not a connection: never listed.
1048            st("local", state::CONNECTED, ""),
1049        ];
1050        let got: Vec<(String, &str, String, bool)> = list(&installed, &hosts, &known)
1051            .into_iter()
1052            .map(|e| (e.connection, e.kind, e.status, e.agent_can_use))
1053            .collect();
1054        let want = [
1055            ("local", "local", "connected", true),
1056            ("wsl://Ubuntu", "wsl", "connected", true),
1057            ("wsl://Debian", "wsl", "available", true),
1058            ("area54", "ssh", "error", true),
1059            ("nas", "ssh", "available", true),
1060            ("deploy@10.0.0.5", "ssh", "connected", true),
1061            ("wsl://Arch", "wsl", "error", true),
1062        ];
1063        let want: Vec<(String, &str, String, bool)> = want
1064            .iter()
1065            .map(|(c, k, s, a)| (c.to_string(), *k, s.to_string(), *a))
1066            .collect();
1067        assert_eq!(got, want);
1068    }
1069
1070    /// The picker's "Remote" section: config hosts first, then other SSH
1071    /// connections used this session, never WSL or local.
1072    #[test]
1073    fn the_picker_gets_the_ssh_hosts_and_the_ssh_connections_used() {
1074        let hosts = vec!["area54".to_string(), "nas".to_string()];
1075        let known = vec![
1076            st("area54", state::CONNECTED, ""),
1077            st("deploy@10.0.0.5:2222", state::DISCONNECTED, ""),
1078            st("wsl://Ubuntu", state::CONNECTED, ""),
1079            st("local", state::CONNECTED, ""),
1080        ];
1081        assert_eq!(
1082            remote_names(&hosts, &known),
1083            ["area54", "nas", "deploy@10.0.0.5:2222"]
1084        );
1085    }
1086}