refactor: move shell and snapshot code out of codex-core

This commit is contained in:
Michael Bolin
2026-04-02 10:41:23 -07:00
parent e846fed2b1
commit 379acefcc7
33 changed files with 1449 additions and 1474 deletions
+7
View File
@@ -0,0 +1,7 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "shell",
crate_name = "codex_shell",
test_tags = ["no-sandbox"],
)
+33
View File
@@ -0,0 +1,33 @@
[package]
edition.workspace = true
license.workspace = true
name = "codex-shell"
version.workspace = true
[lints]
workspace = true
[dependencies]
anyhow = { workspace = true }
codex-otel = { workspace = true }
codex-protocol = { workspace = true }
codex-utils-pty = { workspace = true }
serde = { workspace = true, features = ["derive"] }
tokio = { workspace = true, features = [
"fs",
"macros",
"process",
"rt",
"sync",
"time",
] }
tracing = { workspace = true }
uuid = { workspace = true }
which = { workspace = true }
[target.'cfg(unix)'.dependencies]
libc = { workspace = true }
[dev-dependencies]
pretty_assertions = { workspace = true }
tempfile = { workspace = true }
+362
View File
@@ -0,0 +1,362 @@
mod snapshot;
pub use snapshot::SNAPSHOT_DIR;
pub use snapshot::SNAPSHOT_RETENTION;
pub use snapshot::ShellSnapshot;
use snapshot::ShellSnapshotState;
pub use snapshot::remove_snapshot_file;
pub use snapshot::snapshot_session_id_from_file_name;
use serde::Deserialize;
use serde::Serialize;
use std::path::Path;
use std::path::PathBuf;
#[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
pub enum ShellType {
Zsh,
Bash,
PowerShell,
Sh,
Cmd,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Shell {
pub shell_type: ShellType,
pub shell_path: PathBuf,
#[serde(skip, default)]
snapshot_state: ShellSnapshotState,
}
impl Shell {
pub fn new(shell_type: ShellType, shell_path: PathBuf) -> Self {
Self {
shell_type,
shell_path,
snapshot_state: ShellSnapshotState::default(),
}
}
pub fn name(&self) -> &'static str {
match self.shell_type {
ShellType::Zsh => "zsh",
ShellType::Bash => "bash",
ShellType::PowerShell => "powershell",
ShellType::Sh => "sh",
ShellType::Cmd => "cmd",
}
}
/// Takes a string of shell and returns the full list of command args to
/// use with `exec()` to run the shell command.
pub fn derive_exec_args(&self, command: &str, use_login_shell: bool) -> Vec<String> {
match self.shell_type {
ShellType::Zsh | ShellType::Bash | ShellType::Sh => {
let arg = if use_login_shell { "-lc" } else { "-c" };
vec![
self.shell_path.to_string_lossy().to_string(),
arg.to_string(),
command.to_string(),
]
}
ShellType::PowerShell => {
let mut args = vec![self.shell_path.to_string_lossy().to_string()];
if !use_login_shell {
args.push("-NoProfile".to_string());
}
args.push("-Command".to_string());
args.push(command.to_string());
args
}
ShellType::Cmd => {
let mut args = vec![self.shell_path.to_string_lossy().to_string()];
args.push("/c".to_string());
args.push(command.to_string());
args
}
}
}
}
impl PartialEq for Shell {
fn eq(&self, other: &Self) -> bool {
self.shell_type == other.shell_type && self.shell_path == other.shell_path
}
}
impl Eq for Shell {}
pub fn detect_shell_type(shell_path: &Path) -> Option<ShellType> {
match shell_path.as_os_str().to_str() {
Some("zsh") => Some(ShellType::Zsh),
Some("sh") => Some(ShellType::Sh),
Some("cmd") => Some(ShellType::Cmd),
Some("bash") => Some(ShellType::Bash),
Some("pwsh") => Some(ShellType::PowerShell),
Some("powershell") => Some(ShellType::PowerShell),
_ => {
let shell_name = shell_path.file_stem()?;
let shell_name_path = Path::new(shell_name);
if shell_name_path == shell_path {
return None;
}
detect_shell_type(shell_name_path)
}
}
}
pub fn get_shell_by_model_provided_path(shell_path: &Path) -> Shell {
detect_shell_type(shell_path)
.and_then(|shell_type| get_shell(shell_type, Some(shell_path)))
.unwrap_or_else(ultimate_fallback_shell)
}
pub fn get_shell(shell_type: ShellType, path: Option<&Path>) -> Option<Shell> {
match shell_type {
ShellType::Zsh => get_zsh_shell(path),
ShellType::Bash => get_bash_shell(path),
ShellType::PowerShell => get_powershell_shell(path),
ShellType::Sh => get_sh_shell(path),
ShellType::Cmd => get_cmd_shell(path),
}
}
pub fn default_user_shell() -> Shell {
default_user_shell_from_path(get_user_shell_path())
}
#[cfg(unix)]
fn get_user_shell_path() -> Option<PathBuf> {
let uid = unsafe { libc::getuid() };
use std::ffi::CStr;
use std::mem::MaybeUninit;
use std::ptr;
let mut passwd = MaybeUninit::<libc::passwd>::uninit();
// We cannot use getpwuid here: it returns pointers into libc-managed
// storage, which is not safe to read concurrently on all targets (the musl
// static build used by the CLI can segfault when parallel callers race on
// that buffer). getpwuid_r keeps the passwd data in caller-owned memory.
let suggested_buffer_len = unsafe { libc::sysconf(libc::_SC_GETPW_R_SIZE_MAX) };
let buffer_len = usize::try_from(suggested_buffer_len)
.ok()
.filter(|len| *len > 0)
.unwrap_or(1024);
let mut buffer = vec![0; buffer_len];
loop {
let mut result = ptr::null_mut();
let status = unsafe {
libc::getpwuid_r(
uid,
passwd.as_mut_ptr(),
buffer.as_mut_ptr().cast(),
buffer.len(),
&mut result,
)
};
if status == 0 {
if result.is_null() {
return None;
}
let passwd = unsafe { passwd.assume_init_ref() };
if passwd.pw_shell.is_null() {
return None;
}
let shell_path = unsafe { CStr::from_ptr(passwd.pw_shell) }
.to_string_lossy()
.into_owned();
return Some(PathBuf::from(shell_path));
}
if status != libc::ERANGE {
return None;
}
// Retry with a larger buffer until libc can materialize the passwd entry.
let new_len = buffer.len().checked_mul(2)?;
if new_len > 1024 * 1024 {
return None;
}
buffer.resize(new_len, 0);
}
}
#[cfg(not(unix))]
fn get_user_shell_path() -> Option<PathBuf> {
None
}
fn default_user_shell_from_path(user_shell_path: Option<PathBuf>) -> Shell {
if cfg!(windows) {
get_shell(ShellType::PowerShell, /*path*/ None).unwrap_or_else(ultimate_fallback_shell)
} else {
let user_default_shell = user_shell_path
.and_then(|shell| detect_shell_type(&shell))
.and_then(|shell_type| get_shell(shell_type, /*path*/ None));
let shell_with_fallback = if cfg!(target_os = "macos") {
user_default_shell
.or_else(|| get_shell(ShellType::Zsh, /*path*/ None))
.or_else(|| get_shell(ShellType::Bash, /*path*/ None))
} else {
user_default_shell
.or_else(|| get_shell(ShellType::Bash, /*path*/ None))
.or_else(|| get_shell(ShellType::Zsh, /*path*/ None))
};
shell_with_fallback.unwrap_or_else(ultimate_fallback_shell)
}
}
fn file_exists(path: &Path) -> Option<PathBuf> {
if std::fs::metadata(path).is_ok_and(|metadata| metadata.is_file()) {
Some(path.to_path_buf())
} else {
None
}
}
fn get_shell_path(
shell_type: ShellType,
provided_path: Option<&Path>,
binary_name: &str,
fallback_paths: &[&str],
) -> Option<PathBuf> {
// If exact provided path exists, use it.
if provided_path.and_then(file_exists).is_some() {
return provided_path.map(Path::to_path_buf);
}
// Check whether the shell we are trying to load is the user's default
// shell and prefer that exact path when available.
let default_shell_path = get_user_shell_path();
if let Some(default_shell_path) = default_shell_path
&& detect_shell_type(&default_shell_path) == Some(shell_type)
&& file_exists(&default_shell_path).is_some()
{
return Some(default_shell_path);
}
if let Ok(path) = which::which(binary_name) {
return Some(path);
}
for path in fallback_paths {
if let Some(path) = file_exists(Path::new(path)) {
return Some(path);
}
}
None
}
fn get_zsh_shell(path: Option<&Path>) -> Option<Shell> {
let shell_path = get_shell_path(ShellType::Zsh, path, "zsh", &["/bin/zsh"]);
shell_path.map(|shell_path| Shell::new(ShellType::Zsh, shell_path))
}
fn get_bash_shell(path: Option<&Path>) -> Option<Shell> {
let shell_path = get_shell_path(ShellType::Bash, path, "bash", &["/bin/bash"]);
shell_path.map(|shell_path| Shell::new(ShellType::Bash, shell_path))
}
fn get_sh_shell(path: Option<&Path>) -> Option<Shell> {
let shell_path = get_shell_path(ShellType::Sh, path, "sh", &["/bin/sh"]);
shell_path.map(|shell_path| Shell::new(ShellType::Sh, shell_path))
}
fn get_powershell_shell(path: Option<&Path>) -> Option<Shell> {
let shell_path = get_shell_path(
ShellType::PowerShell,
path,
"pwsh",
&["/usr/local/bin/pwsh"],
)
.or_else(|| get_shell_path(ShellType::PowerShell, path, "powershell", &[]));
shell_path.map(|shell_path| Shell::new(ShellType::PowerShell, shell_path))
}
fn get_cmd_shell(path: Option<&Path>) -> Option<Shell> {
let shell_path = get_shell_path(ShellType::Cmd, path, "cmd", &[]);
shell_path.map(|shell_path| Shell::new(ShellType::Cmd, shell_path))
}
fn ultimate_fallback_shell() -> Shell {
if cfg!(windows) {
Shell::new(ShellType::Cmd, PathBuf::from("cmd.exe"))
} else {
Shell::new(ShellType::Sh, PathBuf::from("/bin/sh"))
}
}
#[cfg(test)]
mod detect_shell_type_tests {
use super::*;
#[test]
fn test_detect_shell_type() {
assert_eq!(detect_shell_type(Path::new("zsh")), Some(ShellType::Zsh));
assert_eq!(detect_shell_type(Path::new("bash")), Some(ShellType::Bash));
assert_eq!(
detect_shell_type(Path::new("pwsh")),
Some(ShellType::PowerShell)
);
assert_eq!(
detect_shell_type(Path::new("powershell")),
Some(ShellType::PowerShell)
);
assert_eq!(detect_shell_type(Path::new("fish")), None);
assert_eq!(detect_shell_type(Path::new("other")), None);
assert_eq!(
detect_shell_type(Path::new("/bin/zsh")),
Some(ShellType::Zsh)
);
assert_eq!(
detect_shell_type(Path::new("/bin/bash")),
Some(ShellType::Bash)
);
assert_eq!(
detect_shell_type(Path::new("powershell.exe")),
Some(ShellType::PowerShell)
);
assert_eq!(
detect_shell_type(Path::new(if cfg!(windows) {
"C:\\windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"
} else {
"/usr/local/bin/pwsh"
})),
Some(ShellType::PowerShell)
);
assert_eq!(
detect_shell_type(Path::new("pwsh.exe")),
Some(ShellType::PowerShell)
);
assert_eq!(
detect_shell_type(Path::new("/usr/local/bin/pwsh")),
Some(ShellType::PowerShell)
);
assert_eq!(detect_shell_type(Path::new("/bin/sh")), Some(ShellType::Sh));
assert_eq!(detect_shell_type(Path::new("sh")), Some(ShellType::Sh));
assert_eq!(detect_shell_type(Path::new("cmd")), Some(ShellType::Cmd));
assert_eq!(
detect_shell_type(Path::new("cmd.exe")),
Some(ShellType::Cmd)
);
}
}
#[cfg(test)]
#[path = "shell_tests.rs"]
mod tests;
+184
View File
@@ -0,0 +1,184 @@
use super::*;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
#[test]
#[cfg(target_os = "macos")]
fn detects_zsh() {
let zsh_shell = get_shell(ShellType::Zsh, /*path*/ None).unwrap();
let shell_path = zsh_shell.shell_path;
assert_eq!(shell_path, Path::new("/bin/zsh"));
}
#[test]
#[cfg(target_os = "macos")]
fn fish_fallback_to_zsh() {
let zsh_shell = default_user_shell_from_path(Some(PathBuf::from("/bin/fish")));
let shell_path = zsh_shell.shell_path;
assert_eq!(shell_path, Path::new("/bin/zsh"));
}
#[test]
fn detects_bash() {
let bash_shell = get_shell(ShellType::Bash, /*path*/ None).unwrap();
let shell_path = bash_shell.shell_path;
assert!(
shell_path.file_stem().and_then(|name| name.to_str()) == Some("bash"),
"shell path: {shell_path:?}",
);
}
#[test]
fn detects_sh() {
let sh_shell = get_shell(ShellType::Sh, /*path*/ None).unwrap();
let shell_path = sh_shell.shell_path;
assert!(
shell_path.file_stem().and_then(|name| name.to_str()) == Some("sh"),
"shell path: {shell_path:?}",
);
}
#[test]
fn can_run_on_shell_test() {
let cmd = "echo \"Works\"";
if cfg!(windows) {
assert!(shell_works(
get_shell(ShellType::PowerShell, /*path*/ None),
"Write-Output 'Works'",
/*required*/ true,
));
assert!(shell_works(
get_shell(ShellType::Cmd, /*path*/ None),
cmd,
/*required*/ true,
));
assert!(shell_works(
Some(ultimate_fallback_shell()),
cmd,
/*required*/ true
));
} else {
assert!(shell_works(
Some(ultimate_fallback_shell()),
cmd,
/*required*/ true
));
assert!(shell_works(
get_shell(ShellType::Zsh, /*path*/ None),
cmd,
/*required*/ false
));
assert!(shell_works(
get_shell(ShellType::Bash, /*path*/ None),
cmd,
/*required*/ true
));
assert!(shell_works(
get_shell(ShellType::Sh, /*path*/ None),
cmd,
/*required*/ true
));
}
}
fn shell_works(shell: Option<Shell>, command: &str, required: bool) -> bool {
if let Some(shell) = shell {
let args = shell.derive_exec_args(command, /*use_login_shell*/ false);
let shell_name = shell.name();
let output = Command::new(args[0].clone())
.args(&args[1..])
.output()
.unwrap();
assert!(
output.status.success(),
"{shell_name} shell command failed: status={:?} stderr={:?}",
output.status.code(),
String::from_utf8_lossy(&output.stderr),
);
assert!(String::from_utf8_lossy(&output.stdout).contains("Works"));
true
} else {
!required
}
}
#[test]
fn derive_exec_args() {
let test_bash_shell = Shell::new(ShellType::Bash, PathBuf::from("/bin/bash"));
assert_eq!(
test_bash_shell.derive_exec_args("echo hello", /*use_login_shell*/ false),
vec!["/bin/bash", "-c", "echo hello"]
);
assert_eq!(
test_bash_shell.derive_exec_args("echo hello", /*use_login_shell*/ true),
vec!["/bin/bash", "-lc", "echo hello"]
);
let test_zsh_shell = Shell::new(ShellType::Zsh, PathBuf::from("/bin/zsh"));
assert_eq!(
test_zsh_shell.derive_exec_args("echo hello", /*use_login_shell*/ false),
vec!["/bin/zsh", "-c", "echo hello"]
);
assert_eq!(
test_zsh_shell.derive_exec_args("echo hello", /*use_login_shell*/ true),
vec!["/bin/zsh", "-lc", "echo hello"]
);
let test_powershell_shell = Shell::new(ShellType::PowerShell, PathBuf::from("pwsh.exe"));
assert_eq!(
test_powershell_shell.derive_exec_args("echo hello", /*use_login_shell*/ false),
vec!["pwsh.exe", "-NoProfile", "-Command", "echo hello"]
);
assert_eq!(
test_powershell_shell.derive_exec_args("echo hello", /*use_login_shell*/ true),
vec!["pwsh.exe", "-Command", "echo hello"]
);
}
#[test]
#[cfg(unix)]
fn test_current_shell_detects_zsh() {
let shell = Command::new("sh")
.arg("-c")
.arg("echo $SHELL")
.output()
.unwrap();
let shell_path = String::from_utf8_lossy(&shell.stdout).trim().to_string();
if shell_path.ends_with("/zsh") {
assert_eq!(
default_user_shell(),
Shell::new(ShellType::Zsh, PathBuf::from(shell_path))
);
}
}
#[test]
fn detects_powershell_as_default() {
if !cfg!(windows) {
return;
}
let powershell_shell = default_user_shell();
let shell_path = powershell_shell.shell_path;
assert!(shell_path.ends_with("pwsh.exe") || shell_path.ends_with("powershell.exe"));
}
#[test]
fn finds_powershell() {
if !cfg!(windows) {
return;
}
let powershell_shell = get_shell(ShellType::PowerShell, /*path*/ None).unwrap();
let shell_path = powershell_shell.shell_path;
assert!(shell_path.ends_with("pwsh.exe") || shell_path.ends_with("powershell.exe"));
}
+543
View File
@@ -0,0 +1,543 @@
use crate::Shell;
use crate::ShellType;
use anyhow::Context;
use anyhow::Result;
use anyhow::anyhow;
use anyhow::bail;
use codex_otel::SessionTelemetry;
use codex_protocol::ThreadId;
use std::fmt;
use std::path::Path;
use std::path::PathBuf;
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use std::time::SystemTime;
use tokio::fs;
use tokio::process::Command;
use tokio::sync::watch;
use tokio::time::timeout;
use tracing::Instrument;
use tracing::info_span;
const SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(10);
pub const SNAPSHOT_RETENTION: Duration = Duration::from_secs(60 * 60 * 24 * 3);
pub const SNAPSHOT_DIR: &str = "shell_snapshots";
const EXCLUDED_EXPORT_VARS: &[&str] = &["PWD", "OLDPWD"];
type ShellSnapshotSender = watch::Sender<Option<Arc<ShellSnapshot>>>;
type ShellSnapshotReceiver = watch::Receiver<Option<Arc<ShellSnapshot>>>;
#[derive(Clone)]
pub(crate) struct ShellSnapshotState {
shell_snapshot_tx: ShellSnapshotSender,
shell_snapshot_rx: ShellSnapshotReceiver,
}
impl Default for ShellSnapshotState {
fn default() -> Self {
let (shell_snapshot_tx, shell_snapshot_rx) = watch::channel(None);
Self {
shell_snapshot_tx,
shell_snapshot_rx,
}
}
}
impl fmt::Debug for ShellSnapshotState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ShellSnapshotState")
.field("shell_snapshot", &self.shell_snapshot())
.finish()
}
}
impl ShellSnapshotState {
fn with_shell_snapshot(shell_snapshot: Option<Arc<ShellSnapshot>>) -> Self {
let (shell_snapshot_tx, shell_snapshot_rx) = watch::channel(shell_snapshot);
Self {
shell_snapshot_tx,
shell_snapshot_rx,
}
}
fn shell_snapshot(&self) -> Option<Arc<ShellSnapshot>> {
self.shell_snapshot_rx.borrow().clone()
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ShellSnapshot {
pub path: PathBuf,
pub cwd: PathBuf,
}
impl Shell {
pub fn shell_snapshot(&self) -> Option<Arc<ShellSnapshot>> {
self.snapshot_state.shell_snapshot()
}
pub fn set_shell_snapshot(&mut self, shell_snapshot: Option<Arc<ShellSnapshot>>) {
self.snapshot_state = ShellSnapshotState::with_shell_snapshot(shell_snapshot);
}
pub fn start_snapshotting(
&mut self,
codex_home: PathBuf,
session_id: ThreadId,
session_cwd: PathBuf,
session_telemetry: SessionTelemetry,
) {
self.snapshot_state = ShellSnapshotState::default();
self.spawn_snapshot_task(
codex_home,
session_id,
session_cwd,
self.snapshot_state.shell_snapshot_tx.clone(),
session_telemetry,
);
}
pub fn refresh_snapshot(
&self,
codex_home: PathBuf,
session_id: ThreadId,
session_cwd: PathBuf,
session_telemetry: SessionTelemetry,
) {
self.spawn_snapshot_task(
codex_home,
session_id,
session_cwd,
self.snapshot_state.shell_snapshot_tx.clone(),
session_telemetry,
);
}
fn spawn_snapshot_task(
&self,
codex_home: PathBuf,
session_id: ThreadId,
session_cwd: PathBuf,
shell_snapshot_tx: ShellSnapshotSender,
session_telemetry: SessionTelemetry,
) {
let snapshot_shell = self.clone();
let snapshot_span = info_span!("shell_snapshot", thread_id = %session_id);
tokio::spawn(
async move {
let timer = session_telemetry.start_timer("codex.shell_snapshot.duration_ms", &[]);
let snapshot = ShellSnapshot::try_new(
&codex_home,
session_id,
session_cwd.as_path(),
&snapshot_shell,
)
.await
.map(Arc::new);
let success = snapshot.is_ok();
let success_tag = if success { "true" } else { "false" };
let _ = timer.map(|timer| timer.record(&[("success", success_tag)]));
let mut counter_tags = vec![("success", success_tag)];
if let Some(failure_reason) = snapshot.as_ref().err() {
counter_tags.push(("failure_reason", *failure_reason));
}
session_telemetry.counter("codex.shell_snapshot", /*inc*/ 1, &counter_tags);
let _ = shell_snapshot_tx.send(snapshot.ok());
}
.instrument(snapshot_span),
);
}
}
impl ShellSnapshot {
async fn try_new(
codex_home: &Path,
session_id: ThreadId,
session_cwd: &Path,
shell: &Shell,
) -> std::result::Result<Self, &'static str> {
let extension = match shell.shell_type {
ShellType::PowerShell => "ps1",
ShellType::Zsh | ShellType::Bash | ShellType::Sh | ShellType::Cmd => "sh",
};
let nonce = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0);
let path = codex_home
.join(SNAPSHOT_DIR)
.join(format!("{session_id}.{nonce}.{extension}"));
let temp_path = codex_home
.join(SNAPSHOT_DIR)
.join(format!("{session_id}.tmp-{nonce}"));
let temp_path = match write_shell_snapshot(shell.shell_type, &temp_path, session_cwd).await
{
Ok(path) => {
tracing::info!("Shell snapshot successfully created: {}", path.display());
path
}
Err(err) => {
tracing::warn!(
"Failed to create shell snapshot for {}: {err:?}",
shell.name()
);
return Err("write_failed");
}
};
let temp_snapshot = Self {
path: temp_path.clone(),
cwd: session_cwd.to_path_buf(),
};
if let Err(err) = validate_snapshot(shell, &temp_snapshot.path, session_cwd).await {
tracing::error!("Shell snapshot validation failed: {err:?}");
remove_snapshot_file(&temp_snapshot.path).await;
return Err("validation_failed");
}
if let Err(err) = fs::rename(&temp_snapshot.path, &path).await {
tracing::warn!("Failed to finalize shell snapshot: {err:?}");
remove_snapshot_file(&temp_snapshot.path).await;
return Err("write_failed");
}
Ok(Self {
path,
cwd: session_cwd.to_path_buf(),
})
}
}
impl Drop for ShellSnapshot {
fn drop(&mut self) {
if let Err(err) = std::fs::remove_file(&self.path) {
tracing::warn!(
"Failed to delete shell snapshot at {:?}: {err:?}",
self.path
);
}
}
}
async fn write_shell_snapshot(
shell_type: ShellType,
output_path: &Path,
cwd: &Path,
) -> Result<PathBuf> {
if shell_type == ShellType::PowerShell || shell_type == ShellType::Cmd {
bail!("Shell snapshot not supported yet for {shell_type:?}");
}
let shell = crate::get_shell(shell_type, /*path*/ None)
.with_context(|| format!("No available shell for {shell_type:?}"))?;
let raw_snapshot = capture_snapshot(&shell, cwd).await?;
let snapshot = strip_snapshot_preamble(&raw_snapshot)?;
if let Some(parent) = output_path.parent() {
let parent_display = parent.display();
fs::create_dir_all(parent)
.await
.with_context(|| format!("Failed to create snapshot parent {parent_display}"))?;
}
let snapshot_path = output_path.display();
fs::write(output_path, snapshot)
.await
.with_context(|| format!("Failed to write snapshot to {snapshot_path}"))?;
Ok(output_path.to_path_buf())
}
async fn capture_snapshot(shell: &Shell, cwd: &Path) -> Result<String> {
match shell.shell_type {
ShellType::Zsh => run_shell_script(shell, &zsh_snapshot_script(), cwd).await,
ShellType::Bash => run_shell_script(shell, &bash_snapshot_script(), cwd).await,
ShellType::Sh => run_shell_script(shell, &sh_snapshot_script(), cwd).await,
ShellType::PowerShell => run_shell_script(shell, powershell_snapshot_script(), cwd).await,
ShellType::Cmd => bail!(
"Shell snapshotting is not yet supported for {:?}",
shell.shell_type
),
}
}
fn strip_snapshot_preamble(snapshot: &str) -> Result<String> {
let marker = "# Snapshot file";
let Some(start) = snapshot.find(marker) else {
bail!("Snapshot output missing marker {marker}");
};
Ok(snapshot[start..].to_string())
}
async fn validate_snapshot(shell: &Shell, snapshot_path: &Path, cwd: &Path) -> Result<()> {
let snapshot_path_display = snapshot_path.display();
let script = format!("set -e; . \"{snapshot_path_display}\"");
run_script_with_timeout(
shell,
&script,
SNAPSHOT_TIMEOUT,
/*use_login_shell*/ false,
cwd,
)
.await
.map(|_| ())
}
async fn run_shell_script(shell: &Shell, script: &str, cwd: &Path) -> Result<String> {
run_script_with_timeout(
shell,
script,
SNAPSHOT_TIMEOUT,
/*use_login_shell*/ true,
cwd,
)
.await
}
async fn run_script_with_timeout(
shell: &Shell,
script: &str,
snapshot_timeout: Duration,
use_login_shell: bool,
cwd: &Path,
) -> Result<String> {
let args = shell.derive_exec_args(script, use_login_shell);
let shell_name = shell.name();
let mut handler = Command::new(&args[0]);
handler.args(&args[1..]);
handler.stdin(Stdio::null());
handler.current_dir(cwd);
#[cfg(unix)]
unsafe {
handler.pre_exec(|| {
codex_utils_pty::process_group::detach_from_tty()?;
Ok(())
});
}
handler.kill_on_drop(true);
let snapshot_output = timeout(snapshot_timeout, handler.output())
.await
.map_err(|_| anyhow!("Snapshot command timed out for {shell_name}"))?
.with_context(|| format!("Failed to execute {shell_name}"))?;
if !snapshot_output.status.success() {
bail!(
"Snapshot command exited with status {}: {}",
snapshot_output.status,
String::from_utf8_lossy(&snapshot_output.stderr)
);
}
Ok(String::from_utf8_lossy(&snapshot_output.stdout).into_owned())
}
fn excluded_exports_regex() -> String {
EXCLUDED_EXPORT_VARS.join("|")
}
fn zsh_snapshot_script() -> String {
let excluded = excluded_exports_regex();
let script = r##"if [[ -n "$ZDOTDIR" ]]; then
rc="$ZDOTDIR/.zshrc"
else
rc="$HOME/.zshrc"
fi
[[ -r "$rc" ]] && . "$rc"
print '# Snapshot file'
print '# Unset all aliases to avoid conflicts with functions'
print 'unalias -a 2>/dev/null || true'
print '# Functions'
functions
print ''
setopt_count=$(setopt | wc -l | tr -d ' ')
print "# setopts $setopt_count"
setopt | sed 's/^/setopt /'
print ''
alias_count=$(alias -L | wc -l | tr -d ' ')
print "# aliases $alias_count"
alias -L
print ''
export_lines=$(export -p | awk '
/^(export|declare -x|typeset -x) / {
line=$0
name=line
sub(/^(export|declare -x|typeset -x) /, "", name)
sub(/=.*/, "", name)
if (name ~ /^(EXCLUDED_EXPORTS)$/) {
next
}
if (name ~ /^[A-Za-z_][A-Za-z0-9_]*$/) {
print line
}
}')
export_count=$(printf '%s\n' "$export_lines" | sed '/^$/d' | wc -l | tr -d ' ')
print "# exports $export_count"
if [[ -n "$export_lines" ]]; then
print -r -- "$export_lines"
fi
"##;
script.replace("EXCLUDED_EXPORTS", &excluded)
}
fn bash_snapshot_script() -> String {
let excluded = excluded_exports_regex();
let script = r##"if [ -z "$BASH_ENV" ] && [ -r "$HOME/.bashrc" ]; then
. "$HOME/.bashrc"
fi
echo '# Snapshot file'
echo '# Unset all aliases to avoid conflicts with functions'
unalias -a 2>/dev/null || true
echo '# Functions'
declare -f
echo ''
bash_opts=$(set -o | awk '$2=="on"{print $1}')
bash_opt_count=$(printf '%s\n' "$bash_opts" | sed '/^$/d' | wc -l | tr -d ' ')
echo "# setopts $bash_opt_count"
if [ -n "$bash_opts" ]; then
printf 'set -o %s\n' $bash_opts
fi
echo ''
alias_count=$(alias -p | wc -l | tr -d ' ')
echo "# aliases $alias_count"
alias -p
echo ''
export_lines=$(
while IFS= read -r name; do
if [[ "$name" =~ ^(EXCLUDED_EXPORTS)$ ]]; then
continue
fi
if [[ ! "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then
continue
fi
declare -xp "$name" 2>/dev/null || true
done < <(compgen -e)
)
export_count=$(printf '%s\n' "$export_lines" | sed '/^$/d' | wc -l | tr -d ' ')
echo "# exports $export_count"
if [ -n "$export_lines" ]; then
printf '%s\n' "$export_lines"
fi
"##;
script.replace("EXCLUDED_EXPORTS", &excluded)
}
fn sh_snapshot_script() -> String {
let excluded = excluded_exports_regex();
let script = r##"if [ -n "$ENV" ] && [ -r "$ENV" ]; then
. "$ENV"
fi
echo '# Snapshot file'
echo '# Unset all aliases to avoid conflicts with functions'
unalias -a 2>/dev/null || true
echo '# Functions'
if command -v typeset >/dev/null 2>&1; then
typeset -f
elif command -v declare >/dev/null 2>&1; then
declare -f
fi
echo ''
if set -o >/dev/null 2>&1; then
sh_opts=$(set -o | awk '$2=="on"{print $1}')
sh_opt_count=$(printf '%s\n' "$sh_opts" | sed '/^$/d' | wc -l | tr -d ' ')
echo "# setopts $sh_opt_count"
if [ -n "$sh_opts" ]; then
printf 'set -o %s\n' $sh_opts
fi
else
echo '# setopts 0'
fi
echo ''
if alias >/dev/null 2>&1; then
alias_count=$(alias | wc -l | tr -d ' ')
echo "# aliases $alias_count"
alias
echo ''
else
echo '# aliases 0'
fi
if export -p >/dev/null 2>&1; then
export_lines=$(export -p | awk '
/^(export|declare -x|typeset -x) / {
line=$0
name=line
sub(/^(export|declare -x|typeset -x) /, "", name)
sub(/=.*/, "", name)
if (name ~ /^(EXCLUDED_EXPORTS)$/) {
next
}
if (name ~ /^[A-Za-z_][A-Za-z0-9_]*$/) {
print line
}
}')
export_count=$(printf '%s\n' "$export_lines" | sed '/^$/d' | wc -l | tr -d ' ')
echo "# exports $export_count"
if [ -n "$export_lines" ]; then
printf '%s\n' "$export_lines"
fi
else
export_count=$(env | sort | awk -F= '$1 ~ /^[A-Za-z_][A-Za-z0-9_]*$/ { count++ } END { print count }')
echo "# exports $export_count"
env | sort | while IFS='=' read -r key value; do
case "$key" in
""|[0-9]*|*[!A-Za-z0-9_]*|EXCLUDED_EXPORTS) continue ;;
esac
escaped=$(printf "%s" "$value" | sed "s/'/'\"'\"'/g")
printf "export %s='%s'\n" "$key" "$escaped"
done
fi
"##;
script.replace("EXCLUDED_EXPORTS", &excluded)
}
fn powershell_snapshot_script() -> &'static str {
r##"$ErrorActionPreference = 'Stop'
Write-Output '# Snapshot file'
Write-Output '# Unset all aliases to avoid conflicts with functions'
Write-Output 'Remove-Item Alias:* -ErrorAction SilentlyContinue'
Write-Output '# Functions'
Get-ChildItem Function: | ForEach-Object {
"function {0} {{`n{1}`n}}" -f $_.Name, $_.Definition
}
Write-Output ''
$aliases = Get-Alias
Write-Output ("# aliases " + $aliases.Count)
$aliases | ForEach-Object {
"Set-Alias -Name {0} -Value {1}" -f $_.Name, $_.Definition
}
Write-Output ''
$envVars = Get-ChildItem Env:
Write-Output ("# exports " + $envVars.Count)
$envVars | ForEach-Object {
$escaped = $_.Value -replace "'", "''"
"`$env:{0}='{1}'" -f $_.Name, $escaped
}
"##
}
pub async fn remove_snapshot_file(path: &Path) {
if let Err(err) = fs::remove_file(path).await {
tracing::warn!(
"Failed to delete stale shell snapshot {}: {err:?}",
path.display()
);
}
}
pub fn snapshot_session_id_from_file_name(file_name: &str) -> Option<&str> {
let mut parts = file_name.split('.');
let session_id = parts.next()?;
if uuid::Uuid::parse_str(session_id).is_ok() {
Some(session_id)
} else {
None
}
}
#[cfg(test)]
#[path = "snapshot_tests.rs"]
mod snapshot_tests;
+367
View File
@@ -0,0 +1,367 @@
use super::*;
use codex_protocol::ThreadId;
use pretty_assertions::assert_eq;
#[cfg(unix)]
use std::process::Command as StdCommand;
use tempfile::tempdir;
#[cfg(unix)]
struct BlockingStdinPipe {
original: i32,
write_end: i32,
}
#[cfg(unix)]
impl BlockingStdinPipe {
fn install() -> Result<Self> {
let mut fds = [0i32; 2];
if unsafe { libc::pipe(fds.as_mut_ptr()) } == -1 {
return Err(std::io::Error::last_os_error()).context("create stdin pipe");
}
let original = unsafe { libc::dup(libc::STDIN_FILENO) };
if original == -1 {
let err = std::io::Error::last_os_error();
unsafe {
libc::close(fds[0]);
libc::close(fds[1]);
}
return Err(err).context("dup stdin");
}
if unsafe { libc::dup2(fds[0], libc::STDIN_FILENO) } == -1 {
let err = std::io::Error::last_os_error();
unsafe {
libc::close(fds[0]);
libc::close(fds[1]);
libc::close(original);
}
return Err(err).context("replace stdin");
}
unsafe {
libc::close(fds[0]);
}
Ok(Self {
original,
write_end: fds[1],
})
}
}
#[cfg(unix)]
impl Drop for BlockingStdinPipe {
fn drop(&mut self) {
unsafe {
libc::dup2(self.original, libc::STDIN_FILENO);
libc::close(self.original);
libc::close(self.write_end);
}
}
}
#[cfg(not(target_os = "windows"))]
fn assert_posix_snapshot_sections(snapshot: &str) {
assert!(snapshot.contains("# Snapshot file"));
assert!(snapshot.contains("aliases "));
assert!(snapshot.contains("exports "));
assert!(
snapshot.contains("PATH"),
"snapshot should capture a PATH export"
);
assert!(snapshot.contains("setopts "));
}
async fn get_snapshot(shell_type: ShellType) -> Result<String> {
let dir = tempdir()?;
let path = dir.path().join("snapshot.sh");
write_shell_snapshot(shell_type, &path, dir.path()).await?;
let content = fs::read_to_string(&path).await?;
Ok(content)
}
#[test]
fn strip_snapshot_preamble_removes_leading_output() {
let snapshot = "noise\n# Snapshot file\nexport PATH=/bin\n";
let cleaned = strip_snapshot_preamble(snapshot).expect("snapshot marker exists");
assert_eq!(cleaned, "# Snapshot file\nexport PATH=/bin\n");
}
#[test]
fn strip_snapshot_preamble_requires_marker() {
let result = strip_snapshot_preamble("missing header");
assert!(result.is_err());
}
#[test]
fn snapshot_file_name_parser_supports_legacy_and_suffixed_names() {
let session_id = "019cf82b-6a62-7700-bbbd-46909794ef89";
assert_eq!(
snapshot_session_id_from_file_name(&format!("{session_id}.sh")),
Some(session_id)
);
assert_eq!(
snapshot_session_id_from_file_name(&format!("{session_id}.123.sh")),
Some(session_id)
);
assert_eq!(
snapshot_session_id_from_file_name(&format!("{session_id}.tmp-123")),
Some(session_id)
);
assert_eq!(
snapshot_session_id_from_file_name("not-a-snapshot.txt"),
None
);
}
#[cfg(unix)]
#[test]
fn bash_snapshot_filters_invalid_exports() -> Result<()> {
let output = StdCommand::new("/bin/bash")
.arg("-c")
.arg(bash_snapshot_script())
.env("BASH_ENV", "/dev/null")
.env("VALID_NAME", "ok")
.env("PWD", "/tmp/stale")
.env("NEXTEST_BIN_EXE_codex-write-config-schema", "/path/to/bin")
.env("BAD-NAME", "broken")
.output()?;
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("VALID_NAME"));
assert!(!stdout.contains("PWD=/tmp/stale"));
assert!(!stdout.contains("NEXTEST_BIN_EXE_codex-write-config-schema"));
assert!(!stdout.contains("BAD-NAME"));
Ok(())
}
#[cfg(unix)]
#[test]
fn bash_snapshot_preserves_multiline_exports() -> Result<()> {
let multiline_cert = "-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----";
let output = StdCommand::new("/bin/bash")
.arg("-c")
.arg(bash_snapshot_script())
.env("BASH_ENV", "/dev/null")
.env("MULTILINE_CERT", multiline_cert)
.output()?;
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("MULTILINE_CERT=") || stdout.contains("MULTILINE_CERT"),
"snapshot should include the multiline export name"
);
let dir = tempdir()?;
let snapshot_path = dir.path().join("snapshot.sh");
std::fs::write(&snapshot_path, stdout.as_bytes())?;
let validate = StdCommand::new("/bin/bash")
.arg("-c")
.arg("set -e; . \"$1\"")
.arg("bash")
.arg(&snapshot_path)
.env("BASH_ENV", "/dev/null")
.output()?;
assert!(
validate.status.success(),
"snapshot validation failed: {}",
String::from_utf8_lossy(&validate.stderr)
);
Ok(())
}
#[cfg(unix)]
#[tokio::test]
async fn try_new_creates_and_deletes_snapshot_file() -> Result<()> {
let dir = tempdir()?;
let shell = Shell::new(ShellType::Bash, PathBuf::from("/bin/bash"));
let snapshot = ShellSnapshot::try_new(dir.path(), ThreadId::new(), dir.path(), &shell)
.await
.expect("snapshot should be created");
let path = snapshot.path.clone();
assert!(path.exists());
assert_eq!(snapshot.cwd, dir.path().to_path_buf());
drop(snapshot);
assert!(!path.exists());
Ok(())
}
#[cfg(unix)]
#[tokio::test]
async fn try_new_uses_distinct_generation_paths() -> Result<()> {
let dir = tempdir()?;
let session_id = ThreadId::new();
let shell = Shell::new(ShellType::Bash, PathBuf::from("/bin/bash"));
let initial_snapshot = ShellSnapshot::try_new(dir.path(), session_id, dir.path(), &shell)
.await
.expect("initial snapshot should be created");
let refreshed_snapshot = ShellSnapshot::try_new(dir.path(), session_id, dir.path(), &shell)
.await
.expect("refreshed snapshot should be created");
let initial_path = initial_snapshot.path.clone();
let refreshed_path = refreshed_snapshot.path.clone();
assert_ne!(initial_path, refreshed_path);
assert_eq!(initial_path.exists(), true);
assert_eq!(refreshed_path.exists(), true);
drop(initial_snapshot);
assert_eq!(initial_path.exists(), false);
assert_eq!(refreshed_path.exists(), true);
drop(refreshed_snapshot);
assert_eq!(refreshed_path.exists(), false);
Ok(())
}
#[cfg(unix)]
#[tokio::test]
async fn snapshot_shell_does_not_inherit_stdin() -> Result<()> {
let _stdin_guard = BlockingStdinPipe::install()?;
let dir = tempdir()?;
let home = dir.path();
let read_status_path = home.join("stdin-read-status");
let read_status_display = read_status_path.display();
let bashrc = format!("read -t 1 -r ignored\nprintf '%s' \"$?\" > \"{read_status_display}\"\n");
fs::write(home.join(".bashrc"), bashrc).await?;
let shell = Shell::new(ShellType::Bash, PathBuf::from("/bin/bash"));
let home_display = home.display();
let script = format!(
"HOME=\"{home_display}\"; export HOME; {}",
bash_snapshot_script()
);
let output = run_script_with_timeout(
&shell,
&script,
Duration::from_secs(2),
/*use_login_shell*/ true,
home,
)
.await
.context("run snapshot command")?;
let read_status = fs::read_to_string(&read_status_path)
.await
.context("read stdin probe status")?;
assert_eq!(
read_status, "1",
"expected shell startup read to see EOF on stdin; status={read_status:?}"
);
assert!(
output.contains("# Snapshot file"),
"expected snapshot marker in output; output={output:?}"
);
Ok(())
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn timed_out_snapshot_shell_is_terminated() -> Result<()> {
use std::process::Stdio;
use tokio::time::Duration as TokioDuration;
use tokio::time::Instant;
use tokio::time::sleep;
let dir = tempdir()?;
let pid_path = dir.path().join("pid");
let script = format!("echo $$ > \"{}\"; sleep 30", pid_path.display());
let shell = Shell::new(ShellType::Sh, PathBuf::from("/bin/sh"));
let err = run_script_with_timeout(
&shell,
&script,
Duration::from_secs(1),
/*use_login_shell*/ true,
dir.path(),
)
.await
.expect_err("snapshot shell should time out");
assert!(
err.to_string().contains("timed out"),
"expected timeout error, got {err:?}"
);
let pid = fs::read_to_string(&pid_path)
.await
.expect("snapshot shell writes its pid before timing out")
.trim()
.parse::<i32>()?;
let deadline = Instant::now() + TokioDuration::from_secs(1);
loop {
let kill_status = StdCommand::new("kill")
.arg("-0")
.arg(pid.to_string())
.stderr(Stdio::null())
.stdout(Stdio::null())
.status()?;
if !kill_status.success() {
break;
}
if Instant::now() >= deadline {
panic!("timed out snapshot shell is still alive after grace period");
}
sleep(TokioDuration::from_millis(50)).await;
}
Ok(())
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn macos_zsh_snapshot_includes_sections() -> Result<()> {
let snapshot = get_snapshot(ShellType::Zsh).await?;
assert_posix_snapshot_sections(&snapshot);
Ok(())
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn linux_bash_snapshot_includes_sections() -> Result<()> {
let snapshot = get_snapshot(ShellType::Bash).await?;
assert_posix_snapshot_sections(&snapshot);
Ok(())
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn linux_sh_snapshot_includes_sections() -> Result<()> {
let snapshot = get_snapshot(ShellType::Sh).await?;
assert_posix_snapshot_sections(&snapshot);
Ok(())
}
#[cfg(target_os = "windows")]
#[ignore]
#[tokio::test]
async fn windows_powershell_snapshot_includes_sections() -> Result<()> {
let snapshot = get_snapshot(ShellType::PowerShell).await?;
assert!(snapshot.contains("# Snapshot file"));
assert!(snapshot.contains("aliases "));
assert!(snapshot.contains("exports "));
Ok(())
}