Skip to main content

agentmux_bashwrap/
askpass.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! askpass mode: `ssh` runs `$SSH_ASKPASS "<prompt>"` and reads the answer on
5//! stdout. When AgentMux runs `ssh` for an agent it points `SSH_ASKPASS` here
6//! and sets `AGENTMUX_ASKPASS_SECRET` on that `ssh` only; this asks srv
7//! (`POST /api/v1/askpass`), which shows the prompt to the user, and prints
8//! their answer. The secret, not anything here, decides whose prompt it is
9//! (srv's `backend::remote::askpass`).
10//!
11//! Recognised before clap runs: ssh passes the prompt as the only argument,
12//! with no subcommand.
13
14use std::time::Duration;
15
16const ENV_SECRET: &str = "AGENTMUX_ASKPASS_SECRET";
17
18/// Longer than srv waits for the user (two minutes), so srv's own answer, or
19/// its "not answered", is what arrives.
20const ANSWER_TIMEOUT: Duration = Duration::from_secs(150);
21
22/// bashwrap's own subcommands: never an askpass call, even if the variable
23/// was inherited by something run under that ssh.
24const SUBCOMMANDS: &[&str] = &[
25    "exec",
26    "hook",
27    "precompact",
28    "sessionstart",
29    "help",
30    "--help",
31    "-h",
32    "--version",
33    "-V",
34];
35
36/// The prompt, when this run is ssh asking: the secret is set and the
37/// arguments are not a bashwrap subcommand.
38pub fn askpass_prompt(secret: Option<&str>, args: &[String]) -> Option<String> {
39    secret.filter(|s| !s.is_empty())?;
40    if args
41        .first()
42        .is_some_and(|a| SUBCOMMANDS.contains(&a.as_str()))
43    {
44        return None;
45    }
46    Some(args.join(" "))
47}
48
49/// Run askpass mode if this is an askpass call: `Some(exit code)` (0 with the
50/// answer on stdout, 1 when there is none), `None` to carry on as bashwrap.
51pub fn run_if_asked() -> Option<i32> {
52    let secret = std::env::var(ENV_SECRET).ok();
53    let args: Vec<String> = std::env::args().skip(1).collect();
54    let prompt = askpass_prompt(secret.as_deref(), &args)?;
55    let secret = secret.unwrap_or_default();
56    let Some(client) = crate::mps_client::WpsClient::from_env() else {
57        eprintln!("agentmux askpass: AGENTMUX_LOCAL_URL / AGENTMUX_AUTH_KEY not set");
58        return Some(1);
59    };
60    let rt = match tokio::runtime::Runtime::new() {
61        Ok(rt) => rt,
62        Err(e) => {
63            eprintln!("agentmux askpass: {e}");
64            return Some(1);
65        }
66    };
67    // ssh's own word for the prompt (OpenSSH 8.4+): `confirm`, `none`, or unset.
68    let hint = std::env::var("SSH_ASKPASS_PROMPT").unwrap_or_default();
69    let body = serde_json::json!({ "secret": secret, "prompt": prompt, "hint": hint });
70    let answer = rt.block_on(client.post_json("/api/v1/askpass", None, &body, ANSWER_TIMEOUT));
71    match answer
72        .ok()
73        .and_then(|v| v.get("answer").and_then(|a| a.as_str()).map(str::to_string))
74    {
75        Some(text) => {
76            // ssh reads one line; the answer is never logged or echoed.
77            println!("{text}");
78            Some(0)
79        }
80        None => Some(1),
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    fn args(a: &[&str]) -> Vec<String> {
89        a.iter().map(|s| s.to_string()).collect()
90    }
91
92    #[test]
93    fn ssh_asking_is_askpass_and_everything_else_is_bashwrap() {
94        assert_eq!(
95            askpass_prompt(Some("amxa_x"), &args(&["asaf@area54's password: "])),
96            Some("asaf@area54's password: ".to_string())
97        );
98        assert_eq!(
99            askpass_prompt(None, &args(&["asaf@area54's password: "])),
100            None,
101            "no secret, no askpass"
102        );
103        assert_eq!(askpass_prompt(Some(""), &args(&["password: "])), None);
104        assert_eq!(
105            askpass_prompt(Some("amxa_x"), &args(&["exec", "--tool-id", "t"])),
106            None,
107            "an inherited secret never turns a subcommand into askpass"
108        );
109        assert_eq!(askpass_prompt(Some("amxa_x"), &args(&["hook"])), None);
110    }
111}