git.delta.rocks / remowt / refs/commits / d819a0103251

difftreelog

feat functional polkit prompt

pvnwrxrnYaroslav Bolyukin2024-08-06parent: #2499daa.patch.diff
in: trunk

7 files changed

modifiedCargo.lockdiffbeforeafterboth
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -883,6 +883,7 @@
 name = "polkit-shared"
 version = "0.1.0"
 dependencies = [
+ "nix",
  "serde",
  "zbus",
 ]
modifiedcmds/polkit-backend/src/main.rsdiffbeforeafterboth
before · cmds/polkit-backend/src/main.rs
1use std::collections::{HashMap, HashSet};2use std::ffi::{CStr, CString};3use std::future::pending;4use std::sync::LazyLock;56use anyhow::Context as _;7use clap::Parser;8use nix::unistd::{setuid, Uid, User};9use pam_client::{Context, ConversationHandler, ErrorCode, Flag};10use polkit_shared::BackendRequest;11use tokio::runtime::Handle;12use tokio::task::{block_in_place, spawn_blocking};13use tracing::trace;14use ui_prompt::dbus::DbusPrompterProxyBlocking;15use ui_prompt::{BlockingPrompter, Prompter};16use zbus::fdo;17use zbus::message::Header;18use zbus::zvariant::OwnedValue;19use zbus::{blocking, interface, proxy, Connection};2021struct Helper {22    connection: Connection,23    blocking_connection: blocking::Connection,24}2526static ALLOWED_ENVIRONMENT: LazyLock<HashSet<&str>> = LazyLock::new(|| {27    [28        // pam ssh agent auth29        "SSH_AUTH_SOCK",30        // ssh itself provides this when running PAM31        "SSH_AUTH_INFO_0",32        // contains user which ran sudo33        "SUDO_USER",34    ]35    .into_iter()36    .collect()37});3839struct Conversation<P>(P);40impl<P: BlockingPrompter> Conversation<P> {41    fn prompt_inner(&self, echo: bool, prompt: &CStr) -> Result<CString, ErrorCode> {42        trace!("do prompt");43        let out = self44            .045            .prompt_text(46                echo,47                &prompt.to_string_lossy(),48                "Polkit prompt request",49                &[],50            )51            .map_err(|e| {52                trace!("prompt error: {e}");53                ErrorCode::CONV_ERR54            })?;55        CString::new(out).map_err(|_| ErrorCode::CONV_AGAIN)56    }57    fn text_inner(&self, error: bool, msg: &CStr) {58        trace!("do text");59        let msg = msg.to_string_lossy();60        let _ = self.0.display_text(error, &msg, &[]);61    }62}63impl<P: BlockingPrompter> ConversationHandler for Conversation<P> {64    fn prompt_echo_on(&mut self, prompt: &CStr) -> Result<CString, ErrorCode> {65        self.prompt_inner(true, prompt)66    }6768    fn prompt_echo_off(&mut self, prompt: &CStr) -> Result<CString, ErrorCode> {69        self.prompt_inner(false, prompt)70    }7172    fn text_info(&mut self, msg: &CStr) {73        self.text_inner(false, msg)74    }7576    fn error_msg(&mut self, msg: &CStr) {77        self.text_inner(true, msg)78    }7980    fn radio_prompt(&mut self, prompt: &CStr) -> Result<bool, ErrorCode> {81        let prompt = prompt.to_string_lossy();82        let result = self83            .084            .prompt_radio(&prompt, "Polkit prompt request", &[])85            .map_err(|_| ErrorCode::CONV_ERR)?;86        Ok(result)87    }88}8990#[proxy(91    default_service = "org.freedesktop.DBus",92    default_path = "/org/freedesktop/DBus"93)]94trait DBus {95    fn get_connection_credentials(&self, body: &str) -> zbus::Result<HashMap<String, OwnedValue>>;96}9798#[interface(name = "lach.PolkitHelper")]99impl Helper {100    async fn init_conversation(101        &self,102        request: BackendRequest,103        #[zbus(header)] hdr: Header<'_>,104    ) -> fdo::Result<()> {105        let Some(sender) = hdr.sender().map(|v| v.to_owned()) else {106            trace!("missing sender");107            return Err(fdo::Error::AuthFailed("missing sender".to_owned()));108        };109110        let dbus = DBusProxy::new(&self.connection).await?;111112        // TOCTOU: sender might be already disconnected, and there might be another113        // user with different user id here, but does it matters?114        let reply = dbus.get_connection_credentials(&sender).await?;115        let uid: u32 = (&reply["UnixUserID"]).try_into().unwrap();116117        let blocking_connection = self.blocking_connection.clone();118        let thread_result: fdo::Result<()> = block_in_place(move || {119            trace!("find user");120            let user = User::from_uid(Uid::from_raw(uid))121                .map_err(|_| fdo::Error::AuthFailed("error querying user".to_owned()))?122                .ok_or_else(|| fdo::Error::AuthFailed("uid not found".to_owned()))?;123124            let responder = DbusPrompterProxyBlocking::new(125                &blocking_connection,126                sender,127                request.prompter_path,128            )?;129            let conversation = Conversation(responder);130            trace!("run context for {}", &user.name);131            let mut ctx = Context::new(132                // TODO: Should another scope be used?133                "login",134                Some(&user.name),135                conversation,136            )137            .map_err(|_| fdo::Error::Failed("pam context init failed".to_owned()))?;138139            trace!("fill env");140            for (k, v) in request.environment {141                if k.contains('=') || !ALLOWED_ENVIRONMENT.contains(k.as_str()) {142                    continue;143                }144                let _ = ctx.putenv(format!("{k}={v}"));145            }146147            trace!("authenticate");148            ctx.authenticate(Flag::NONE)149                .map_err(|_| fdo::Error::AuthFailed("pam authentication failed".to_owned()))?;150151            trace!("acct mgmt");152            ctx.acct_mgmt(Flag::NONE)153                .map_err(|_| fdo::Error::AuthFailed("pam acct mgmt failed".to_owned()))?;154155            Ok(())156        });157158        thread_result?;159160        trace!("respond");161        let proxy = zbus_polkit::policykit1::AuthorityProxy::new(&self.connection).await?;162163        let identity_details = request164            .identity165            .details166            .iter()167            .map(|(k, v)| (k.as_str(), (**v).try_clone().expect("success")))168            .collect::<HashMap<_, _>>();169        proxy170            .authentication_agent_response2(171                uid,172                &request.cookie,173                &zbus_polkit::policykit1::Identity {174                    identity_kind: &request.identity.kind,175                    identity_details: &identity_details,176                },177            )178            .await?;179        Ok(())180    }181}182183const OBJ_PATH: &str = "/lach/PolkitHelper";184185#[derive(Parser)]186struct Opts {187    /// Not recommended: start as a session connection, then use escalation188    /// to respond to polkit requests.189    #[arg(long)]190    session: bool,191}192193#[tokio::main]194async fn main() -> anyhow::Result<()> {195    tracing_subscriber::fmt::init();196    let opts = Opts::parse();197    let connection = if opts.session {198        Connection::session().await199    } else {200        Connection::system().await201    }202    .context("failed to open connection")?;203204    let session = opts.session;205    let blocking_connection: anyhow::Result<blocking::Connection> = spawn_blocking(move || {206        Ok(if session {207            blocking::Connection::session()?208        } else {209            blocking::Connection::system()?210        })211    })212    .await?;213    let blocking_connection = blocking_connection.context("failed to open blocking connection")?;214215    if opts.session {216        setuid(Uid::from_raw(0))217            .context("polkit-backend needs to be suid if run in session mode")?;218    }219220    connection221        .object_server()222        .at(223            OBJ_PATH,224            Helper {225                connection: connection.clone(),226                blocking_connection,227            },228        )229        .await230        .context("failed listen path")?;231232    connection233        .request_name("lach.polkit.helper1")234        .await235        .context("failed to request name")?;236237    pending().await238}
after · cmds/polkit-backend/src/main.rs
1use std::collections::{HashMap, HashSet};2use std::ffi::{CStr, CString};3use std::future::pending;4use std::sync::LazyLock;56use anyhow::Context as _;7use clap::Parser;8use nix::unistd::{setuid, Uid, User};9use pam_client::{Context, ConversationHandler, ErrorCode, Flag};10use polkit_shared::BackendRequest;11use tokio::runtime::Handle;12use tokio::task::{block_in_place, spawn_blocking};13use tracing::trace;14use ui_prompt::dbus::DbusPrompterProxyBlocking;15use ui_prompt::{BlockingPrompter, Prompter};16use zbus::fdo;17use zbus::message::Header;18use zbus::zvariant::OwnedValue;19use zbus::{blocking, interface, proxy, Connection};2021struct Helper {22    connection: Connection,23    blocking_connection: blocking::Connection,24}2526static ALLOWED_ENVIRONMENT: LazyLock<HashSet<&str>> = LazyLock::new(|| {27    [28        // pam ssh agent auth29        "SSH_AUTH_SOCK",30        // ssh itself provides this when running PAM31        "SSH_AUTH_INFO_0",32        // contains user which ran sudo33        "SUDO_USER",34    ]35    .into_iter()36    .collect()37});3839struct Conversation<P>(P);40impl<P: BlockingPrompter> Conversation<P> {41    fn prompt_inner(&self, echo: bool, prompt: &CStr) -> Result<CString, ErrorCode> {42        trace!("do prompt");43        let out = self44            .045            .prompt_text(46                echo,47                &prompt.to_string_lossy(),48                "PAM prompt request",49                &[],50            )51            .map_err(|e| {52                trace!("prompt error: {e}");53                ErrorCode::CONV_ERR54            })?;55        CString::new(out).map_err(|_| ErrorCode::CONV_AGAIN)56    }57    fn text_inner(&self, error: bool, msg: &CStr) {58        trace!("do text");59        let msg = msg.to_string_lossy();60        let _ = self.0.display_text(error, &msg, &[]);61    }62}63impl<P: BlockingPrompter> ConversationHandler for Conversation<P> {64    fn prompt_echo_on(&mut self, prompt: &CStr) -> Result<CString, ErrorCode> {65        self.prompt_inner(true, prompt)66    }6768    fn prompt_echo_off(&mut self, prompt: &CStr) -> Result<CString, ErrorCode> {69        self.prompt_inner(false, prompt)70    }7172    fn text_info(&mut self, msg: &CStr) {73        self.text_inner(false, msg)74    }7576    fn error_msg(&mut self, msg: &CStr) {77        self.text_inner(true, msg)78    }7980    fn radio_prompt(&mut self, prompt: &CStr) -> Result<bool, ErrorCode> {81        let prompt = prompt.to_string_lossy();82        let result = self83            .084            .prompt_radio(&prompt, "PAM prompt request", &[])85            .map_err(|_| ErrorCode::CONV_ERR)?;86        Ok(result)87    }88}8990#[proxy(91    default_service = "org.freedesktop.DBus",92    default_path = "/org/freedesktop/DBus"93)]94trait DBus {95    fn get_connection_credentials(&self, body: &str) -> zbus::Result<HashMap<String, OwnedValue>>;96}9798#[interface(name = "lach.PolkitHelper")]99impl Helper {100    async fn init_conversation(101        &self,102        request: BackendRequest,103        #[zbus(header)] hdr: Header<'_>,104    ) -> fdo::Result<()> {105        let Some(sender) = hdr.sender().map(|v| v.to_owned()) else {106            trace!("missing sender");107            return Err(fdo::Error::AuthFailed("missing sender".to_owned()));108        };109110        let dbus = DBusProxy::new(&self.connection).await?;111112        // TOCTOU: sender might be already disconnected, and there might be another113        // user with different user id here, but does it matters?114        let reply = dbus.get_connection_credentials(&sender).await?;115        let uid: u32 = (&reply["UnixUserID"]).try_into().unwrap();116117        let blocking_connection = self.blocking_connection.clone();118        let thread_result: fdo::Result<()> = block_in_place(move || {119            trace!("find user");120            let user = User::from_uid(Uid::from_raw(uid))121                .map_err(|_| fdo::Error::AuthFailed("error querying user".to_owned()))?122                .ok_or_else(|| fdo::Error::AuthFailed("uid not found".to_owned()))?;123124            let responder = DbusPrompterProxyBlocking::new(125                &blocking_connection,126                sender,127                request.prompter_path,128            )?;129            let conversation = Conversation(responder);130            trace!("run context for {}", &user.name);131            let mut ctx = Context::new(132                // TODO: Should another scope be used?133                "login",134                Some(&user.name),135                conversation,136            )137            .map_err(|_| fdo::Error::Failed("pam context init failed".to_owned()))?;138139            trace!("fill env");140            for (k, v) in request.environment {141                if k.contains('=') || !ALLOWED_ENVIRONMENT.contains(k.as_str()) {142                    continue;143                }144                let _ = ctx.putenv(format!("{k}={v}"));145            }146147            trace!("authenticate");148            ctx.authenticate(Flag::NONE)149                .map_err(|_| fdo::Error::AuthFailed("pam authentication failed".to_owned()))?;150151            trace!("acct mgmt");152            ctx.acct_mgmt(Flag::NONE)153                .map_err(|_| fdo::Error::AuthFailed("pam acct mgmt failed".to_owned()))?;154155            Ok(())156        });157158        thread_result?;159160        trace!("respond");161        let proxy = zbus_polkit::policykit1::AuthorityProxy::new(&self.connection).await?;162163        let identity_details = request164            .identity165            .details166            .iter()167            .map(|(k, v)| (k.as_str(), (**v).try_clone().expect("success")))168            .collect::<HashMap<_, _>>();169        proxy170            .authentication_agent_response2(171                uid,172                &request.cookie,173                &zbus_polkit::policykit1::Identity {174                    identity_kind: &request.identity.kind,175                    identity_details: &identity_details,176                },177            )178            .await?;179        Ok(())180    }181}182183const OBJ_PATH: &str = "/lach/PolkitHelper";184185#[derive(Parser)]186struct Opts {187    /// Not recommended: start as a session connection, then use escalation188    /// to respond to polkit requests.189    #[arg(long)]190    session: bool,191}192193#[tokio::main]194async fn main() -> anyhow::Result<()> {195    tracing_subscriber::fmt::init();196    let opts = Opts::parse();197    let connection = if opts.session {198        Connection::session().await199    } else {200        Connection::system().await201    }202    .context("failed to open connection")?;203204    let session = opts.session;205    let blocking_connection: anyhow::Result<blocking::Connection> = spawn_blocking(move || {206        Ok(if session {207            blocking::Connection::session()?208        } else {209            blocking::Connection::system()?210        })211    })212    .await?;213    let blocking_connection = blocking_connection.context("failed to open blocking connection")?;214215    if opts.session {216        setuid(Uid::from_raw(0))217            .context("polkit-backend needs to be suid if run in session mode")?;218    }219220    connection221        .object_server()222        .at(223            OBJ_PATH,224            Helper {225                connection: connection.clone(),226                blocking_connection,227            },228        )229        .await230        .context("failed listen path")?;231232    connection233        .request_name("lach.polkit.helper1")234        .await235        .context("failed to request name")?;236237    pending().await238}
modifiedcmds/remowt-agent/src/main.rsdiffbeforeafterboth
--- a/cmds/remowt-agent/src/main.rs
+++ b/cmds/remowt-agent/src/main.rs
@@ -1,19 +1,20 @@
-use std::collections::HashMap;
+use std::borrow::Cow;
+use std::collections::{BTreeMap, HashMap};
 use std::io::{stdout, Write};
 use std::marker::PhantomData;
 use std::sync::{Mutex, RwLock};
 use std::{future, process};
 
 use clap::Parser;
-use polkit_shared::{BackendRequest, Identity};
+use polkit_shared::{emphasize, BackendRequest, Identity, PidDisplay};
 use tokio::runtime::Handle;
 use tokio::task::{AbortHandle, JoinHandle, LocalSet};
 use tracing::trace;
 use ui_prompt::dbus::DbusPrompterInterface;
 use ui_prompt::rofi::RofiPrompter;
-use ui_prompt::Prompter;
+use ui_prompt::{PrependSourcePrompter, Prompter, Source};
 use zbus::zvariant::{OwnedValue, Str};
-use zbus::ObjectServer;
+use zbus::{fdo, ObjectServer};
 use zbus::{interface, proxy, Connection};
 use zbus_polkit::policykit1::Subject;
 
@@ -81,10 +82,11 @@
         action_id: String,
         message: String,
         icon_name: String,
-        details: HashMap<String, String>,
+        mut details: BTreeMap<String, String>,
         cookie: String,
         identities: Vec<Identity>,
     ) -> zbus::fdo::Result<()> {
+        use std::fmt::Write;
         trace!("begin auth");
         let task = {
             let connection = self.connection.clone();
@@ -92,7 +94,61 @@
             let cookie = cookie.clone();
             tokio::task::spawn(async move {
                 trace!("conversation task");
-                let prompter = TemporaryPrompterInterface::new(connection, RofiPrompter).await;
+                let mut description = format!("{message}\n\n<b>Action id:</b> {action_id}",);
+                if let Some(subject) = details.remove("polkit.caller-pid") {
+                    let _ = write!(description, "\n<b>Caller:</b> ");
+                    if let Ok(pid) = subject.parse::<u32>() {
+                        let _ = write!(description, "{}", PidDisplay(pid));
+                    } else {
+                        let _ = write!(description, "{}", emphasize("invalid pid"));
+                    }
+                }
+                if let Some(subject) = details.remove("polkit.subject-pid") {
+                    let _ = write!(description, "\n<b>Subject:</b> ");
+                    if let Ok(pid) = subject.parse::<u32>() {
+                        let _ = write!(description, "{}", PidDisplay(pid));
+                    } else {
+                        let _ = write!(description, "{}", emphasize("invalid pid"));
+                    }
+                }
+                let mut prompter = PrependSourcePrompter {
+                    source: vec![Source(Cow::Borrowed("polkit agent"))],
+                    description: description.clone(),
+                    prompter: RofiPrompter,
+                };
+
+                let identity_displays: Vec<String> =
+                    identities.iter().map(|v| v.to_string()).collect();
+                let identity_displays: Vec<&str> =
+                    identity_displays.iter().map(|v| v.as_str()).collect();
+                let choosen_identity = match identity_displays.len() {
+                    0 => {
+                        return Err(fdo::Error::AuthFailed(
+                            "no identity to authenticate as".to_owned(),
+                        ))
+                    }
+                    1 => 0,
+                    _ => {
+                        prompter
+                            .prompt_enum(
+                                "Identity",
+                                "Select identity to use for polkit authorization",
+                                &identity_displays,
+                                &[],
+                            )
+                            .await?
+                    }
+                };
+
+                let _ = write!(
+                    description,
+                    "\n<b>Identity:</b> {}",
+                    identities[choosen_identity as usize]
+                );
+                prompter.description = description;
+
+                prompter.source.push(Source(Cow::Borrowed("polkit daemon")));
+                let prompter = TemporaryPrompterInterface::new(connection, prompter).await;
                 helper
                     .init_conversation(
                         BackendRequest {
@@ -100,7 +156,7 @@
                             environment: HashMap::new(),
                             prompter_path: prompter.path.clone(),
                             // TODO: Let user choose
-                            identity: identities.get(0).expect("first always exists").clone(),
+                            identity: identities[choosen_identity as usize].clone(),
                         }, // cookie.to_owned(), HashMap::new(), prompter.path.clone()
                     )
                     .await?;
@@ -135,7 +191,7 @@
     }
 }
 
-const OBJ_PATH: &str = "/0lach/polkitAgent";
+const OBJ_PATH: &str = "/org/freedesktop/PolicyKit1/AuthenticationAgent";
 
 #[proxy(
     interface = "lach.PolkitHelper",
modifiedcrates/polkit-shared/Cargo.tomldiffbeforeafterboth
--- a/crates/polkit-shared/Cargo.toml
+++ b/crates/polkit-shared/Cargo.toml
@@ -4,5 +4,6 @@
 edition = "2021"
 
 [dependencies]
+nix = "0.29.0"
 serde = { version = "1.0.204", features = ["derive"] }
 zbus = "4.4.0"
modifiedcrates/polkit-shared/src/lib.rsdiffbeforeafterboth
--- a/crates/polkit-shared/src/lib.rs
+++ b/crates/polkit-shared/src/lib.rs
@@ -1,14 +1,83 @@
 use std::collections::HashMap;
+use std::{fmt, fs};
 
+use nix::unistd::{Uid, User};
 use serde::{Deserialize, Serialize};
-use zbus::zvariant::{OwnedValue, Type};
+use zbus::zvariant::{OwnedValue, Type, Value};
+
+pub fn emphasize(s: impl AsRef<str>) -> String {
+    format!("<span style=\"italic\">&lt;{}&gt;</span>", escape(s),)
+}
+fn command(s: impl AsRef<str>) -> String {
+    format!("<u><tt>{}</tt></u>", s.as_ref())
+}
+fn escape(s: impl AsRef<str>) -> String {
+    s.as_ref()
+        .replace("&", "&quot;")
+        .replace("<", "&lt;")
+        .replace(">", "&gt;")
+}
 
+pub struct PidDisplay(pub u32);
+impl fmt::Display for PidDisplay {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        if self.0 == 1 {
+            emphasize("init").fmt(f)
+        } else if let Ok(proc) = fs::read_to_string(format!("/proc/{}/cmdline", self.0)) {
+            write!(
+                f,
+                "<sub>command</sub>{}",
+                command(
+                    proc.replace("\0", " ")
+                        .strip_suffix(" ")
+                        .expect("cmdline should end with NUL")
+                )
+            )
+        } else if let Ok(proc) = fs::read_to_string(format!("/proc/{}/comm", self.0)) {
+            write!(f, "<sub>process</sub>{}", command(proc.replace("\0", " ")))
+        } else {
+            emphasize("unknown process").fmt(f)
+        }
+    }
+}
+
 #[derive(Serialize, Deserialize, Type, PartialEq, Debug)]
 pub struct Identity {
     pub kind: String,
     pub details: HashMap<String, OwnedValue>,
 }
 
+impl fmt::Display for Identity {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self.kind.as_str() {
+            "unix-user" => match self.details.get("uid").map(|v| &**v) {
+                Some(Value::U32(uid)) => match User::from_uid(Uid::from_raw(*uid)) {
+                    Ok(Some(u)) => write!(
+                        f,
+                        "<sub>user</sub>{}<sup>{}</sup>{}",
+                        u.name,
+                        u.uid,
+                        if u.gecos.is_empty() {
+                            "".to_owned()
+                        } else {
+                            format!(": {}", escape(u.gecos.to_string_lossy()))
+                        }
+                    ),
+                    Ok(None) => emphasize("not found").fmt(f),
+                    Err(e) => {
+                        let user = format!("could not get user: {e}");
+                        emphasize(&user).fmt(f)?;
+                        Ok(())
+                    }
+                },
+
+                _ => emphasize("unknown uid").fmt(f),
+            },
+            _ => emphasize(format!("identity of unknown kind: {}", self.kind)).fmt(f),
+        }
+    }
+}
+
 impl Clone for Identity {
     fn clone(&self) -> Self {
         Self {
modifiedcrates/ui-prompt/src/lib.rsdiffbeforeafterboth
--- a/crates/ui-prompt/src/lib.rs
+++ b/crates/ui-prompt/src/lib.rs
@@ -18,7 +18,7 @@
 
 #[cfg_attr(feature = "dbus", derive(zbus::zvariant::Type))]
 #[derive(serde::Serialize, serde::Deserialize, Clone)]
-pub struct Source(Cow<'static, str>);
+pub struct Source(pub Cow<'static, str>);
 impl fmt::Display for Source {
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
         write!(f, "<u>{}</u>", self.0)
@@ -79,8 +79,9 @@
 }
 
 pub struct PrependSourcePrompter<P> {
-    prompter: P,
-    source: Vec<Source>,
+    pub prompter: P,
+    pub source: Vec<Source>,
+    pub description: String,
 }
 impl<P> PrependSourcePrompter<P> {
     fn source(&self, input: &[Source]) -> Vec<Source> {
@@ -88,11 +89,31 @@
         out.extend(input.iter().cloned());
         out
     }
+    fn description(&self, input: &str) -> String {
+        if self.description.is_empty() {
+            input.to_owned()
+        } else if input.is_empty() {
+            self.description.to_owned()
+        } else {
+            format!("{input}\n\n{}", self.description)
+        }
+    }
 }
 impl<P> Prompter for PrependSourcePrompter<P>
 where
     P: Prompter + Sync,
 {
+    async fn prompt_radio(
+        &self,
+        prompt: &str,
+        description: &str,
+        source: &[Source],
+    ) -> Result<bool> {
+        self.prompter
+            .prompt_radio(prompt, &self.description(description), &self.source(source))
+            .await
+    }
+
     async fn prompt_enum(
         &self,
         prompt: &str,
@@ -101,7 +122,12 @@
         source: &[Source],
     ) -> Result<u32> {
         self.prompter
-            .prompt_enum(prompt, description, variants, &self.source(source))
+            .prompt_enum(
+                prompt,
+                dbg!(&self.description(description)),
+                variants,
+                &self.source(source),
+            )
             .await
     }
 
@@ -113,13 +139,18 @@
         source: &[Source],
     ) -> Result<String> {
         self.prompter
-            .prompt_text(echo, prompt, description, &self.source(source))
+            .prompt_text(
+                echo,
+                prompt,
+                &self.description(description),
+                &self.source(source),
+            )
             .await
     }
 
     async fn display_text(&self, error: bool, description: &str, source: &[Source]) -> Result<()> {
         self.prompter
-            .display_text(error, description, &self.source(source))
+            .display_text(error, &self.description(description), &self.source(source))
             .await
     }
 }
modifiedcrates/ui-prompt/src/rofi.rsdiffbeforeafterboth
--- a/crates/ui-prompt/src/rofi.rs
+++ b/crates/ui-prompt/src/rofi.rs
@@ -27,7 +27,10 @@
             description.to_owned()
         } else {
             let mut out = format!("{description}\n\n<b>Requested on ",);
-            for s in source.iter() {
+            for (i, s) in source.iter().enumerate() {
+                if i != 0 {
+                    out.push_str(" -> ");
+                }
                 out.push_str(&s.to_string());
             }
             out.push_str("</b>");
@@ -43,6 +46,7 @@
             fixup_prompt(prompt),
             "-format",
             "i",
+            "-markup-rows",
         ]);
         cmd.stdin(Stdio::piped());
         cmd.stdout(Stdio::piped());
@@ -100,7 +104,10 @@
             description.to_owned()
         } else {
             let mut out = format!("{description}\n\n<b>Requested on ",);
-            for s in source.iter() {
+            for (i, s) in source.iter().enumerate() {
+                if i != 0 {
+                    out.push_str(" -> ");
+                }
                 out.push_str(&s.to_string());
             }
             out.push_str("</b>");