1use 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 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 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 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 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 let name = data.as_str().unwrap_or_default().to_string();
67 match ConnTarget::parse(&name) {
68 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 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 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 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 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 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 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 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
208async 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
250fn 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
259async 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
275async 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
289async 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
300async 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
309async 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 "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 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
363pub 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
398fn 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
407async fn confirm_session_end(
410 state: &AppState,
411 block: &str,
412 conn: &str,
413 id: &str,
414) -> Result<(), String> {
415 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
442pub(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
456pub(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 status::set(Some(broker), name, state::ERROR, Some(&message));
483 Err(message)
484}
485
486#[derive(Debug, Clone, PartialEq, Eq)]
488pub(crate) enum AgentTarget {
489 Local,
490 Wsl(String),
492 Ssh(crate::backend::remote::conn::SshDest),
496}
497
498pub(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
510pub(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
517pub(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
532const DIALOG_TIMEOUT_MS: u64 = 120_000;
534
535const SSH_WITH_PROMPTS: std::time::Duration = std::time::Duration::from_millis(DIALOG_TIMEOUT_MS + 30_000);
538
539#[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 #[serde(default)]
549 pub text: String,
550 #[serde(default)]
551 pub checkbox: bool,
552}
553
554pub(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
567pub(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 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
621pub(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
643pub(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#[derive(Debug, serde::Deserialize)]
705pub(crate) struct AskpassRequest {
706 secret: String,
707 prompt: String,
708 #[serde(default)]
710 hint: String,
711}
712
713pub(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 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 match ask_user(&state, &grant.agent_block_id, question).await {
763 Ok(answer) if answer.answered => {
764 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#[derive(Debug, Clone, PartialEq, serde::Serialize)]
783pub(crate) struct ConnEntry {
784 pub connection: String,
785 pub kind: &'static str,
787 pub status: String,
790 #[serde(skip_serializing_if = "String::is_empty")]
791 pub error: String,
792 pub agent_can_use: bool,
796}
797
798pub(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
812pub(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
879pub(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 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 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 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 #[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 #[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 #[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 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 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 #[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}