Compare commits

...

26 Commits

Author SHA1 Message Date
jimmyfraiture
7df5ea7baa Fix linter 2025-09-11 19:05:09 -07:00
jimmyfraiture
65bd8965c9 Fix the race condition 2025-09-11 16:29:47 -07:00
jimmyfraiture
5acf9c7703 FMT 2025-09-10 17:25:22 -07:00
jimmyfraiture
c082f0d168 Using tokio watch instead 2025-09-10 14:03:18 -07:00
jimmyfraiture
c39316bdb9 rc path fix 2025-09-08 17:54:28 -07:00
jimmyfraiture
81e624741b Drop unstable test 2025-09-08 12:33:31 -07:00
jimmyfraiture
896b1ab4ff NIT 4 2025-09-08 12:05:31 -07:00
jimmyfraiture
226d223035 Move test 2025-09-08 12:00:32 -07:00
jimmyfraiture
ba73ddb30c NIT 2 2025-09-08 11:52:42 -07:00
jimmyfraiture
f61c2fb704 NIT 1 2025-09-08 11:51:34 -07:00
jimmyfraiture
022c23f529 R3 2025-09-08 11:39:02 -07:00
jimmyfraiture
08536d7acc R2 2025-09-08 11:26:10 -07:00
jimmyfraiture
3e6c6ffd7e R1 2025-09-08 11:17:28 -07:00
jif-oai
917cb9b948 Merge branch 'main' into jif-oai/macos-snapshots 2025-09-08 10:56:11 -07:00
jimmyfraiture
b8cec9d1e3 Redef 2025-09-05 10:42:18 -07:00
jimmyfraiture
be45b979f9 Unify into POSIX 2025-09-05 10:39:21 -07:00
jimmyfraiture
badc7ec54a Fix the merge 2025-09-05 10:12:26 -07:00
jimmyfraiture
60f980f70a Merge remote-tracking branch 'origin/main' into jif-oai/macos-snapshots
# Conflicts:
#	codex-rs/core/src/shell.rs
2025-09-05 09:55:37 -07:00
jimmyfraiture
154fd14d1d .zshrc not included in bash -l 2025-09-04 16:09:22 -07:00
jimmyfraiture
7b7b938053 Fmt 2025-09-04 15:19:33 -07:00
jimmyfraiture
ea0ed2a4d8 Merge remote-tracking branch 'origin/jif-oai/macos-snapshots' into jif-oai/macos-snapshots 2025-09-04 14:46:52 -07:00
jimmyfraiture
63b52b159f Process comments 2025-09-04 14:46:12 -07:00
jif-oai
81451f4246 Update codex-rs/core/src/shell.rs
Co-authored-by: Michael Bolin <mbolin@openai.com>
2025-09-04 13:39:55 -07:00
jimmyfraiture
c6aba86fdf NIT 2025-09-04 13:22:03 -07:00
jif-oai
01d2752c40 Merge branch 'main' into jif-oai/macos-snapshots 2025-09-04 12:17:50 -07:00
jimmyfraiture
18dd539c48 MacOS snapshots 2025-09-04 12:14:17 -07:00
3 changed files with 515 additions and 129 deletions

View File

