Files
codex/prs/bolinfest/PR-1678.md
2025-09-02 15:17:45 -07:00

27 KiB

PR #1678: Optionally run using user profile

Description

(No description.)

Full Diff

diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock
index df1b0235a7..f654ce49e6 100644
--- a/codex-rs/Cargo.lock
+++ b/codex-rs/Cargo.lock
@@ -683,6 +683,7 @@ dependencies = [
  "serde",
  "serde_json",
  "sha1",
+ "shlex",
  "strum_macros 0.27.2",
  "tempfile",
  "thiserror 2.0.12",
@@ -696,6 +697,7 @@ dependencies = [
  "tree-sitter-bash",
  "uuid",
  "walkdir",
+ "whoami",
  "wildmatch",
  "wiremock",
 ]
@@ -5127,6 +5129,12 @@ dependencies = [
  "wit-bindgen-rt",
 ]
 
+[[package]]
+name = "wasite"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
+
 [[package]]
 name = "wasm-bindgen"
 version = "0.2.100"
@@ -5227,6 +5235,17 @@ version = "0.1.10"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3"
 
+[[package]]
+name = "whoami"
+version = "1.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6994d13118ab492c3c80c1f81928718159254c53c472bf9ce36f8dae4add02a7"
+dependencies = [
+ "redox_syscall",
+ "wasite",
+ "web-sys",
+]
+
 [[package]]
 name = "wildmatch"
 version = "2.4.0"
diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml
index 62e462bf97..2e0489c9b5 100644
--- a/codex-rs/core/Cargo.toml
+++ b/codex-rs/core/Cargo.toml
@@ -30,6 +30,7 @@ reqwest = { version = "0.12", features = ["json", "stream"] }
 serde = { version = "1", features = ["derive"] }
 serde_json = "1"
 sha1 = "0.10.6"
+shlex = "1.3.0"
 strum_macros = "0.27.2"
 thiserror = "2.0.12"
 time = { version = "0.3", features = ["formatting", "local-offset", "macros"] }
@@ -47,6 +48,8 @@ tree-sitter = "0.25.8"
 tree-sitter-bash = "0.25.0"
 uuid = { version = "1", features = ["serde", "v4"] }
 wildmatch = "2.4.0"
+whoami = "1.6.0"
+
 
 [target.'cfg(target_os = "linux")'.dependencies]
 landlock = "0.4.1"
diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs
index f35348b779..d9d40a8a47 100644
--- a/codex-rs/core/src/codex.rs
+++ b/codex-rs/core/src/codex.rs
@@ -85,6 +85,7 @@ use crate::rollout::RolloutRecorder;
 use crate::safety::SafetyCheck;
 use crate::safety::assess_command_safety;
 use crate::safety::assess_patch_safety;
+use crate::shell;
 use crate::user_notification::UserNotification;
 use crate::util::backoff;
 
@@ -204,6 +205,7 @@ pub(crate) struct Session {
     rollout: Mutex<Option<RolloutRecorder>>,
     state: Mutex<State>,
     codex_linux_sandbox_exe: Option<PathBuf>,
+    user_shell: shell::Shell,
 }
 
 impl Session {
@@ -676,6 +678,7 @@ async fn submission_loop(
                         });
                     }
                 }
+                let default_shell = shell::default_user_shell().await;
                 sess = Some(Arc::new(Session {
                     client,
                     tx_event: tx_event.clone(),
@@ -693,6 +696,7 @@ async fn submission_loop(
                     rollout: Mutex::new(rollout_recorder),
                     codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(),
                     disable_response_storage,
+                    user_shell: default_shell,
                 }));
 
                 // Patch restored state into the newly created session.
@@ -1383,6 +1387,18 @@ fn parse_container_exec_arguments(
     }
 }
 
