[codex] Add environment shell info (#26480)

## Why

Shell detection needs to be available through the `Environment`
abstraction so callers can ask the selected local or remote environment
for shell metadata without adding a separate HTTP endpoint or parallel
info-source path. This keeps shell metadata shaped like the existing
environment-owned filesystem capability and lets remote environments
answer through exec-server JSON-RPC.

## What changed

- Added `environment/info` to the exec-server protocol/client/server and
exposed `Environment::info()`.
- Added local and remote environment info providers on `Environment`,
following the existing capability-provider pattern used for filesystem
access.
- Moved the shared shell detection logic into `codex-shell-command` and
kept core shell APIs as wrappers around that implementation.
- Returned shell metadata as `EnvironmentInfo { shell: ShellInfo }`
using the existing shell detection path.
- Added a remote environment test that calls `Environment::info()`
through an exec-server-backed environment.

## Validation

- `git diff --check`
- `just test -p codex-shell-command`
- `just test -p codex-core -E 'test(/shell::tests::/)'`\n- `just test -p
codex-exec-server environment`
This commit is contained in:
pakrym-oai
2026-06-04 22:36:25 -07:00
committed by GitHub
Unverified
parent 64e0829cab
commit 6a6a5f925e
21 changed files with 488 additions and 355 deletions
+2
View File
@@ -2789,6 +2789,7 @@ dependencies = [
"codex-file-system",
"codex-protocol",
"codex-sandboxing",
"codex-shell-command",
"codex-test-binary-support",
"codex-utils-absolute-path",
"codex-utils-pty",
@@ -3692,6 +3693,7 @@ dependencies = [
"base64 0.22.1",
"codex-protocol",
"codex-utils-absolute-path",
"libc",
"once_cell",
"pretty_assertions",
"regex",
-1
View File
@@ -83,7 +83,6 @@ mod sandbox_tags;
pub mod sandboxing;
mod session_prefix;
mod session_startup_prewarm;
mod shell_detect;
pub mod skills;
pub(crate) use skills::SkillInjections;
pub(crate) use skills::SkillLoadOutcome;
+16 -312
View File
@@ -1,19 +1,12 @@
use crate::shell_detect::detect_shell_type;
use crate::shell_snapshot::ShellSnapshot;
use codex_shell_command::shell_detect::DetectedShell;
use serde::Deserialize;
use serde::Serialize;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::watch;
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
pub enum ShellType {
Zsh,
Bash,
PowerShell,
Sh,
Cmd,
}
pub use codex_shell_command::shell_detect::ShellType;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Shell {
@@ -29,13 +22,7 @@ pub struct Shell {
impl Shell {
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",
}
self.shell_type.name()
}
/// Takes a string of shell and returns the full list of command args to
@@ -88,319 +75,36 @@ impl PartialEq for Shell {
impl Eq for Shell {}
#[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 file_exists(path: &PathBuf) -> Option<PathBuf> {
if std::fs::metadata(path).is_ok_and(|metadata| metadata.is_file()) {
Some(PathBuf::from(path))
} else {
None
}
}
fn get_shell_path(
shell_type: ShellType,
provided_path: Option<&PathBuf>,
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.cloned();
}
// Check if the shell we are trying to load is user's default shell
// if just use it
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 {
//check exists
if let Some(path) = file_exists(&PathBuf::from(path)) {
return Some(path);
impl From<DetectedShell> for Shell {
fn from(detected: DetectedShell) -> Self {
Self {
shell_type: detected.shell_type,
shell_path: detected.shell_path,
shell_snapshot: empty_shell_snapshot_receiver(),
}
}
None
}
const ZSH_FALLBACK_PATHS: &[&str] = &["/bin/zsh"];
fn get_zsh_shell(path: Option<&PathBuf>) -> Option<Shell> {
let shell_path = get_shell_path(ShellType::Zsh, path, "zsh", ZSH_FALLBACK_PATHS);
shell_path.map(|shell_path| Shell {
shell_type: ShellType::Zsh,
shell_path,
shell_snapshot: empty_shell_snapshot_receiver(),
})
}
const BASH_FALLBACK_PATHS: &[&str] = &["/bin/bash"];
fn get_bash_shell(path: Option<&PathBuf>) -> Option<Shell> {
let shell_path = get_shell_path(ShellType::Bash, path, "bash", BASH_FALLBACK_PATHS);
shell_path.map(|shell_path| Shell {
shell_type: ShellType::Bash,
shell_path,
shell_snapshot: empty_shell_snapshot_receiver(),
})
}
const SH_FALLBACK_PATHS: &[&str] = &["/bin/sh"];
fn get_sh_shell(path: Option<&PathBuf>) -> Option<Shell> {
let shell_path = get_shell_path(ShellType::Sh, path, "sh", SH_FALLBACK_PATHS);
shell_path.map(|shell_path| Shell {
shell_type: ShellType::Sh,
shell_path,
shell_snapshot: empty_shell_snapshot_receiver(),
})
}
// Note the `pwsh` and `powershell` fallback paths are where the respective
// shells are commonly installed on GitHub Actions Windows runners, but may not
// be present on all Windows machines:
// https://docs.github.com/en/actions/tutorials/build-and-test-code/powershell
#[cfg(windows)]
const PWSH_FALLBACK_PATHS: &[&str] = &[r#"C:\Program Files\PowerShell\7\pwsh.exe"#];
#[cfg(not(windows))]
const PWSH_FALLBACK_PATHS: &[&str] = &["/usr/local/bin/pwsh"];
#[cfg(windows)]
const POWERSHELL_FALLBACK_PATHS: &[&str] =
&[r#"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"#];
#[cfg(not(windows))]
const POWERSHELL_FALLBACK_PATHS: &[&str] = &[];
fn get_powershell_shell(path: Option<&PathBuf>) -> Option<Shell> {
let shell_path = get_shell_path(ShellType::PowerShell, path, "pwsh", PWSH_FALLBACK_PATHS)
.or_else(|| {
get_shell_path(
ShellType::PowerShell,
path,
"powershell",
POWERSHELL_FALLBACK_PATHS,
)
});
shell_path.map(|shell_path| Shell {
shell_type: ShellType::PowerShell,
shell_path,
shell_snapshot: empty_shell_snapshot_receiver(),
})
}
fn get_cmd_shell(path: Option<&PathBuf>) -> Option<Shell> {
let shell_path = get_shell_path(ShellType::Cmd, path, "cmd", &[]);
shell_path.map(|shell_path| Shell {
shell_type: ShellType::Cmd,
shell_path,
shell_snapshot: empty_shell_snapshot_receiver(),
})
}
#[cfg(all(test, unix))]
fn ultimate_fallback_shell() -> Shell {
if cfg!(windows) {
Shell {
shell_type: ShellType::Cmd,
shell_path: PathBuf::from("cmd.exe"),
shell_snapshot: empty_shell_snapshot_receiver(),
}
} else {
Shell {
shell_type: ShellType::Sh,
shell_path: PathBuf::from("/bin/sh"),
shell_snapshot: empty_shell_snapshot_receiver(),
}
}
codex_shell_command::shell_detect::ultimate_fallback_shell().into()
}
pub fn get_shell_by_model_provided_path(shell_path: &PathBuf) -> Shell {
detect_shell_type(shell_path)
.and_then(|shell_type| get_shell(shell_type, Some(shell_path)))
.unwrap_or(ultimate_fallback_shell())
codex_shell_command::shell_detect::get_shell_by_model_provided_path(shell_path).into()
}
pub fn get_shell(shell_type: ShellType, path: Option<&PathBuf>) -> 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),
}
codex_shell_command::shell_detect::get_shell(shell_type, path).map(Into::into)
}
pub fn default_user_shell() -> Shell {
default_user_shell_from_path(get_user_shell_path())
codex_shell_command::shell_detect::default_user_shell().into()
}
#[cfg(all(test, target_os = "macos"))]
fn default_user_shell_from_path(user_shell_path: Option<PathBuf>) -> Shell {
if cfg!(windows) {
get_shell(ShellType::PowerShell, /*path*/ None).unwrap_or(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(ultimate_fallback_shell())
}
}
#[cfg(test)]
mod detect_shell_type_tests {
use super::*;
#[test]
fn test_detect_shell_type() {
assert_eq!(
detect_shell_type(&PathBuf::from("zsh")),
Some(ShellType::Zsh)
);
assert_eq!(
detect_shell_type(&PathBuf::from("bash")),
Some(ShellType::Bash)
);
assert_eq!(
detect_shell_type(&PathBuf::from("pwsh")),
Some(ShellType::PowerShell)
);
assert_eq!(
detect_shell_type(&PathBuf::from("powershell")),
Some(ShellType::PowerShell)
);
assert_eq!(detect_shell_type(&PathBuf::from("fish")), None);
assert_eq!(detect_shell_type(&PathBuf::from("other")), None);
assert_eq!(
detect_shell_type(&PathBuf::from("/bin/zsh")),
Some(ShellType::Zsh)
);
assert_eq!(
detect_shell_type(&PathBuf::from("/bin/bash")),
Some(ShellType::Bash)
);
assert_eq!(
detect_shell_type(&PathBuf::from("powershell.exe")),
Some(ShellType::PowerShell)
);
assert_eq!(
detect_shell_type(&PathBuf::from(if cfg!(windows) {
"C:\\windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"
} else {
"/usr/local/bin/pwsh"
})),
Some(ShellType::PowerShell)
);
assert_eq!(
detect_shell_type(&PathBuf::from("pwsh.exe")),
Some(ShellType::PowerShell)
);
assert_eq!(
detect_shell_type(&PathBuf::from("/usr/local/bin/pwsh")),
Some(ShellType::PowerShell)
);
assert_eq!(
detect_shell_type(&PathBuf::from("/bin/sh")),
Some(ShellType::Sh)
);
assert_eq!(detect_shell_type(&PathBuf::from("sh")), Some(ShellType::Sh));
assert_eq!(
detect_shell_type(&PathBuf::from("cmd")),
Some(ShellType::Cmd)
);
assert_eq!(
detect_shell_type(&PathBuf::from("cmd.exe")),
Some(ShellType::Cmd)
);
}
codex_shell_command::shell_detect::default_user_shell_from_path(user_shell_path).into()
}
#[cfg(test)]
-24
View File
@@ -1,24 +0,0 @@
use crate::shell::ShellType;
use std::path::Path;
use std::path::PathBuf;
pub(crate) fn detect_shell_type(shell_path: &PathBuf) -> 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();
if let Some(shell_name) = shell_name {
let shell_name_path = Path::new(shell_name);
if shell_name_path != Path::new(shell_path) {
return detect_shell_type(&shell_name_path.to_path_buf());
}
}
None
}
}
}
+3 -5
View File
@@ -151,9 +151,7 @@ impl ShellSnapshot {
});
// Make the new snapshot.
if let Err(err) =
write_shell_snapshot(shell.shell_type.clone(), &temp_path, session_cwd).await
{
if let Err(err) = write_shell_snapshot(shell.shell_type, &temp_path, session_cwd).await {
tracing::warn!(
"Failed to create shell snapshot for {}: {err:?}",
shell.name()
@@ -203,7 +201,7 @@ async fn write_shell_snapshot(
if shell_type == ShellType::PowerShell || shell_type == ShellType::Cmd {
bail!("Shell snapshot not supported yet for {shell_type:?}");
}
let shell = get_shell(shell_type.clone(), /*path*/ None)
let shell = get_shell(shell_type, /*path*/ None)
.with_context(|| format!("No available shell for {shell_type:?}"))?;
let raw_snapshot = capture_snapshot(&shell, cwd).await?;
@@ -225,7 +223,7 @@ async fn write_shell_snapshot(
}
async fn capture_snapshot(shell: &Shell, cwd: &AbsolutePathBuf) -> Result<String> {
let shell_type = shell.shell_type.clone();
let shell_type = shell.shell_type;
match shell_type {
ShellType::Zsh => run_shell_script(shell, &zsh_snapshot_script(), cwd).await,
ShellType::Bash => run_shell_script(shell, &bash_snapshot_script(), cwd).await,
@@ -182,7 +182,7 @@ impl ToolExecutor<ToolInvocation> for ShellCommandHandler {
session.thread_id,
turn.config.permissions.allow_login_shell,
)?;
let shell_type = Some(session.user_shell().shell_type.clone());
let shell_type = Some(session.user_shell().shell_type);
run_exec_like(RunExecLikeArgs {
tool_name,
exec_params,
@@ -122,7 +122,7 @@ pub(crate) fn get_command(
let shell = model_shell.as_ref().unwrap_or(session_shell.as_ref());
Ok(ResolvedCommand {
command: shell.derive_exec_args(&args.cmd, use_login_shell),
shell_type: shell.shell_type.clone(),
shell_type: shell.shell_type,
})
}
UnifiedExecShellMode::ZshFork(zsh_fork_config) => {
@@ -1032,7 +1032,7 @@ impl UnifiedExecProcessManager {
.await;
let req = UnifiedExecToolRequest {
command: request.command.clone(),
shell_type: request.shell_type.clone(),
shell_type: request.shell_type,
hook_command: request.hook_command.clone(),
process_id: request.process_id,
cwd,
+1
View File
@@ -22,6 +22,7 @@ codex-client = { workspace = true }
codex-file-system = { workspace = true }
codex-protocol = { workspace = true }
codex-sandboxing = { workspace = true }
codex-shell-command = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-pty = { workspace = true }
codex-utils-rustls-provider = { workspace = true }
+12
View File
@@ -29,6 +29,7 @@ use crate::connection::JsonRpcConnection;
use crate::process::ExecProcessEvent;
use crate::process::ExecProcessEventLog;
use crate::process::ExecProcessEventReceiver;
use crate::protocol::ENVIRONMENT_INFO_METHOD;
use crate::protocol::EXEC_CLOSED_METHOD;
use crate::protocol::EXEC_EXITED_METHOD;
use crate::protocol::EXEC_METHOD;
@@ -36,6 +37,7 @@ use crate::protocol::EXEC_OUTPUT_DELTA_METHOD;
use crate::protocol::EXEC_READ_METHOD;
use crate::protocol::EXEC_TERMINATE_METHOD;
use crate::protocol::EXEC_WRITE_METHOD;
use crate::protocol::EnvironmentInfo;
use crate::protocol::ExecClosedNotification;
use crate::protocol::ExecExitedNotification;
use crate::protocol::ExecOutputDeltaNotification;
@@ -279,6 +281,12 @@ impl HttpClient for LazyRemoteExecServerClient {
}
}
impl LazyRemoteExecServerClient {
pub(crate) async fn environment_info(&self) -> Result<EnvironmentInfo, ExecServerError> {
self.get().await?.environment_info().await
}
}
#[derive(Debug, thiserror::Error)]
pub enum ExecServerError {
#[error("failed to spawn exec-server: {0}")]
@@ -363,6 +371,10 @@ impl ExecServerClient {
self.call(EXEC_METHOD, &params).await
}
pub async fn environment_info(&self) -> Result<EnvironmentInfo, ExecServerError> {
self.call(ENVIRONMENT_INFO_METHOD, &()).await
}
pub async fn read(&self, params: ReadParams) -> Result<ReadResponse, ExecServerError> {
self.call(EXEC_READ_METHOD, &params).await
}
+62
View File
@@ -2,6 +2,9 @@ use std::collections::HashMap;
use std::sync::Arc;
use std::sync::RwLock;
use futures::FutureExt;
use futures::future::BoxFuture;
use crate::ExecServerError;
use crate::ExecServerRuntimePaths;
use crate::ExecutorFileSystem;
@@ -18,8 +21,11 @@ use crate::environment_toml::environment_provider_from_codex_home;
use crate::local_file_system::LocalFileSystem;
use crate::local_process::LocalProcess;
use crate::process::ExecBackend;
use crate::protocol::EnvironmentInfo;
use crate::protocol::ShellInfo;
use crate::remote_file_system::RemoteFileSystem;
use crate::remote_process::RemoteProcess;
use codex_shell_command::shell_detect::DetectedShell;
pub const CODEX_EXEC_SERVER_URL_ENV_VAR: &str = "CODEX_EXEC_SERVER_URL";
@@ -286,18 +292,49 @@ impl EnvironmentManager {
pub struct Environment {
exec_server_url: Option<String>,
remote_transport: Option<ExecServerTransportParams>,
info_provider: Arc<dyn EnvironmentInfoProvider>,
exec_backend: Arc<dyn ExecBackend>,
filesystem: Arc<dyn ExecutorFileSystem>,
http_client: Arc<dyn HttpClient>,
local_runtime_paths: Option<ExecServerRuntimePaths>,
}
/// Provides environment metadata from either a local environment or a remote exec-server.
trait EnvironmentInfoProvider: Send + Sync {
fn info(&self) -> BoxFuture<'_, Result<EnvironmentInfo, ExecServerError>>;
}
struct LocalEnvironmentInfoProvider;
impl EnvironmentInfoProvider for LocalEnvironmentInfoProvider {
fn info(&self) -> BoxFuture<'_, Result<EnvironmentInfo, ExecServerError>> {
std::future::ready(Ok(EnvironmentInfo::local())).boxed()
}
}
struct RemoteEnvironmentInfoProvider {
client: LazyRemoteExecServerClient,
}
impl RemoteEnvironmentInfoProvider {
fn new(client: LazyRemoteExecServerClient) -> Self {
Self { client }
}
}
impl EnvironmentInfoProvider for RemoteEnvironmentInfoProvider {
fn info(&self) -> BoxFuture<'_, Result<EnvironmentInfo, ExecServerError>> {
async move { self.client.environment_info().await }.boxed()
}
}
impl Environment {
/// Builds a test-only local environment without configured sandbox helper paths.
pub fn default_for_tests() -> Self {
Self {
exec_server_url: None,
remote_transport: None,
info_provider: Arc::new(LocalEnvironmentInfoProvider),
exec_backend: Arc::new(LocalProcess::default()),
filesystem: Arc::new(LocalFileSystem::unsandboxed()),
http_client: Arc::new(ReqwestHttpClient),
@@ -354,6 +391,7 @@ impl Environment {
Self {
exec_server_url: None,
remote_transport: None,
info_provider: Arc::new(LocalEnvironmentInfoProvider),
exec_backend: Arc::new(LocalProcess::default()),
filesystem: Arc::new(LocalFileSystem::with_runtime_paths(
local_runtime_paths.clone(),
@@ -392,6 +430,7 @@ impl Environment {
Self {
exec_server_url,
remote_transport: Some(remote_transport),
info_provider: Arc::new(RemoteEnvironmentInfoProvider::new(client.clone())),
exec_backend,
filesystem,
http_client: Arc::new(client),
@@ -412,6 +451,11 @@ impl Environment {
self.local_runtime_paths.as_ref()
}
/// Returns environment information from the selected execution/filesystem environment.
pub async fn info(&self) -> Result<EnvironmentInfo, ExecServerError> {
self.info_provider.info().await
}
pub fn get_exec_backend(&self) -> Arc<dyn ExecBackend> {
Arc::clone(&self.exec_backend)
}
@@ -425,6 +469,23 @@ impl Environment {
}
}
impl EnvironmentInfo {
pub(crate) fn local() -> Self {
Self {
shell: codex_shell_command::shell_detect::default_user_shell().into(),
}
}
}
impl From<DetectedShell> for ShellInfo {
fn from(shell: DetectedShell) -> Self {
Self {
name: shell.name().to_string(),
path: shell.shell_path.to_string_lossy().into_owned(),
}
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
@@ -458,6 +519,7 @@ mod tests {
assert_eq!(environment.exec_server_url(), None);
assert!(!environment.is_remote());
assert!(environment.info().await.is_ok());
}
#[tokio::test]
+2
View File
@@ -57,6 +57,7 @@ pub use process::ExecProcessEvent;
pub use process::ExecProcessEventReceiver;
pub use process::StartedExecProcess;
pub use process_id::ProcessId;
pub use protocol::EnvironmentInfo;
pub use protocol::ExecClosedNotification;
pub use protocol::ExecEnvPolicy;
pub use protocol::ExecExitedNotification;
@@ -94,6 +95,7 @@ pub use protocol::InitializeResponse;
pub use protocol::ProcessOutputChunk;
pub use protocol::ReadParams;
pub use protocol::ReadResponse;
pub use protocol::ShellInfo;
pub use protocol::TerminateParams;
pub use protocol::TerminateResponse;
pub use protocol::WriteParams;
+18
View File
@@ -19,6 +19,7 @@ pub const EXEC_TERMINATE_METHOD: &str = "process/terminate";
pub const EXEC_OUTPUT_DELTA_METHOD: &str = "process/output";
pub const EXEC_EXITED_METHOD: &str = "process/exited";
pub const EXEC_CLOSED_METHOD: &str = "process/closed";
pub const ENVIRONMENT_INFO_METHOD: &str = "environment/info";
pub const FS_READ_FILE_METHOD: &str = "fs/readFile";
pub const FS_WRITE_FILE_METHOD: &str = "fs/writeFile";
pub const FS_CREATE_DIRECTORY_METHOD: &str = "fs/createDirectory";
@@ -64,6 +65,23 @@ pub struct InitializeResponse {
pub session_id: String,
}
/// Information about an execution/filesystem environment.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EnvironmentInfo {
pub shell: ShellInfo,
}
/// Shell detected for an execution/filesystem environment.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ShellInfo {
/// Stable shell name, for example `zsh`, `bash`, `powershell`, `sh`, or `cmd`.
pub name: String,
/// Path the exec server would use for that shell.
pub path: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExecParams {
@@ -14,6 +14,7 @@ use tokio_util::task::TaskTracker;
use crate::ExecServerRuntimePaths;
use crate::client::http_client::PendingReqwestHttpBodyStream;
use crate::client::http_client::ReqwestHttpRequestRunner;
use crate::protocol::EnvironmentInfo;
use crate::protocol::ExecParams;
use crate::protocol::ExecResponse;
use crate::protocol::FsCanonicalizeParams;
@@ -147,6 +148,11 @@ impl ExecServerHandler {
session.process().exec(params).await
}
pub(crate) fn environment_info(&self) -> Result<EnvironmentInfo, JSONRPCErrorError> {
self.require_initialized_for("environment info")?;
Ok(EnvironmentInfo::local())
}
pub(crate) async fn exec_read(
&self,
params: ReadParams,
@@ -1,5 +1,6 @@
use std::sync::Arc;
use crate::protocol::ENVIRONMENT_INFO_METHOD;
use crate::protocol::EXEC_METHOD;
use crate::protocol::EXEC_READ_METHOD;
use crate::protocol::EXEC_TERMINATE_METHOD;
@@ -60,6 +61,10 @@ pub(crate) fn build_router() -> RpcRouter<ExecServerHandler> {
EXEC_METHOD,
|handler: Arc<ExecServerHandler>, params: ExecParams| async move { handler.exec(params).await },
);
router.request(
ENVIRONMENT_INFO_METHOD,
|handler: Arc<ExecServerHandler>, _params: ()| async move { handler.environment_info() },
);
router.request(
EXEC_READ_METHOD,
|handler: Arc<ExecServerHandler>, params: ReadParams| async move {
+15
View File
@@ -2,6 +2,7 @@
mod common;
use codex_exec_server::Environment;
use common::exec_server::exec_server;
use pretty_assertions::assert_eq;
@@ -19,3 +20,17 @@ async fn exec_server_serves_readyz_alongside_websocket_endpoint() -> anyhow::Res
server.shutdown().await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn remote_environment_fetches_info_from_exec_server() -> anyhow::Result<()> {
let mut server = exec_server().await?;
let environment = Environment::create_for_tests(Some(server.websocket_url().to_string()))?;
assert!(environment.is_remote());
let remote_info = environment.info().await?;
let local_info = Environment::default_for_tests().info().await?;
assert_eq!(remote_info, local_info);
server.shutdown().await?;
Ok(())
}
+1
View File
@@ -11,6 +11,7 @@ workspace = true
base64 = { workspace = true }
codex-protocol = { workspace = true }
codex-utils-absolute-path = { workspace = true }
libc = { workspace = true }
once_cell = { workspace = true }
regex = { workspace = true }
serde = { workspace = true, features = ["derive"] }
+1 -1
View File
@@ -100,7 +100,7 @@ pub fn extract_bash_command(command: &[String]) -> Option<(&str, &str)> {
};
if !matches!(flag.as_str(), "-lc" | "-c")
|| !matches!(
detect_shell_type(&PathBuf::from(shell)),
detect_shell_type(PathBuf::from(shell)),
Some(ShellType::Zsh) | Some(ShellType::Bash) | Some(ShellType::Sh)
)
{
+1 -1
View File
@@ -1,6 +1,6 @@
//! Command parsing and safety utilities shared across Codex crates.
mod shell_detect;
pub mod shell_detect;
pub mod bash;
pub(crate) mod command_safety;
+1 -1
View File
@@ -47,7 +47,7 @@ pub fn extract_powershell_command(command: &[String]) -> Option<(&str, &str)> {
let shell = &command[0];
if !matches!(
detect_shell_type(&PathBuf::from(shell)),
detect_shell_type(PathBuf::from(shell)),
Some(ShellType::PowerShell)
) {
return None;
+339 -7
View File
@@ -1,8 +1,10 @@
use std::path::Path;
use std::path::PathBuf;
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub(crate) enum ShellType {
use serde::Deserialize;
use serde::Serialize;
#[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
pub enum ShellType {
Zsh,
Bash,
PowerShell,
@@ -10,7 +12,32 @@ pub(crate) enum ShellType {
Cmd,
}
pub(crate) fn detect_shell_type(shell_path: &PathBuf) -> Option<ShellType> {
impl ShellType {
pub fn name(self) -> &'static str {
match self {
Self::Zsh => "zsh",
Self::Bash => "bash",
Self::PowerShell => "powershell",
Self::Sh => "sh",
Self::Cmd => "cmd",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DetectedShell {
pub shell_type: ShellType,
pub shell_path: PathBuf,
}
impl DetectedShell {
pub fn name(&self) -> &'static str {
self.shell_type.name()
}
}
pub fn detect_shell_type(shell_path: impl AsRef<std::path::Path>) -> Option<ShellType> {
let shell_path = shell_path.as_ref();
match shell_path.as_os_str().to_str() {
Some("zsh") => Some(ShellType::Zsh),
Some("sh") => Some(ShellType::Sh),
@@ -21,12 +48,317 @@ pub(crate) fn detect_shell_type(shell_path: &PathBuf) -> Option<ShellType> {
_ => {
let shell_name = shell_path.file_stem();
if let Some(shell_name) = shell_name {
let shell_name_path = Path::new(shell_name);
if shell_name_path != Path::new(shell_path) {
return detect_shell_type(&shell_name_path.to_path_buf());
let shell_name_path = std::path::Path::new(shell_name);
if shell_name_path != shell_path {
return detect_shell_type(shell_name_path);
}
}
None
}
}
}
#[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 file_exists(path: &std::path::Path) -> Option<PathBuf> {
if std::fs::metadata(path).is_ok_and(|metadata| metadata.is_file()) {
Some(PathBuf::from(path))
} else {
None
}
}
fn get_shell_path(
shell_type: ShellType,
provided_path: Option<&PathBuf>,
binary_name: &str,
fallback_paths: &[&str],
) -> Option<PathBuf> {
if let Some(path) = provided_path.and_then(|path| file_exists(path)) {
return Some(path);
}
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(std::path::Path::new(path)) {
return Some(path);
}
}
None
}
const ZSH_FALLBACK_PATHS: &[&str] = &["/bin/zsh"];
fn get_zsh_shell(path: Option<&PathBuf>) -> Option<DetectedShell> {
let shell_path = get_shell_path(ShellType::Zsh, path, "zsh", ZSH_FALLBACK_PATHS);
shell_path.map(|shell_path| DetectedShell {
shell_type: ShellType::Zsh,
shell_path,
})
}
const BASH_FALLBACK_PATHS: &[&str] = &["/bin/bash"];
fn get_bash_shell(path: Option<&PathBuf>) -> Option<DetectedShell> {
let shell_path = get_shell_path(ShellType::Bash, path, "bash", BASH_FALLBACK_PATHS);
shell_path.map(|shell_path| DetectedShell {
shell_type: ShellType::Bash,
shell_path,
})
}
const SH_FALLBACK_PATHS: &[&str] = &["/bin/sh"];
fn get_sh_shell(path: Option<&PathBuf>) -> Option<DetectedShell> {
let shell_path = get_shell_path(ShellType::Sh, path, "sh", SH_FALLBACK_PATHS);
shell_path.map(|shell_path| DetectedShell {
shell_type: ShellType::Sh,
shell_path,
})
}
// Note the `pwsh` and `powershell` fallback paths are where the respective
// shells are commonly installed on GitHub Actions Windows runners, but may not
// be present on all Windows machines:
// https://docs.github.com/en/actions/tutorials/build-and-test-code/powershell
#[cfg(windows)]
const PWSH_FALLBACK_PATHS: &[&str] = &[r#"C:\Program Files\PowerShell\7\pwsh.exe"#];
#[cfg(not(windows))]
const PWSH_FALLBACK_PATHS: &[&str] = &["/usr/local/bin/pwsh"];
#[cfg(windows)]
const POWERSHELL_FALLBACK_PATHS: &[&str] =
&[r#"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"#];
#[cfg(not(windows))]
const POWERSHELL_FALLBACK_PATHS: &[&str] = &[];
fn get_powershell_shell(path: Option<&PathBuf>) -> Option<DetectedShell> {
let shell_path = get_shell_path(ShellType::PowerShell, path, "pwsh", PWSH_FALLBACK_PATHS)
.or_else(|| {
get_shell_path(
ShellType::PowerShell,
path,
"powershell",
POWERSHELL_FALLBACK_PATHS,
)
});
shell_path.map(|shell_path| DetectedShell {
shell_type: ShellType::PowerShell,
shell_path,
})
}
fn get_cmd_shell(path: Option<&PathBuf>) -> Option<DetectedShell> {
let shell_path = get_shell_path(ShellType::Cmd, path, "cmd", &[]);
shell_path.map(|shell_path| DetectedShell {
shell_type: ShellType::Cmd,
shell_path,
})
}
pub fn ultimate_fallback_shell() -> DetectedShell {
if cfg!(windows) {
DetectedShell {
shell_type: ShellType::Cmd,
shell_path: PathBuf::from("cmd.exe"),
}
} else {
DetectedShell {
shell_type: ShellType::Sh,
shell_path: PathBuf::from("/bin/sh"),
}
}
}
pub fn get_shell_by_model_provided_path(shell_path: &PathBuf) -> DetectedShell {
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<&PathBuf>) -> Option<DetectedShell> {
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() -> DetectedShell {
default_user_shell_from_path(get_user_shell_path())
}
pub fn default_user_shell_from_path(user_shell_path: Option<PathBuf>) -> DetectedShell {
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)
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn test_detect_shell_type() {
assert_eq!(
detect_shell_type(PathBuf::from("zsh")),
Some(ShellType::Zsh)
);
assert_eq!(
detect_shell_type(PathBuf::from("bash")),
Some(ShellType::Bash)
);
assert_eq!(
detect_shell_type(PathBuf::from("pwsh")),
Some(ShellType::PowerShell)
);
assert_eq!(
detect_shell_type(PathBuf::from("powershell")),
Some(ShellType::PowerShell)
);
assert_eq!(detect_shell_type(PathBuf::from("fish")), None);
assert_eq!(detect_shell_type(PathBuf::from("other")), None);
assert_eq!(
detect_shell_type(PathBuf::from("/bin/zsh")),
Some(ShellType::Zsh)
);
assert_eq!(
detect_shell_type(PathBuf::from("/bin/bash")),
Some(ShellType::Bash)
);
assert_eq!(
detect_shell_type(PathBuf::from("powershell.exe")),
Some(ShellType::PowerShell)
);
assert_eq!(
detect_shell_type(PathBuf::from(if cfg!(windows) {
"C:\\windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"
} else {
"/usr/local/bin/pwsh"
})),
Some(ShellType::PowerShell)
);
assert_eq!(
detect_shell_type(PathBuf::from("pwsh.exe")),
Some(ShellType::PowerShell)
);
assert_eq!(
detect_shell_type(PathBuf::from("/usr/local/bin/pwsh")),
Some(ShellType::PowerShell)
);
assert_eq!(
detect_shell_type(PathBuf::from("/bin/sh")),
Some(ShellType::Sh)
);
assert_eq!(detect_shell_type(PathBuf::from("sh")), Some(ShellType::Sh));
assert_eq!(
detect_shell_type(PathBuf::from("cmd")),
Some(ShellType::Cmd)
);
assert_eq!(
detect_shell_type(PathBuf::from("cmd.exe")),
Some(ShellType::Cmd)
);
}
}