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

908 lines
27 KiB
Markdown

# PR #1678: Optionally run using user profile
- URL: https://github.com/openai/codex/pull/1678
- Author: pakrym-oai
- Created: 2025-07-25 01:27:01 UTC
- Updated: 2025-07-25 18:45:31 UTC
- Changes: +260/-1, Files changed: 8, Commits: 8
## Description
(No description.)
## Full Diff
```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
- Created: 2025-07-25 18:28:59 UTC | Link: https://github.com/openai/codex/pull/1678#discussion_r2231771262
```diff
@@ -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
- Created: 2025-07-25 05:06:29 UTC | Link: https://github.com/openai/codex/pull/1678#discussion_r2230147821
```diff
@@ -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`?
- Created: 2025-07-25 05:07:50 UTC | Link: https://github.com/openai/codex/pull/1678#discussion_r2230149272
```diff
@@ -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`?
>
> ```suggestion
> let params = maybe_run_with_shell(params.clone(), sess);
> ```
### codex-rs/core/src/config_types.rs
- Created: 2025-07-25 05:17:56 UTC | Link: https://github.com/openai/codex/pull/1678#discussion_r2230159268
```diff
@@ -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
- Created: 2025-07-25 04:56:21 UTC | Link: https://github.com/openai/codex/pull/1678#discussion_r2230137095
```diff
@@ -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?
- Created: 2025-07-25 05:03:35 UTC | Link: https://github.com/openai/codex/pull/1678#discussion_r2230145115
```diff
@@ -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?
- Created: 2025-07-25 05:05:26 UTC | Link: https://github.com/openai/codex/pull/1678#discussion_r2230146829
```diff
@@ -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?
- Created: 2025-07-25 05:16:21 UTC | Link: https://github.com/openai/codex/pull/1678#discussion_r2230157756
```diff
@@ -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?
- Created: 2025-07-25 05:22:37 UTC | Link: https://github.com/openai/codex/pull/1678#discussion_r2230164598
```diff
@@ -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?
- Created: 2025-07-25 05:27:20 UTC | Link: https://github.com/openai/codex/pull/1678#discussion_r2230170271
```diff
@@ -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)')
> ```
- Created: 2025-07-25 18:33:06 UTC | Link: https://github.com/openai/codex/pull/1678#discussion_r2231777388
```diff
@@ -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?
- Created: 2025-07-25 18:35:41 UTC | Link: https://github.com/openai/codex/pull/1678#discussion_r2231780950
```diff
@@ -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...