Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T05:01:49.542Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

askpass.rs

571 lines · 21.8 KB · rust
1mod encrypted_password;
2
3pub use encrypted_password::{EncryptedPassword, IKnowWhatIAmDoingAndIHaveReadTheDocs};
4
5use net::async_net::UnixListener;
6use smol::lock::Mutex;
7#[cfg(not(target_os = "windows"))]
8use util::fs::make_file_executable;
9
10use std::ffi::OsStr;
11use std::ops::ControlFlow;
12use std::sync::Arc;
13use std::sync::OnceLock;
14use std::time::Duration;
15
16use anyhow::{Context as _, Result};
17use futures::channel::{mpsc, oneshot};
18use futures::{
19    AsyncBufReadExt as _, AsyncWriteExt as _, FutureExt as _, SinkExt, StreamExt, io::BufReader,
20    select_biased,
21};
22use gpui::{AsyncApp, BackgroundExecutor, Task};
23#[cfg(not(target_os = "windows"))]
24use smol::fs;
25use util::{ResultExt as _, debug_panic, maybe};
26
27#[cfg(not(target_os = "windows"))]
28use util::{paths::PathExt, shell::ShellKind};
29
30/// Path to the program used for askpass
31///
32/// On Unix and remote servers, this defaults to the current executable.
33/// On Windows, this must be set to the CLI variant of zed via set_askpass_program(),
34/// because SSH_ASKPASS must point to a directly executable binary. The CLI binary
35/// handles the ZED_ASKPASS_SOCKET env var to communicate with Zed over a Unix socket
36/// without needing a wrapper script.
37static ASKPASS_PROGRAM: OnceLock<std::path::PathBuf> = OnceLock::new();
38
39#[derive(PartialEq, Eq)]
40pub enum AskPassResult {
41    CancelledByUser,
42    Timedout,
43}
44
45pub struct AskPassDelegate {
46    tx: mpsc::UnboundedSender<(String, oneshot::Sender<EncryptedPassword>)>,
47    executor: BackgroundExecutor,
48    _task: Task<()>,
49}
50
51impl AskPassDelegate {
52    pub fn new(
53        cx: &mut AsyncApp,
54        password_prompt: impl Fn(String, oneshot::Sender<EncryptedPassword>, &mut AsyncApp)
55        + Send
56        + Sync
57        + 'static,
58    ) -> Self {
59        let (tx, mut rx) = mpsc::unbounded::<(String, oneshot::Sender<_>)>();
60        let task = cx.spawn(async move |cx: &mut AsyncApp| {
61            while let Some((prompt, channel)) = rx.next().await {
62                password_prompt(prompt, channel, cx);
63            }
64        });
65        Self {
66            tx,
67            _task: task,
68            executor: cx.background_executor().clone(),
69        }
70    }
71
72    pub fn ask_password(&mut self, prompt: String) -> Task<Option<EncryptedPassword>> {
73        let mut this_tx = self.tx.clone();
74        self.executor.spawn(async move {
75            let (tx, rx) = oneshot::channel();
76            this_tx.send((prompt, tx)).await.ok()?;
77            rx.await.ok()
78        })
79    }
80}
81
82pub struct AskPassSession {
83    #[cfg(target_os = "windows")]
84    secret: std::sync::Arc<std::sync::Mutex<Option<EncryptedPassword>>>,
85    askpass_task: PasswordProxy,
86    askpass_opened_rx: Option<oneshot::Receiver<()>>,
87    askpass_kill_master_rx: Option<oneshot::Receiver<()>>,
88    executor: BackgroundExecutor,
89}
90
91#[cfg(not(target_os = "windows"))]
92const ASKPASS_SCRIPT_NAME: &str = "askpass.sh";
93
94#[cfg(not(target_os = "windows"))]
95const GPG_WRAPPER_SCRIPT_NAME: &str = "gpg-wrapper.sh";
96
97impl AskPassSession {
98    /// This will create a new AskPassSession.
99    /// You must retain this session until the master process exits.
100    #[must_use]
101    pub async fn new(executor: BackgroundExecutor, mut delegate: AskPassDelegate) -> Result<Self> {
102        #[cfg(target_os = "windows")]
103        let secret = std::sync::Arc::new(std::sync::Mutex::new(None));
104
105        let (askpass_opened_tx, askpass_opened_rx) = oneshot::channel::<()>();
106
107        let askpass_opened_tx = Arc::new(Mutex::new(Some(askpass_opened_tx)));
108
109        let (askpass_kill_master_tx, askpass_kill_master_rx) = oneshot::channel::<()>();
110        let kill_tx = Arc::new(Mutex::new(Some(askpass_kill_master_tx)));
111
112        let get_password = {
113            let executor = executor.clone();
114
115            #[cfg(target_os = "windows")]
116            let askpass_secret = secret.clone();
117            move |prompt| {
118                let prompt = delegate.ask_password(prompt);
119                let kill_tx = kill_tx.clone();
120                let askpass_opened_tx = askpass_opened_tx.clone();
121                #[cfg(target_os = "windows")]
122                let askpass_secret = askpass_secret.clone();
123                executor.spawn(async move {
124                    if let Some(askpass_opened_tx) = askpass_opened_tx.lock().await.take() {
125                        askpass_opened_tx.send(()).ok();
126                    }
127                    if let Some(password) = prompt.await {
128                        #[cfg(target_os = "windows")]
129                        {
130                            askpass_secret.lock().unwrap().replace(password.clone());
131                        }
132                        ControlFlow::Continue(Ok(password))
133                    } else {
134                        if let Some(kill_tx) = kill_tx.lock().await.take() {
135                            kill_tx.send(()).ok();
136                        }
137                        ControlFlow::Break(())
138                    }
139                })
140            }
141        };
142        let askpass_task = PasswordProxy::new(Box::new(get_password), executor.clone()).await?;
143
144        Ok(Self {
145            #[cfg(target_os = "windows")]
146            secret,
147
148            askpass_task,
149            askpass_kill_master_rx: Some(askpass_kill_master_rx),
150            askpass_opened_rx: Some(askpass_opened_rx),
151            executor,
152        })
153    }
154
155    // This will run the askpass task forever, resolving as many authentication requests as needed.
156    // The caller is responsible for examining the result of their own commands and cancelling this
157    // future when this is no longer needed. Note that this can only be called once, but due to the
158    // drop order this takes an &mut, so you can `drop()` it after you're done with the master process.
159    //
160    // When `timeout` is provided, this resolves with `AskPassResult::Timedout` if no askpass prompt
161    // has been opened within that duration. This is intended for connection establishment (e.g.
162    // SSH), where "no prompt and no connection" indicates an unreachable host. Callers wrapping
163    // commands that may legitimately run for a long time without prompting (e.g. git) should pass
164    // `None` and rely on the command's own completion instead.
165    pub async fn run(&mut self, timeout: Option<Duration>) -> AskPassResult {
166        let askpass_opened_rx = self.askpass_opened_rx.take().expect("Only call run once");
167        let askpass_kill_master_rx = self
168            .askpass_kill_master_rx
169            .take()
170            .expect("Only call run once");
171        let executor = self.executor.clone();
172        let timer = async move {
173            match timeout {
174                Some(timeout) => executor.timer(timeout).await,
175                None => std::future::pending().await,
176            }
177        };
178
179        select_biased! {
180            _ = askpass_opened_rx.fuse() => {
181                // Note: this await can only resolve after we are dropped.
182                askpass_kill_master_rx.await.ok();
183                AskPassResult::CancelledByUser
184            }
185
186            _ = futures::FutureExt::fuse(timer) => {
187                AskPassResult::Timedout
188            }
189        }
190    }
191
192    /// This will return the password that was last set by the askpass script.
193    #[cfg(target_os = "windows")]
194    pub fn get_password(&self) -> Option<EncryptedPassword> {
195        self.secret.lock().ok()?.clone()
196    }
197
198    /// Returns the value to set as SSH_ASKPASS.
199    /// On Unix this is the path to the generated shell script.
200    /// On Windows this is the path to cli.exe directly — no script needed.
201    pub fn script_path(&self) -> impl AsRef<OsStr> {
202        self.askpass_task.script_path()
203    }
204
205    /// Path to a script suitable for git's `gpg.program`, routing GnuPG
206    /// passphrase prompts through Zed's askpass UI. `None` if unavailable.
207    pub fn gpg_wrapper_path(&self) -> Option<&std::path::Path> {
208        #[cfg(not(target_os = "windows"))]
209        return self.askpass_task.gpg_wrapper_path();
210        #[cfg(target_os = "windows")]
211        return None;
212    }
213
214    /// Returns the socket path to set as ZED_ASKPASS_SOCKET.
215    ///
216    /// On Windows, SSH_ASKPASS points directly to cli.exe. SSH passes only
217    /// the prompt string as argv[1] with no mechanism for extra arguments,
218    /// so the socket path is communicated via this environment variable instead.
219    /// cli.exe must check ZED_ASKPASS_SOCKET before clap parses args.
220    #[cfg(target_os = "windows")]
221    pub fn socket_path(&self) -> impl AsRef<OsStr> {
222        self.askpass_task.socket_path()
223    }
224}
225
226pub struct PasswordProxy {
227    _task: Task<()>,
228    /// On Unix: path to the generated .sh askpass script (set as SSH_ASKPASS).
229    /// On Windows: path to cli.exe (set as SSH_ASKPASS directly — no script needed).
230    askpass_script_path: std::path::PathBuf,
231    #[cfg(not(target_os = "windows"))]
232    gpg_wrapper_script_path: Option<std::path::PathBuf>,
233    /// On Windows only: path to the Unix socket, passed as ZED_ASKPASS_SOCKET
234    /// so cli.exe can find it without --askpass argument parsing.
235    #[cfg(target_os = "windows")]
236    askpass_socket_path: std::path::PathBuf,
237}
238
239impl PasswordProxy {
240    pub async fn new(
241        mut get_password: Box<
242            dyn FnMut(String) -> Task<ControlFlow<(), Result<EncryptedPassword>>>
243                + 'static
244                + Send
245                + Sync,
246        >,
247        executor: BackgroundExecutor,
248    ) -> Result<Self> {
249        let temp_dir = tempfile::Builder::new().prefix("omega-askpass").tempdir()?;
250        let askpass_socket = temp_dir.path().join("askpass.sock");
251        let current_exec =
252            std::env::current_exe().context("Failed to determine current Omega executable path.")?;
253
254        let askpass_program = ASKPASS_PROGRAM.get_or_init(|| current_exec);
255
256        // Unix: SSH_ASKPASS = path to generated .sh script in temp dir.
257        // Windows: SSH_ASKPASS = path to cli.exe directly. No script is written.
258        #[cfg(not(target_os = "windows"))]
259        let askpass_script_path = temp_dir.path().join(ASKPASS_SCRIPT_NAME);
260        #[cfg(target_os = "windows")]
261        let askpass_script_path = askpass_program.to_path_buf();
262
263        let askpass_socket_path = askpass_socket.clone();
264
265        // Create a gpg wrapper script that routes GnuPG passphrase prompts through
266        // the same socket (and thus through Zed's askpass UI). This only works on
267        // Unix where we control the pinentry via loopback mode. We compute the path
268        // before the socket task takes ownership of `temp_dir`, and write the file
269        // afterwards.
270        #[cfg(not(target_os = "windows"))]
271        let (gpg_wrapper_script_path, gpg_wrapper_script) =
272            match generate_gpg_wrapper_script(askpass_program, &askpass_socket_path) {
273                Ok(script) => (
274                    Some(temp_dir.path().join(GPG_WRAPPER_SCRIPT_NAME)),
275                    Some(script),
276                ),
277                Err(err) => {
278                    log::warn!("could not create gpg askpass wrapper: {err:#}");
279                    (None, None)
280                }
281            };
282
283        let _task = executor.spawn(async move {
284            maybe!(async move {
285                let listener =
286                    UnixListener::bind(&askpass_socket).context("creating askpass socket")?;
287
288                while let Ok((mut stream, _)) = listener.accept().await {
289                    let mut buffer = Vec::new();
290                    let mut reader = BufReader::new(&mut stream);
291                    if reader.read_until(b'\0', &mut buffer).await.is_err() {
292                        buffer.clear();
293                    }
294                    let prompt = String::from_utf8_lossy(&buffer).into_owned();
295                    let password = get_password(prompt).await;
296                    match password {
297                        ControlFlow::Continue(password) => {
298                            if let Ok(password) = password
299                                && let Ok(decrypted) =
300                                    password.decrypt(IKnowWhatIAmDoingAndIHaveReadTheDocs)
301                            {
302                                stream.write_all(decrypted.as_bytes()).await.log_err();
303                            }
304                        }
305                        ControlFlow::Break(()) => {
306                            // note: we expect the caller to drop this task when it's done.
307                            // We need to keep the stream open until the caller is done to avoid
308                            // spurious errors from ssh.
309                            std::future::pending::<()>().await;
310                            drop(stream);
311                        }
312                    }
313                }
314                drop(temp_dir);
315                Result::<_, anyhow::Error>::Ok(())
316            })
317            .await
318            .log_err();
319        });
320
321        // Unix only: write the shell script and mark it executable.
322        // On Windows cli.exe is invoked directly, so no script is needed.
323        #[cfg(not(target_os = "windows"))]
324        {
325            let askpass_script = generate_askpass_script(askpass_program, &askpass_socket_path)?;
326            fs::write(&askpass_script_path, askpass_script)
327                .await
328                .with_context(|| format!("creating askpass script at {askpass_script_path:?}"))?;
329            make_file_executable(&askpass_script_path)
330                .await
331                .with_context(|| {
332                    format!("marking askpass script executable at {askpass_script_path:?}")
333                })?;
334        }
335
336        // Write the gpg wrapper script (computed above) and mark it executable.
337        #[cfg(not(target_os = "windows"))]
338        let gpg_wrapper_script_path =
339            if let Some((path, script)) = gpg_wrapper_script_path.zip(gpg_wrapper_script) {
340                match async {
341                    fs::write(&path, script)
342                        .await
343                        .with_context(|| format!("creating gpg wrapper script at {path:?}"))?;
344                    make_file_executable(&path).await.with_context(|| {
345                        format!("marking gpg wrapper script executable at {path:?}")
346                    })?;
347                    anyhow::Ok(())
348                }
349                .await
350                {
351                    Ok(()) => Some(path),
352                    Err(err) => {
353                        log::warn!("could not write gpg askpass wrapper: {err:#}");
354                        None
355                    }
356                }
357            } else {
358                None
359            };
360
361        Ok(Self {
362            _task,
363            askpass_script_path,
364            #[cfg(not(target_os = "windows"))]
365            gpg_wrapper_script_path,
366            #[cfg(target_os = "windows")]
367            askpass_socket_path,
368        })
369    }
370
371    pub fn script_path(&self) -> impl AsRef<OsStr> {
372        &self.askpass_script_path
373    }
374
375    #[cfg(target_os = "windows")]
376    pub fn socket_path(&self) -> impl AsRef<OsStr> {
377        &self.askpass_socket_path
378    }
379
380    #[cfg(not(target_os = "windows"))]
381    pub fn gpg_wrapper_path(&self) -> Option<&std::path::Path> {
382        self.gpg_wrapper_script_path.as_deref()
383    }
384}
385
386/// Runs Zed in netcat mode for use in askpass.
387pub fn main(socket: &str) {
388    use std::io::{self, Read};
389    use std::process::exit;
390
391    let mut buffer = Vec::new();
392    if let Err(err) = io::stdin().read_to_end(&mut buffer) {
393        eprintln!("Error reading from stdin: {}", err);
394        exit(1);
395    }
396
397    connect_and_write_prompt(socket, buffer)
398}
399
400/// Runs Zed in askpass mode using prompts passed as arguments.
401pub fn main_from_args(socket: &str, args: impl IntoIterator<Item = String>) {
402    let prompt = args.into_iter().collect::<Vec<_>>().join("\0");
403    connect_and_write_prompt(socket, prompt.into_bytes())
404}
405
406fn connect_and_write_prompt(socket: &str, mut buffer: Vec<u8>) {
407    use net::UnixStream;
408    use std::io::{self, Read, Write};
409    use std::process::exit;
410
411    let mut stream = match UnixStream::connect(socket) {
412        Ok(stream) => stream,
413        Err(err) => {
414            eprintln!("Error connecting to socket {}: {}", socket, err);
415            exit(1);
416        }
417    };
418
419    #[cfg(target_os = "windows")]
420    while buffer.last().is_some_and(|&b| b == b'\n' || b == b'\r') {
421        buffer.pop();
422    }
423    if buffer.last() != Some(&b'\0') {
424        buffer.push(b'\0');
425    }
426
427    if let Err(err) = stream.write_all(&buffer) {
428        eprintln!("Error writing to socket: {}", err);
429        exit(1);
430    }
431
432    let mut response = Vec::new();
433    if let Err(err) = stream.read_to_end(&mut response) {
434        eprintln!("Error reading from socket: {}", err);
435        exit(1);
436    }
437
438    if let Err(err) = io::stdout().write_all(&response) {
439        eprintln!("Error writing to stdout: {}", err);
440        exit(1);
441    }
442}
443
444pub fn set_askpass_program(path: std::path::PathBuf) {
445    if ASKPASS_PROGRAM.set(path).is_err() {
446        debug_panic!("askpass program has already been set");
447    }
448}
449
450/// Generates the Unix shell askpass script.
451/// Not used on Windows — cli.exe is invoked directly as SSH_ASKPASS.
452#[cfg(not(target_os = "windows"))]
453fn generate_askpass_script(
454    askpass_program: &std::path::Path,
455    askpass_socket: &std::path::Path,
456) -> Result<String> {
457    let shell_kind = ShellKind::Posix;
458    let askpass_program = shell_kind.prepend_command_prefix(
459        askpass_program
460            .to_str()
461            .context("Askpass program is on a non-utf8 path")?,
462    );
463    let askpass_program = shell_kind
464        .try_quote_prefix_aware(&askpass_program)
465        .context("Failed to shell-escape Askpass program path")?;
466    let askpass_socket = askpass_socket
467        .try_shell_safe(shell_kind)
468        .context("Failed to shell-escape Askpass socket path")?;
469    let print_args = "printf '%s\\0' \"$@\"";
470    let shebang = "#!/bin/sh";
471    Ok(format!(
472        "{shebang}\n{print_args} | {askpass_program} --askpass={askpass_socket} 2> /dev/null \n",
473    ))
474}
475
476#[inline]
477#[cfg(not(target_os = "windows"))]
478fn generate_gpg_wrapper_script(
479    askpass_program: &std::path::Path,
480    askpass_socket: &std::path::Path,
481) -> Result<String> {
482    let shell_kind = ShellKind::Posix;
483    let gpg_program = find_gpg_program().context("could not find a gpg binary on PATH")?;
484    let gpg_program = gpg_program
485        .to_str()
486        .context("gpg program is on a non-utf8 path")?;
487    let gpg_program = shell_kind
488        .try_quote_prefix_aware(gpg_program)
489        .context("Failed to shell-escape gpg program path")?;
490
491    let askpass_program = shell_kind.prepend_command_prefix(
492        askpass_program
493            .to_str()
494            .context("Askpass program is on a non-utf8 path")?,
495    );
496    let askpass_program = shell_kind
497        .try_quote_prefix_aware(&askpass_program)
498        .context("Failed to shell-escape Askpass program path")?;
499    let askpass_socket = askpass_socket
500        .try_shell_safe(shell_kind)
501        .context("Failed to shell-escape Askpass socket path")?;
502
503    let prompt = shell_kind
504        .try_quote_prefix_aware("Enter passphrase for your Git signing key:")
505        .context("Failed to shell-escape gpg passphrase prompt")?;
506
507    // The wrapper only intervenes when git asks gpg to *sign* (e.g. `gpg -bsau
508    // <key>`); other invocations like `--verify` run unchanged. For signing we
509    // first try plain gpg so gpg-agent/keychain can supply a cached or empty
510    // passphrase silently, and only fall back to asking Zed (loopback mode, fd
511    // 3) when that fails, e.g. the "Inappropriate ioctl for device" case with no
512    // TTY for pinentry.
513    //
514    // git streams the payload on stdin (readable once) and reads the signature
515    // from stdout, so we buffer stdin to replay it into both attempts and buffer
516    // the first attempt's output, forwarding it only if it succeeds. The
517    // passphrase goes to fd 3 via a pipe.
518    Ok(format!(
519        r#"#!/bin/sh
520for arg in "$@"; do
521    case "$arg" in
522        # Long-form signing options.
523        --sign|--detach-sign|--clearsign|--clear-sign) is_signing=1 ;;
524        # Skip other long options so flags like `--status-fd` don't match below.
525        --*) ;;
526        # Short-flag clusters containing `s`, e.g. git's `-bsau`.
527        -*s*) is_signing=1 ;;
528    esac
529done
530
531# Not a signing request: run gpg as-is, leaving its prompting untouched.
532if [ -z "${{is_signing}}" ]; then
533    exec {gpg_program} "$@"
534fi
535
536# Signing. Buffer stdin (the payload) and the first attempt's output
537# so we can retry cleanly on failure without git seeing partial output.
538tmpdir=$(mktemp -d) || exit 1
539trap 'rm -rf "$tmpdir"' EXIT
540payload="$tmpdir/payload"
541signature="$tmpdir/signature"
542status="$tmpdir/status"
543cat > "$payload" || exit 1
544
545# First try letting gpg-agent/keychain supply the passphrase without any
546# interactive pinentry. If that succeeds (cached passphrase)
547# forward its output and we're done, so Omega never shows a modal.
548if {gpg_program} --pinentry-mode error "$@" < "$payload" > "$signature" 2> "$status"; then
549    cat "$status" >&2
550    cat "$signature"
551    exit 0
552fi
553
554# The silent attempt failed: ask Omega for the passphrase, then hand it to gpg on
555# fd 3 using loopback mode so no pinentry/terminal is required.
556passphrase=$(printf '%s\0' {prompt} | {askpass_program} --askpass={askpass_socket} 2>/dev/null)
557printf '%s\n' "$passphrase" |
558{gpg_program} --pinentry-mode loopback --passphrase-fd 3 "$@" 3<&0 < "$payload"
559"#,
560    ))
561}
562
563/// Finds the real `gpg` (or `gpg2`) executable on `PATH`.
564#[inline]
565#[cfg(not(target_os = "windows"))]
566fn find_gpg_program() -> Option<std::path::PathBuf> {
567    ["gpg", "gpg2"]
568        .into_iter()
569        .find_map(|candidate| which::which(candidate).ok())
570}
571
Served at tenant.openagents/omega Member data and write actions are omitted.