+fn maybe_run_with_user_profile(params: ExecParams, sess: &Session) -> ExecParams {
+    if sess.shell_environment_policy.use_profile {
+        let command = sess
+            .user_shell
+            .format_default_shell_invocation(params.command.clone());
+        if let Some(command) = command {
+            return ExecParams { command, ..params };
+        }
+    }
+    params
+}
+
 async fn handle_container_exec_with_params(
     params: ExecParams,
     sess: &Session,
@@ -1469,6 +1485,7 @@ async fn handle_container_exec_with_params(
     sess.notify_exec_command_begin(&sub_id, &call_id, &params)
         .await;
 
+    let params = maybe_run_with_user_profile(params, sess);
     let output_result = process_exec_tool_call(
         params.clone(),
         sandbox_type,
diff --git a/codex-rs/core/src/config_types.rs b/codex-rs/core/src/config_types.rs
index 83fe613c86..ef835fb6e1 100644
--- a/codex-rs/core/src/config_types.rs
+++ b/codex-rs/core/src/config_types.rs
@@ -143,6 +143,8 @@ pub struct ShellEnvironmentPolicyToml {
 
     /// List of regular expressions.
     pub include_only: Option<Vec<String>>,
+
+    pub experimental_use_profile: Option<bool>,
 }
 
 pub type EnvironmentVariablePattern = WildMatchPattern<'*', '?'>;
@@ -171,6 +173,9 @@ pub struct ShellEnvironmentPolicy {
 
     /// Environment variable names to retain in the environment.
     pub include_only: Vec<EnvironmentVariablePattern>,
+
+    /// If true, the shell profile will be used to run the command.
+    pub use_profile: bool,
 }
 
 impl From<ShellEnvironmentPolicyToml> for ShellEnvironmentPolicy {
@@ -190,6 +195,7 @@ impl From<ShellEnvironmentPolicyToml> for ShellEnvironmentPolicy {
             .into_iter()
             .map(|s| EnvironmentVariablePattern::new_case_insensitive(&s))
             .collect();
+        let use_profile = toml.experimental_use_profile.unwrap_or(false);
 
         Self {
             inherit,
@@ -197,6 +203,7 @@ impl From<ShellEnvironmentPolicyToml> for ShellEnvironmentPolicy {
             exclude,
             r#set,
             include_only,
+            use_profile,
         }
     }
 }
diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs
index 4b33b0b3b5..230c4ec134 100644
--- a/codex-rs/core/src/exec.rs
+++ b/codex-rs/core/src/exec.rs
@@ -17,6 +17,7 @@ use tokio::io::BufReader;
 use tokio::process::Child;
 use tokio::process::Command;
 use tokio::sync::Notify;
+use tracing::trace;
 
 use crate::error::CodexErr;
 use crate::error::Result;
@@ -82,7 +83,8 @@ pub async fn process_exec_tool_call(
 ) -> Result<ExecToolCallOutput> {
     let start = Instant::now();
 
-    let raw_output_result = match sandbox_type {
+    let raw_output_result: std::result::Result<RawExecToolCallOutput, CodexErr> = match sandbox_type
+    {
         SandboxType::None => exec(params, sandbox_policy, ctrl_c).await,
         SandboxType::MacosSeatbelt => {
             let ExecParams {
@@ -372,6 +374,10 @@ async fn spawn_child_async(
     stdio_policy: StdioPolicy,
     env: HashMap<String, String>,
 ) -> std::io::Result<Child> {
+    trace!(
+        "spawn_child_async: {program:?} {args:?} {arg0:?} {cwd:?} {sandbox_policy:?} {stdio_policy:?} {env:?}"
+    );
+
     let mut cmd = Command::new(&program);
     #[cfg(unix)]
     cmd.arg0(arg0.map_or_else(|| program.to_string_lossy().to_string(), String::from));
diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs
index 12321e0abc..2b82a3f045 100644
--- a/codex-rs/core/src/lib.rs
+++ b/codex-rs/core/src/lib.rs
@@ -36,6 +36,7 @@ mod project_doc;
 pub mod protocol;
 mod rollout;
 mod safety;
+pub mod shell;
 mod user_notification;
 pub mod util;
 
diff --git a/codex-rs/core/src/shell.rs b/codex-rs/core/src/shell.rs
new file mode 100644
index 0000000000..463651234c
--- /dev/null
+++ b/codex-rs/core/src/shell.rs
@@ -0,0 +1,204 @@
+use shlex;
+
+#[derive(Debug, PartialEq, Eq)]
+pub struct ZshShell {
+    shell_path: String,
+    zshrc_path: String,
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub enum Shell {
+    Zsh(ZshShell),
+    Unknown,
+}
+
+impl Shell {
+    pub fn format_default_shell_invocation(&self, command: Vec<String>) -> Option<Vec<String>> {
+        match self {
+            Shell::Zsh(zsh) => {
+                if !std::path::Path::new(&zsh.zshrc_path).exists() {
+                    return None;
+                }
+
+                let mut result = vec![zsh.shell_path.clone(), "-c".to_string()];
+                if let Ok(joined) = shlex::try_join(command.iter().map(|s| s.as_str())) {
+                    result.push(format!("source {} && ({joined})", zsh.zshrc_path));
+                } else {
+                    return None;
+                }
+                Some(result)
+            }
+            Shell::Unknown => None,
+        }
+    }
+}
+
+#[cfg(target_os = "macos")]
+pub async fn default_user_shell() -> Shell {
+    use tokio::process::Command;
+    use whoami;
+
+    let user = whoami::username();
+    let home = format!("/Users/{user}");
+    let output = Command::new("dscl")
+        .args([".", "-read", &home, "UserShell"])
+        .output()
+        .await
+        .ok();
+    match output {
+        Some(o) => {
+            if !o.status.success() {
+                return Shell::Unknown;
+            }
+            let stdout = String::from_utf8_lossy(&o.stdout);
+            for line in stdout.lines() {
+                if let Some(shell_path) = line.strip_prefix("UserShell: ") {
+                    if shell_path.ends_with("/zsh") {
+                        return Shell::Zsh(ZshShell {
+                            shell_path: shell_path.to_string(),
+                            zshrc_path: format!("{home}/.zshrc"),
+                        });
+                    }
+                }
+            }
+
+            Shell::Unknown
+        }
+        _ => Shell::Unknown,
+    }
+}
+
+#[cfg(not(target_os = "macos"))]
+pub async fn default_user_shell() -> Shell {
+    Shell::Unknown
+}
+
+#[cfg(test)]
+#[cfg(target_os = "macos")]
+mod tests {
+    use super::*;
+    use std::process::Command;
+
+    #[tokio::test]
+    #[expect(clippy::unwrap_used)]
+    async fn test_current_shell_detects_zsh() {
+        let shell = Command::new("sh")
+            .arg("-c")
+            .arg("echo $SHELL")
+            .output()
+            .unwrap();
+
+        let home = std::env::var("HOME").unwrap();
+        let shell_path = String::from_utf8_lossy(&shell.stdout).trim().to_string();
+        if shell_path.ends_with("/zsh") {
+            assert_eq!(
+                default_user_shell().await,
+                Shell::Zsh(ZshShell {
+                    shell_path: shell_path.to_string(),
+                    zshrc_path: format!("{home}/.zshrc",),
+                })
+            );
+        }
+    }
+
+    #[tokio::test]
+    async fn test_run_with_profile_zshrc_not_exists() {
+        let shell = Shell::Zsh(ZshShell {
+            shell_path: "/bin/zsh".to_string(),
+            zshrc_path: "/does/not/exist/.zshrc".to_string(),
+        });
+        let actual_cmd = shell.format_default_shell_invocation(vec!["myecho".to_string()]);
+        assert_eq!(actual_cmd, None);
+    }
+
+    #[expect(clippy::unwrap_used)]
+    #[tokio::test]
+    async fn test_run_with_profile_escaping_and_execution() {
+        let shell_path = "/bin/zsh";
+
+        let cases = vec![
+            (
+                vec!["myecho"],
+                vec![shell_path, "-c", "source ZSHRC_PATH && (myecho)"],
+                Some("It works!\n"),
+            ),
+            (
+                vec!["bash", "-lc", "echo 'single' \"double\""],
+                vec![
+                    shell_path,
+                    "-c",
+                    "source ZSHRC_PATH && (bash -lc \"echo 'single' \\\"double\\\"\")",
+                ],
+                Some("single double\n"),
+            ),
+        ];
+        for (input, expected_cmd, expected_output) in cases {
+            use std::collections::HashMap;
+            use std::path::PathBuf;
+            use std::sync::Arc;
+
+            use tokio::sync::Notify;
+
+            use crate::exec::ExecParams;
+            use crate::exec::SandboxType;
+            use crate::exec::process_exec_tool_call;
+            use crate::protocol::SandboxPolicy;
+
+            // create a temp directory with a zshrc file in it
+            let temp_home = tempfile::tempdir().unwrap();
+            let zshrc_path = temp_home.path().join(".zshrc");
+            std::fs::write(
+                &zshrc_path,
+                r#"
+                    set -x
+                    function myecho {
+                        echo 'It works!'
+                    }
+                    "#,
+            )
+            .unwrap();
+            let shell = Shell::Zsh(ZshShell {
+                shell_path: shell_path.to_string(),
+                zshrc_path: zshrc_path.to_str().unwrap().to_string(),
+            });
+
+            let actual_cmd = shell
+                .format_default_shell_invocation(input.iter().map(|s| s.to_string()).collect());
+            let expected_cmd = expected_cmd
+                .iter()
+                .map(|s| {
+                    s.replace("ZSHRC_PATH", zshrc_path.to_str().unwrap())
+                        .to_string()
+                })
+                .collect();
+
+            assert_eq!(actual_cmd, Some(expected_cmd));
+            // Actually run the command and check output/exit code
+            let output = process_exec_tool_call(
+                ExecParams {
+                    command: actual_cmd.unwrap(),
+                    cwd: PathBuf::from(temp_home.path()),
+                    timeout_ms: None,
+                    env: HashMap::from([(
+                        "HOME".to_string(),
+                        temp_home.path().to_str().unwrap().to_string(),
+                    )]),
+                },
+                SandboxType::None,
+                Arc::new(Notify::new()),
+                &SandboxPolicy::DangerFullAccess,
+                &None,
+            )
+            .await
+            .unwrap();
+
+            assert_eq!(output.exit_code, 0, "input: {input:?} output: {output:?}");
+            if let Some(expected) = expected_output {
+                assert_eq!(
+                    output.stdout, expected,
+                    "input: {input:?} output: {output:?}"
+                );
+            }
+        }
+    }
+}
diff --git a/codex-rs/mcp-server/src/lib.rs b/codex-rs/mcp-server/src/lib.rs
index 79981e4992..aaf67571b4 100644
--- a/codex-rs/mcp-server/src/lib.rs
+++ b/codex-rs/mcp-server/src/lib.rs
@@ -13,6 +13,7 @@ use tokio::sync::mpsc;
 use tracing::debug;
 use tracing::error;
 use tracing::info;
+use tracing_subscriber::EnvFilter;
 
 mod codex_tool_config;
 mod codex_tool_runner;
@@ -43,6 +44,7 @@ pub async fn run_main(codex_linux_sandbox_exe: Option<PathBuf>) -> IoResult<()>
     // control the log level with `RUST_LOG`.
     tracing_subscriber::fmt()
         .with_writer(std::io::stderr)
+        .with_env_filter(EnvFilter::from_default_env())
         .init();
 
     // Set up channels.

Review Comments

codex-rs/core/Cargo.toml

@@ -47,6 +47,8 @@ tree-sitter = "0.25.8"
 tree-sitter-bash = "0.25.0"
 uuid = { version = "1", features = ["serde", "v4"] }
 wildmatch = "2.4.0"
+whoami = "1.6.0"
+shlex = "1.3.0"

alpha sort?

codex-rs/core/src/codex.rs

@@ -204,6 +205,7 @@ pub(crate) struct Session {
     rollout: Mutex<Option<RolloutRecorder>>,
     state: Mutex<State>,
     codex_linux_sandbox_exe: Option<PathBuf>,
+    user_shell: Option<shell::Shell>,

Should this be non-Option and be Unknown instead of None?

@@ -1469,8 +1483,9 @@ async fn handle_container_exec_with_params(
     sess.notify_exec_command_begin(&sub_id, &call_id, &params)
         .await;
 
+    let processed_params = maybe_run_with_shell(params.clone(), sess);

Consider just shadowing, which avoids clone?

    let params = maybe_run_with_shell(params.clone(), sess);

codex-rs/core/src/config_types.rs

@@ -171,6 +173,9 @@ pub struct ShellEnvironmentPolicy {
 
     /// Environment variable names to retain in the environment.
     pub include_only: Vec<EnvironmentVariablePattern>,
+
+    /// If true, the shell profile will be used to run the command.
+    pub use_profile: bool,

Currently, I am tempted to get rid of ShellEnvironmentPolicy altogether and make the user responsible for sanitizing the environment with which they invoke codex. Or at least, make the default behavior to inherit the current environment and the user has to take action to configure something else.

Thoughts?

codex-rs/core/src/shell.rs

@@ -0,0 +1,125 @@
+use shlex;
+use std::process::Command;
+use whoami;
+
+#[derive(Debug, PartialEq, Eq)]
+pub enum Shell {
+    Zsh(String),
+    Unknown,
+}
+
+impl Shell {
+    pub fn run_with_profile(&self, command: Vec<String>) -> Option<Vec<String>> {
+        match self {
+            Shell::Zsh(shell_path) => {
+                let mut result = vec![shell_path.clone(), "-c".to_string()];
+                if let Ok(joined) = shlex::try_join(command.iter().map(|s| s.as_str())) {
+                    result.push(format!("source ~/.zshrc && ({joined})"));
+                } else {
+                    return None;
+                }
+                Some(result)
+            }
+            Shell::Unknown => None,
+        }
+    }
+}
+
+#[cfg(target_os = "macos")]
+pub fn current_shell() -> Option<Shell> {
+    let user = whoami::username();
+    let output = Command::new("dscl")
+        .args([".", "-read", &format!("/Users/{user}"), "UserShell"])
+        .output()
+        .ok()?;
+    if !output.status.success() {
+        return Some(Shell::Unknown);
+    }
+    let stdout = String::from_utf8_lossy(&output.stdout);
+    for line in stdout.lines() {
+        if let Some(shell_path) = line.strip_prefix("UserShell: ") {
+            if shell_path.ends_with("/zsh") {
+                return Some(Shell::Zsh(shell_path.to_string()));
+            } else {
+                return Some(Shell::Unknown);
+            }
+        }
+    }
+    Some(Shell::Unknown)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use std::process::Command;
+
+    #[test]
+    #[cfg(target_os = "macos")]
+    #[expect(clippy::unwrap_used)]
+    fn test_current_shell_detects_zsh() {
+        let output = Command::new("sh")
+            .arg("-c")
+            .arg("echo $SHELL")
+            .output()
+            .unwrap();
+        let shell_path = String::from_utf8_lossy(&output.stdout).trim().to_string();
+        if shell_path.ends_with("/zsh") {
+            assert_eq!(current_shell(), Some(Shell::Zsh(shell_path)));
+        }
+    }
+
+    #[cfg(target_os = "macos")]
+    #[expect(clippy::unwrap_used)]
+    #[tokio::test]
+    async fn test_run_with_profile_escaping_and_execution() {
+        let shell_path = "/bin/zsh";
+        let shell = Shell::Zsh(shell_path.to_string());
+        let cases = vec![(
+            vec!["bash", "-lc", "echo 'single' \"double\""],
+            vec![
+                shell_path,
+                "-c",
+                "source ~/.zshrc && (bash -lc \"echo 'single' \\\"double\\\"\")",

I don't think this is a safe thing to run in tests.

Maybe we should be using https://docs.rs/dirs/6.0.0/dirs/fn.home_dir.html to construct the path to ~/.zshrc and then you can run this test with HOME set to a temp dir?

@@ -0,0 +1,125 @@
+use shlex;
+use std::process::Command;
+use whoami;
+
+#[derive(Debug, PartialEq, Eq)]
+pub enum Shell {
+    Zsh(String),
+    Unknown,
+}
+
+impl Shell {
+    pub fn run_with_profile(&self, command: Vec<String>) -> Option<Vec<String>> {
+        match self {
+            Shell::Zsh(shell_path) => {
+                let mut result = vec![shell_path.clone(), "-c".to_string()];
+                if let Ok(joined) = shlex::try_join(command.iter().map(|s| s.as_str())) {
+                    result.push(format!("source ~/.zshrc && ({joined})"));
+                } else {
+                    return None;
+                }
+                Some(result)
+            }
+            Shell::Unknown => None,
+        }
+    }
+}
+
+#[cfg(target_os = "macos")]
+pub fn current_shell() -> Option<Shell> {
+    let user = whoami::username();

We should probably check $SHELL first and then do this as a fallback?

@@ -0,0 +1,125 @@
+use shlex;
+use std::process::Command;
+use whoami;
+
+#[derive(Debug, PartialEq, Eq)]
+pub enum Shell {
+    Zsh(String),
+    Unknown,
+}
+
+impl Shell {
+    pub fn run_with_profile(&self, command: Vec<String>) -> Option<Vec<String>> {
+        match self {
+            Shell::Zsh(shell_path) => {
+                let mut result = vec![shell_path.clone(), "-c".to_string()];
+                if let Ok(joined) = shlex::try_join(command.iter().map(|s| s.as_str())) {
+                    result.push(format!("source ~/.zshrc && ({joined})"));
+                } else {
+                    return None;
+                }
+                Some(result)
+            }
+            Shell::Unknown => None,
+        }
+    }
+}
+
+#[cfg(target_os = "macos")]
+pub fn current_shell() -> Option<Shell> {
+    let user = whoami::username();
+    let output = Command::new("dscl")

Consider tokio::Command and use async on the off chance invoking dscl gets wedged?

@@ -0,0 +1,125 @@
+use shlex;
+use std::process::Command;
+use whoami;
+
+#[derive(Debug, PartialEq, Eq)]
+pub enum Shell {
+    Zsh(String),
+    Unknown,
+}
+
+impl Shell {
+    pub fn run_with_profile(&self, command: Vec<String>) -> Option<Vec<String>> {
+        match self {
+            Shell::Zsh(shell_path) => {
+                let mut result = vec![shell_path.clone(), "-c".to_string()];
+                if let Ok(joined) = shlex::try_join(command.iter().map(|s| s.as_str())) {
+                    result.push(format!("source ~/.zshrc && ({joined})"));

Is ~/.zshrc required to exist? Should ; be used instead of && to ignore a failure in that case?

@@ -0,0 +1,125 @@
+use shlex;
+use std::process::Command;
+use whoami;
+
+#[derive(Debug, PartialEq, Eq)]
+pub enum Shell {
+    Zsh(String),
+    Unknown,
+}
+
+impl Shell {
+    pub fn run_with_profile(&self, command: Vec<String>) -> Option<Vec<String>> {
+        match self {
+            Shell::Zsh(shell_path) => {
+                let mut result = vec![shell_path.clone(), "-c".to_string()];
+                if let Ok(joined) = shlex::try_join(command.iter().map(|s| s.as_str())) {
+                    result.push(format!("source ~/.zshrc && ({joined})"));

A well-behaved ~/.zshrc should not do this, but it could write to stdout/stderr and interfere with the tool call output, no?

@@ -0,0 +1,125 @@
+use shlex;
+use std::process::Command;
+use whoami;
+
+#[derive(Debug, PartialEq, Eq)]
+pub enum Shell {
+    Zsh(String),
+    Unknown,
+}
+
+impl Shell {
+    pub fn run_with_profile(&self, command: Vec<String>) -> Option<Vec<String>> {
+        match self {
+            Shell::Zsh(shell_path) => {
+                let mut result = vec![shell_path.clone(), "-c".to_string()];
+                if let Ok(joined) = shlex::try_join(command.iter().map(|s| s.as_str())) {
+                    result.push(format!("source ~/.zshrc && ({joined})"));

Out of curiosity, are the parens around necessary?

Though I did try the following and it still exits 42, at least:

ls && (python -c 'import sys; sys.exit(42)')
@@ -0,0 +1,203 @@
+use shlex;
+
+#[derive(Debug, PartialEq, Eq)]
+pub struct ZshShell {
+    shell_path: String,
+    zshrc_path: String,
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub enum Shell {
+    Zsh(ZshShell),
+    Unknown,
+}
+
+impl Shell {
+    pub fn run_with_profile(&self, command: Vec<String>) -> Option<Vec<String>> {
+        match self {
+            Shell::Zsh(zsh) => {
+                if !std::path::Path::new(&zsh.zshrc_path).exists() {
+                    return None;
+                }
+
+                let mut result = vec![zsh.shell_path.clone(), "-c".to_string()];
+                if let Ok(joined) = shlex::try_join(command.iter().map(|s| s.as_str())) {
+                    result.push(format!("source {} && ({joined})", zsh.zshrc_path));
+                } else {
+                    return None;
+                }
+                Some(result)
+            }
+            Shell::Unknown => None,
+        }
+    }
+}
+
+#[cfg(target_os = "macos")]
+pub async fn default_user_shell() -> Shell {
+    use tokio::process::Command;
+    use whoami;
+
+    let user = whoami::username();
+    let home = format!("/Users/{user}");
+    let output = Command::new("dscl")
+        .args([".", "-read", &home, "UserShell"])
+        .output()
+        .await
+        .ok();
+    match output {
+        Some(o) => {
+            if !o.status.success() {
+                return Shell::Unknown;
+            }
+            let stdout = String::from_utf8_lossy(&o.stdout);
+            for line in stdout.lines() {
+                if let Some(shell_path) = line.strip_prefix("UserShell: ") {
+                    if shell_path.ends_with("/zsh") {
+                        return Shell::Zsh(ZshShell {
+                            shell_path: shell_path.to_string(),
+                            zshrc_path: format!("{home}/.zshrc"),
+                        });
+                    }
+                }
+            }
+
+            Shell::Unknown
+        }
+        _ => Shell::Unknown,
+    }
+}
+
+#[cfg(not(target_os = "macos"))]
+pub async fn default_user_shell() -> Shell {
+    Shell::Unknown
+}
+
+#[cfg(test)]
+#[cfg(target_os = "macos")]
+mod tests {
+    use super::*;
+    use std::process::Command;
+
+    #[tokio::test]
+    #[expect(clippy::unwrap_used)]
+    async fn test_current_shell_detects_zsh() {
+        let shell = Command::new("sh")
+            .arg("-c")
+            .arg("echo $SHELL")
+            .output()
+            .unwrap();
+
+        let home = std::env::var("HOME").unwrap();
+        let shell_path = String::from_utf8_lossy(&shell.stdout).trim().to_string();
+        if shell_path.ends_with("/zsh") {
+            assert_eq!(

I guess it is fair to assume this test should pass on an arbitrary Mac?

@@ -0,0 +1,203 @@
+use shlex;
+
+#[derive(Debug, PartialEq, Eq)]
+pub struct ZshShell {
+    shell_path: String,
+    zshrc_path: String,
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub enum Shell {
+    Zsh(ZshShell),
+    Unknown,
+}
+
+impl Shell {
+    pub fn run_with_profile(&self, command: Vec<String>) -> Option<Vec<String>> {

This doesn't actually "run" anything: it just rewrites the command? Maybe create_command_shell_invocation()? I don't know, naming is hard...