@@ -392,7 +392,7 @@ impl Session {
let rollout_fut = RolloutRecorder::new(&config, conversation_id, user_instructions.clone());
let mcp_fut = McpConnectionManager::new(config.mcp_servers.clone());
let default_shell_fut = shell::default_user_shell();
let default_shell_fut = shell::default_user_shell(conversation_id.0, &config.codex_home);
let history_meta_fut = crate::message_history::history_metadata(&config);
// Join all independent futures.
@@ -464,6 +464,7 @@ impl Session {
shell_environment_policy: config.shell_environment_policy.clone(),
cwd,
};
let sess = Arc::new(Session {
conversation_id,
tx_event: tx_event.clone(),
@@ -2297,24 +2298,59 @@ pub struct ExecInvokeArgs<'a> {
pub stdout_stream: Option<StdoutStream>,
}
fn maybe_translate_shell_command(
params: ExecParams,
fn should_translate_shell_command(
shell: &crate::shell::Shell,
shell_policy: &ShellEnvironmentPolicy,
) -> bool {
matches!(shell, crate::shell::Shell::PowerShell(_))
|| shell_policy.use_profile
|| matches!(
shell,
crate::shell::Shell::Posix(shell)
if !shell.shell_snapshot.borrow().is_unavailable()
)
}
async fn maybe_translate_shell_command(
mut params: ExecParams,
sess: &Session,
turn_context: &TurnContext,
) -> ExecParams {
let should_translate = matches!(sess.user_shell, crate::shell::Shell::PowerShell(_))
|| turn_context.shell_environment_policy.use_profile;
let should_translate =
should_translate_shell_command(&sess.user_shell, &turn_context.shell_environment_policy);
if should_translate
&& let Some(command) = sess
.user_shell
.format_default_shell_invocation(params.command.clone())
{
return ExecParams { command, ..params };
if !should_translate {
return params;
}
if let crate::shell::Shell::Posix(shell) = &sess.user_shell
&& shell.shell_snapshot.borrow().is_pending()
{
wait_for_shell_snapshot(shell).await;
}
let original_command = std::mem::take(&mut params.command);
params.command = sess
.user_shell
.format_default_shell_invocation(&original_command)
.unwrap_or(original_command);
params
}
async fn wait_for_shell_snapshot(shell: &crate::shell::PosixShell) {
if !shell.shell_snapshot.borrow().is_pending() {
return;
}
let mut rx = shell.shell_snapshot.clone();
while rx.changed().await.is_ok() {
if !rx.borrow().is_pending() {
break;
}
}
}
async fn handle_container_exec_with_params(
params: ExecParams,
sess: &Session,
@@ -2475,7 +2511,7 @@ async fn handle_container_exec_with_params(
),
};
let params = maybe_translate_shell_command(params, sess, turn_context);
let params = maybe_translate_shell_command(params, sess, turn_context).await;
let output_result = sess
.run_exec_with_events(
turn_diff_tracker,
@@ -2920,10 +2956,15 @@ fn convert_call_tool_result_to_function_call_output_payload(
#[cfg(test)]
mod tests {
use super::*;
use crate::config_types::ShellEnvironmentPolicyInherit;
use mcp_types::ContentBlock;
use mcp_types::TextContent;
use pretty_assertions::assert_eq;
use serde_json::json;
use shell::ShellSnapshot;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration as StdDuration;
fn text_block(s: &str) -> ContentBlock {
@@ -2934,6 +2975,56 @@ mod tests {
})
}
fn shell_policy_with_profile(use_profile: bool) -> ShellEnvironmentPolicy {
ShellEnvironmentPolicy {
inherit: ShellEnvironmentPolicyInherit::All,
ignore_default_excludes: false,
exclude: Vec::new(),
r#set: HashMap::new(),
include_only: Vec::new(),
use_profile,
}
}
fn zsh_shell(shell_snapshot: shell::ShellSnapshotState) -> shell::Shell {
let (_tx, rx) = tokio::sync::watch::channel(shell_snapshot);
shell::Shell::Posix(shell::PosixShell {
shell_path: "/bin/zsh".to_string(),
rc_path: "/Users/example/.zshrc".to_string(),
shell_snapshot: rx,
})
}
#[test]
fn translates_commands_when_shell_policy_requests_profile() {
let policy = shell_policy_with_profile(true);
let shell = zsh_shell(shell::ShellSnapshotState::Unavailable);
assert!(should_translate_shell_command(&shell, &policy));
}
#[test]
fn translates_commands_for_zsh_with_snapshot() {
let policy = shell_policy_with_profile(false);
let shell = zsh_shell(shell::ShellSnapshotState::Ready(Arc::new(
ShellSnapshot::new(PathBuf::from("/tmp/snapshot")),
)));
assert!(should_translate_shell_command(&shell, &policy));
}
#[test]
fn bypasses_translation_for_zsh_without_snapshot_or_profile() {
let policy = shell_policy_with_profile(false);
let shell = zsh_shell(shell::ShellSnapshotState::Unavailable);
assert!(!should_translate_shell_command(&shell, &policy));
}
#[test]
fn translates_commands_for_zsh_with_pending_snapshot() {
let policy = shell_policy_with_profile(false);
let shell = zsh_shell(shell::ShellSnapshotState::Pending);
assert!(should_translate_shell_command(&shell, &policy));
}
#[test]
fn prefers_structured_content_when_present() {
let ctr = CallToolResult {

View File

@@ -1,20 +1,90 @@
use serde::Deserialize;
use serde::Serialize;
use shlex;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use tracing::trace;
use uuid::Uuid;
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
pub struct ZshShell {
shell_path: String,
zshrc_path: String,
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
/// This structure cannot derive Clone or this will break the Drop implementation.
pub struct ShellSnapshot {
pub(crate) path: PathBuf,
}
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
pub struct BashShell {
shell_path: String,
bashrc_path: String,
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ShellSnapshotState {
Pending,
Ready(Arc<ShellSnapshot>),
Unavailable,
}
impl ShellSnapshotState {
pub fn is_pending(&self) -> bool {
matches!(self, Self::Pending)
}
pub fn is_unavailable(&self) -> bool {
matches!(self, Self::Unavailable)
}
pub fn snapshot(&self) -> Option<&Arc<ShellSnapshot>> {
match self {
Self::Ready(snapshot) => Some(snapshot),
Self::Pending | Self::Unavailable => None,
}
}
pub fn into_snapshot(self) -> Option<Arc<ShellSnapshot>> {
match self {
Self::Ready(snapshot) => Some(snapshot),
Self::Pending | Self::Unavailable => None,
}
}
}
// Usage of fully qualified names for the Receiver for clarity.
type ShellSnapshotRx = tokio::sync::watch::Receiver<ShellSnapshotState>;
pub fn default_shell_snapshot_rx() -> ShellSnapshotRx {
let (_tx, rx) = tokio::sync::watch::channel(ShellSnapshotState::Unavailable);
rx
}
impl ShellSnapshot {
pub fn new(path: PathBuf) -> Self {
Self { path }
}
}
impl Drop for ShellSnapshot {
fn drop(&mut self) {
delete_shell_snapshot(&self.path);
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PosixShell {
pub(crate) shell_path: String,
pub(crate) rc_path: String,
#[serde(
skip_serializing,
skip_deserializing,
default = "default_shell_snapshot_rx"
)]
pub(crate) shell_snapshot: ShellSnapshotRx,
}
impl PartialEq for PosixShell {
fn eq(&self, other: &Self) -> bool {
self.shell_path == other.shell_path && self.rc_path == other.rc_path
// deliberately ignore `shell_snapshot` as the RX does not implement PartialEq.
}
}
// Rely on the default implementation.
impl Eq for PosixShell {}
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
pub struct PowerShellConfig {
exe: String, // Executable name or path, e.g. "pwsh" or "powershell.exe".
@@ -23,24 +93,37 @@ pub struct PowerShellConfig {
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
pub enum Shell {
Zsh(ZshShell),
Bash(BashShell),
Posix(PosixShell),
PowerShell(PowerShellConfig),
Unknown,
}
impl Shell {
pub fn format_default_shell_invocation(&self, command: Vec<String>) -> Option<Vec<String>> {
pub fn format_default_shell_invocation(&self, command: &[String]) -> Option<Vec<String>> {
match self {
Shell::Zsh(zsh) => {
format_shell_invocation_with_rc(&command, &zsh.shell_path, &zsh.zshrc_path)
}
Shell::Bash(bash) => {
format_shell_invocation_with_rc(&command, &bash.shell_path, &bash.bashrc_path)
Shell::Posix(shell) => {
let joined = strip_bash_lc(command)
.or_else(|| shlex::try_join(command.iter().map(|s| s.as_str())).ok())?;
let snapshot_state = shell.shell_snapshot.borrow();
let (source_path, session_cmd) = if let Some(snapshot) = snapshot_state.snapshot()
&& snapshot.path.exists()
{
(snapshot.path.clone(), "-c".to_string())
} else {
(PathBuf::from(&shell.rc_path), "-lc".to_string())
};
let source_path_str = source_path.to_string_lossy().to_string();
let quoted_source_path = shlex::try_quote(&source_path_str).ok()?;
let rc_command =
format!("[ -f {quoted_source_path} ] && . {quoted_source_path}; ({joined})");
Some(vec![shell.shell_path.clone(), session_cmd, rc_command])
}
Shell::PowerShell(ps) => {
// If model generated a bash command, prefer a detected bash fallback
if let Some(script) = strip_bash_lc(&command) {
if let Some(script) = strip_bash_lc(command) {
return match &ps.bash_exe_fallback {
Some(bash) => Some(vec![
bash.to_string_lossy().to_string(),
@@ -66,7 +149,7 @@ impl Shell {
if first != Some(ps.exe.as_str()) {
// TODO (CODEX_2900): Handle escaping newlines.
if command.iter().any(|a| a.contains('\n') || a.contains('\r')) {
return Some(command);
return Some(command.to_vec());
}
let joined = shlex::try_join(command.iter().map(|s| s.as_str())).ok();
@@ -81,7 +164,7 @@ impl Shell {
}
// Model generated a PowerShell command. Run it.
Some(command)
Some(command.to_vec())
}
Shell::Unknown => None,
}
@@ -89,37 +172,24 @@ impl Shell {
pub fn name(&self) -> Option<String> {
match self {
Shell::Zsh(zsh) => std::path::Path::new(&zsh.shell_path)
.file_name()
.map(|s| s.to_string_lossy().to_string()),
Shell::Bash(bash) => std::path::Path::new(&bash.shell_path)
Shell::Posix(shell) => Path::new(&shell.shell_path)
.file_name()
.map(|s| s.to_string_lossy().to_string()),
Shell::PowerShell(ps) => Some(ps.exe.clone()),
Shell::Unknown => None,
}
}
pub fn get_snapshot(&self) -> Option<Arc<ShellSnapshot>> {
match self {
Shell::Posix(shell) => shell.shell_snapshot.borrow().snapshot().cloned(),
_ => None,
}
}
}
fn format_shell_invocation_with_rc(
command: &Vec<String>,
shell_path: &str,
rc_path: &str,
) -> Option<Vec<String>> {
let joined = strip_bash_lc(command)
.or_else(|| shlex::try_join(command.iter().map(|s| s.as_str())).ok())?;
let rc_command = if std::path::Path::new(rc_path).exists() {
format!("source {rc_path} && ({joined})")
} else {
joined
};
Some(vec![shell_path.to_string(), "-lc".to_string(), rc_command])
}
fn strip_bash_lc(command: &Vec<String>) -> Option<String> {
match command.as_slice() {
fn strip_bash_lc(command: &[String]) -> Option<String> {
match command {
// exactly three items
[first, second, third]
// first two must be "bash", "-lc"
@@ -132,7 +202,7 @@ fn strip_bash_lc(command: &Vec<String>) -> Option<String> {
}
#[cfg(unix)]
fn detect_default_user_shell() -> Shell {
async fn detect_default_user_shell(session_id: Uuid, codex_home: &Path) -> Shell {
use libc::getpwuid;
use libc::getuid;
use std::ffi::CStr;
@@ -147,31 +217,60 @@ fn detect_default_user_shell() -> Shell {
.into_owned();
let home_path = CStr::from_ptr((*pw).pw_dir).to_string_lossy().into_owned();
if shell_path.ends_with("/zsh") {
return Shell::Zsh(ZshShell {
shell_path,
zshrc_path: format!("{home_path}/.zshrc"),
let rc_path = if shell_path.ends_with("/zsh") {
format!("{home_path}/.zshrc")
} else if shell_path.ends_with("/bash") {
format!("{home_path}/.bashrc")
} else {
return Shell::Unknown;
};
let (tx, rx) = tokio::sync::watch::channel(ShellSnapshotState::Pending);
{
let shell_path = shell_path.clone();
let rc_path = rc_path.clone();
let codex_home = codex_home.to_path_buf();
tokio::spawn(async move {
let snapshot_path = snapshots::ensure_posix_snapshot(
&shell_path,
&rc_path,
Path::new(&home_path),
codex_home.as_path(),
session_id,
)
.await;
if snapshot_path.is_none() {
trace!("failed to prepare posix snapshot; using live profile");
}
let snapshot_state = snapshot_path
.map(|snapshot| {
ShellSnapshotState::Ready(Arc::new(ShellSnapshot::new(snapshot)))
})
.unwrap_or(ShellSnapshotState::Unavailable);
if tx.send(snapshot_state).is_err() {
trace!("failed to send posix snapshot; using live profile");
}
});
}
if shell_path.ends_with("/bash") {
return Shell::Bash(BashShell {
shell_path,
bashrc_path: format!("{home_path}/.bashrc"),
});
}
return Shell::Posix(PosixShell {
shell_path,
rc_path,
shell_snapshot: rx,
});
}
}
Shell::Unknown
}
#[cfg(unix)]
pub async fn default_user_shell() -> Shell {
detect_default_user_shell()
pub async fn default_user_shell(session_id: Uuid, codex_home: &Path) -> Shell {
detect_default_user_shell(session_id, codex_home).await
}
#[cfg(target_os = "windows")]
pub async fn default_user_shell() -> Shell {
pub async fn default_user_shell(_session_id: Uuid, _codex_home: &Path) -> Shell {
use tokio::process::Command;
// Prefer PowerShell 7+ (`pwsh`) if available, otherwise fall back to Windows PowerShell.
@@ -211,67 +310,167 @@ pub async fn default_user_shell() -> Shell {
}
#[cfg(all(not(target_os = "windows"), not(unix)))]
pub async fn default_user_shell() -> Shell {
pub async fn default_user_shell(_session_id: Uuid, _codex_home: &Path) -> Shell {
Shell::Unknown
}
#[cfg(unix)]
mod snapshots {
use super::*;
fn zsh_profile_paths(home: &Path) -> Vec<PathBuf> {
[".zshenv", ".zprofile", ".zshrc", ".zlogin"]
.into_iter()
.map(|name| home.join(name))
.collect()
}
fn posix_profile_source_script(home: &Path) -> String {
zsh_profile_paths(home)
.into_iter()
.map(|profile| {
let profile_string = profile.to_string_lossy().into_owned();
let quoted = shlex::try_quote(&profile_string)
.map(|cow| cow.into_owned())
.unwrap_or(profile_string.clone());
format!("[ -f {quoted} ] && . {quoted}")
})
.collect::<Vec<_>>()
.join("; ")
}
pub(crate) async fn ensure_posix_snapshot(
shell_path: &str,
rc_path: &str,
home: &Path,
codex_home: &Path,
session_id: Uuid,
) -> Option<PathBuf> {
let snapshot_path = codex_home.join(format!("shell_snapshots/snapshot_{session_id}.zsh"));
// Check if an update in the profile requires to re-generate the snapshot.
let snapshot_is_stale = async {
let snapshot_metadata = tokio::fs::metadata(&snapshot_path).await.ok()?;
let snapshot_modified = snapshot_metadata.modified().ok()?;
for profile in zsh_profile_paths(home) {
let Ok(profile_metadata) = tokio::fs::metadata(&profile).await else {
continue;
};
let Ok(profile_modified) = profile_metadata.modified() else {
return Some(true);
};
if profile_modified > snapshot_modified {
return Some(true);
}
}
Some(false)
}
.await
.unwrap_or(true);
if !snapshot_is_stale {
return Some(snapshot_path);
}
match regenerate_posix_snapshot(shell_path, rc_path, home, &snapshot_path).await {
Ok(()) => Some(snapshot_path),
Err(err) => {
tracing::warn!("failed to generate posix snapshot: {err}");
None
}
}
}
async fn regenerate_posix_snapshot(
shell_path: &str,
rc_path: &str,
home: &Path,
snapshot_path: &Path,
) -> std::io::Result<()> {
// Use `emulate -L sh` instead of `set -o posix` so we work on zsh builds
// that disable that option. Guard `alias -p` with `|| true` so the script
// keeps a zero exit status even if aliases are disabled.
let mut capture_script = String::new();
let profile_sources = posix_profile_source_script(home);
if !profile_sources.is_empty() {
capture_script.push_str(&format!("{profile_sources}; "));
}
let zshrc = home.join(rc_path);
capture_script.push_str(
&format!(". {}; setopt posixbuiltins; export -p; {{ alias | sed 's/^/alias /'; }} 2>/dev/null || true", zshrc.display()),
);
let output = tokio::process::Command::new(shell_path)
.arg("-lc")
.arg(capture_script)
.env("HOME", home)
.output()
.await?;
if !output.status.success() {
return Err(std::io::Error::other(format!(
"snapshot capture exited with status {}",
output.status
)));
}
let mut contents = String::from("# Generated by Codex. Do not edit.\n");
contents.push_str(&String::from_utf8_lossy(&output.stdout));
contents.push('\n');
if let Some(parent) = snapshot_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let tmp_path = snapshot_path.with_extension("tmp");
tokio::fs::write(&tmp_path, contents).await?;
// Restrict the snapshot to user read/write so that environment variables or aliases
// that may contain secrets are not exposed to other users on the system.
use std::os::unix::fs::PermissionsExt;
let permissions = std::fs::Permissions::from_mode(0o600);
tokio::fs::set_permissions(&tmp_path, permissions).await?;
tokio::fs::rename(&tmp_path, snapshot_path).await?;
Ok(())
}
}
pub(crate) fn delete_shell_snapshot(path: &Path) {
if let Err(err) = std::fs::remove_file(path) {
trace!("failed to delete shell snapshot {path:?}: {err}");
}
}
#[cfg(test)]
#[cfg(unix)]
mod tests {
pub(crate) mod tests {
use super::*;
use std::process::Command;
#[tokio::test]
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",),
})
);
}
}
use std::path::PathBuf;
#[tokio::test]
async fn test_run_with_profile_zshrc_not_exists() {
let shell = Shell::Zsh(ZshShell {
let shell = Shell::Posix(PosixShell {
shell_path: "/bin/zsh".to_string(),
zshrc_path: "/does/not/exist/.zshrc".to_string(),
rc_path: "/does/not/exist/.zshrc".to_string(),
shell_snapshot: default_shell_snapshot_rx(),
});
let actual_cmd = shell.format_default_shell_invocation(vec!["myecho".to_string()]);
let command = vec!["myecho".to_string()];
let actual_cmd = shell.format_default_shell_invocation(&command);
assert_eq!(
actual_cmd,
Some(vec![
"/bin/zsh".to_string(),
"-lc".to_string(),
"myecho".to_string()
])
);
}
#[tokio::test]
async fn test_run_with_profile_bashrc_not_exists() {
let shell = Shell::Bash(BashShell {
shell_path: "/bin/bash".to_string(),
bashrc_path: "/does/not/exist/.bashrc".to_string(),
});
let actual_cmd = shell.format_default_shell_invocation(vec!["myecho".to_string()]);
assert_eq!(
actual_cmd,
Some(vec![
"/bin/bash".to_string(),
"-lc".to_string(),
"myecho".to_string()
"[ -f /does/not/exist/.zshrc ] && . /does/not/exist/.zshrc; (myecho)".to_string(),
])
);
}
@@ -283,7 +482,11 @@ mod tests {
let cases = vec![
(
vec!["myecho"],
vec![shell_path, "-lc", "source BASHRC_PATH && (myecho)"],
vec![
shell_path,
"-lc",
"[ -f BASHRC_PATH ] && . BASHRC_PATH; (myecho)",
],
Some("It works!\n"),
),
(
@@ -291,7 +494,7 @@ mod tests {
vec![
shell_path,
"-lc",
"source BASHRC_PATH && (echo 'single' \"double\")",
"[ -f BASHRC_PATH ] && . BASHRC_PATH; (echo 'single' \"double\")",
],
Some("single double\n"),
),
@@ -317,13 +520,14 @@ mod tests {
"#,
)
.unwrap();
let shell = Shell::Bash(BashShell {
let shell = Shell::Posix(PosixShell {
shell_path: shell_path.to_string(),
bashrc_path: bashrc_path.to_str().unwrap().to_string(),
rc_path: bashrc_path.to_str().unwrap().to_string(),
shell_snapshot: default_shell_snapshot_rx(),
});
let actual_cmd = shell
.format_default_shell_invocation(input.iter().map(|s| s.to_string()).collect());
let input = input.iter().map(|s| s.to_string()).collect::<Vec<_>>();
let actual_cmd = shell.format_default_shell_invocation(&input);
let expected_cmd = expected_cmd
.iter()
.map(|s| {
@@ -369,6 +573,87 @@ mod tests {
#[cfg(target_os = "macos")]
mod macos_tests {
use super::*;
use crate::shell::snapshots::ensure_posix_snapshot;
#[tokio::test]
async fn test_snapshot_generation_uses_session_id_and_cleanup() {
let shell_path = "/bin/zsh";
let temp_home = tempfile::tempdir().unwrap();
let codex_home = tempfile::tempdir().unwrap();
std::fs::write(
temp_home.path().join(".zshrc"),
"export SNAPSHOT_TEST_VAR=1\nalias snapshot_test_alias='echo hi'\n",
)
.unwrap();
let session_id = Uuid::new_v4();
let snapshot_path = ensure_posix_snapshot(
shell_path,
".zshrc",
temp_home.path(),
codex_home.path(),
session_id,
)
.await
.expect("snapshot path");
let filename = snapshot_path
.file_name()
.unwrap()
.to_string_lossy()
.to_string();
assert!(filename.contains(&session_id.to_string()));
assert!(snapshot_path.exists());
let snapshot_path_second = ensure_posix_snapshot(
shell_path,
".zshrc",
temp_home.path(),
codex_home.path(),
session_id,
)
.await
.expect("snapshot path");
assert_eq!(snapshot_path, snapshot_path_second);
let contents = std::fs::read_to_string(&snapshot_path).unwrap();
assert!(contents.contains("alias snapshot_test_alias='echo hi'"));
assert!(contents.contains("SNAPSHOT_TEST_VAR=1"));
delete_shell_snapshot(&snapshot_path);
assert!(!snapshot_path.exists());
}
#[test]
fn format_default_shell_invocation_prefers_snapshot_when_available() {
let temp_dir = tempfile::tempdir().unwrap();
let snapshot_path = temp_dir.path().join("snapshot.zsh");
std::fs::write(&snapshot_path, "export SNAPSHOT_READY=1").unwrap();
let (_tx, rx) = tokio::sync::watch::channel(ShellSnapshotState::Ready(Arc::new(
ShellSnapshot::new(snapshot_path.clone()),
)));
let shell = Shell::Posix(PosixShell {
shell_path: "/bin/zsh".to_string(),
rc_path: {
let path = temp_dir.path().join(".zshrc");
std::fs::write(&path, "# test zshrc").unwrap();
path.to_string_lossy().to_string()
},
shell_snapshot: rx,
});
let command = vec!["echo".to_string()];
let invocation = shell.format_default_shell_invocation(&command);
let expected_command = vec!["/bin/zsh".to_string(), "-c".to_string(), {
let snapshot_path = snapshot_path.to_string_lossy();
format!("[ -f {snapshot_path} ] && . {snapshot_path}; (echo)")
}];
assert_eq!(invocation, Some(expected_command));
}
#[tokio::test]
async fn test_run_with_profile_escaping_and_execution() {
@@ -377,12 +662,20 @@ mod macos_tests {
let cases = vec![
(
vec!["myecho"],
vec![shell_path, "-lc", "source ZSHRC_PATH && (myecho)"],
vec![
shell_path,
"-lc",
"[ -f ZSHRC_PATH ] && . ZSHRC_PATH; (myecho)",
],
Some("It works!\n"),
),
(
vec!["myecho"],
vec![shell_path, "-lc", "source ZSHRC_PATH && (myecho)"],
vec![
shell_path,
"-lc",
"[ -f ZSHRC_PATH ] && . ZSHRC_PATH; (myecho)",
],
Some("It works!\n"),
),
(
@@ -390,7 +683,7 @@ mod macos_tests {
vec![
shell_path,
"-lc",
"source ZSHRC_PATH && (bash -c \"echo 'single' \\\"double\\\"\")",
"[ -f ZSHRC_PATH ] && . ZSHRC_PATH; (bash -c \"echo 'single' \\\"double\\\"\")",
],
Some("single double\n"),
),
@@ -399,7 +692,7 @@ mod macos_tests {
vec![
shell_path,
"-lc",
"source ZSHRC_PATH && (echo 'single' \"double\")",
"[ -f ZSHRC_PATH ] && . ZSHRC_PATH; (echo 'single' \"double\")",
],
Some("single double\n"),
),
@@ -426,13 +719,14 @@ mod macos_tests {
"#,
)
.unwrap();
let shell = Shell::Zsh(ZshShell {
let shell = Shell::Posix(PosixShell {
shell_path: shell_path.to_string(),
zshrc_path: zshrc_path.to_str().unwrap().to_string(),
rc_path: zshrc_path.to_str().unwrap().to_string(),
shell_snapshot: default_shell_snapshot_rx(),
});
let actual_cmd = shell
.format_default_shell_invocation(input.iter().map(|s| s.to_string()).collect());
let input = input.iter().map(|s| s.to_string()).collect::<Vec<_>>();
let actual_cmd = shell.format_default_shell_invocation(&input);
let expected_cmd = expected_cmd
.iter()
.map(|s| {
@@ -558,8 +852,8 @@ mod tests_windows {
];
for (shell, input, expected_cmd) in cases {
let actual_cmd = shell
.format_default_shell_invocation(input.iter().map(|s| s.to_string()).collect());
let input = input.iter().map(|s| s.to_string()).collect::<Vec<_>>();
let actual_cmd = shell.format_default_shell_invocation(&input);
assert_eq!(
actual_cmd,
Some(expected_cmd.iter().map(|s| s.to_string()).collect())

View File

@@ -17,6 +17,7 @@ use core_test_support::load_default_config_for_test;
use core_test_support::load_sse_fixture_with_id;
use core_test_support::wait_for_event;
use tempfile::TempDir;
use uuid::Uuid;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
@@ -269,7 +270,7 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests
let requests = server.received_requests().await.unwrap();
assert_eq!(requests.len(), 2, "expected two POST requests");
let shell = default_user_shell().await;
let shell = default_user_shell(Uuid::new_v4(), codex_home.path()).await;
let expected_env_text = format!(
r#"<environment_context>