mirror of
https://github.com/openai/codex.git
synced 2026-04-28 18:32:04 +03:00
908 lines
27 KiB
Markdown
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, ¶ms)
|
|
.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, ¶ms)
|
|
.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... |