mirror of
https://github.com/openai/codex.git
synced 2026-04-24 14:45:27 +00:00
fix: keep rmcp-client env vars as OsString (#15363)
## Why This is a follow-up to #15360. That change fixed the `arg0` helper setup, but `rmcp-client` still coerced stdio transport environment values into UTF-8 `String`s before program resolution and process spawn. If `PATH` or another inherited environment value contains non-UTF-8 bytes, that loses fidelity before it reaches `which` and `Command`. ## What changed - change `create_env_for_mcp_server()` to return `HashMap<OsString, OsString>` and read inherited values with `std::env::var_os()` - change `TransportRecipe::Stdio.env`, `RmcpClient::new_stdio_client()`, and `program_resolver::resolve()` to keep stdio transport env values in `OsString` form within `rmcp-client` - keep the `codex-core` config boundary stringly, but convert configured stdio env values to `OsString` once when constructing the transport - update the rmcp-client stdio test fixtures and callers to use `OsString` env maps - add a Unix regression test that verifies `create_env_for_mcp_server()` preserves a non-UTF-8 `PATH` ## How to verify - `cargo test -p codex-rmcp-client` - `cargo test -p codex-core mcp_connection_manager` - `just argument-comment-lint` Targeted coverage in this change includes `utils::tests::create_env_preserves_path_when_it_is_not_utf8`, while the updated stdio transport path is exercised by the existing rmcp-client tests that construct `RmcpClient::new_stdio_client()`.
This commit is contained in:
@@ -1440,7 +1440,12 @@ async fn make_rmcp_client(
|
||||
} => {
|
||||
let command_os: OsString = command.into();
|
||||
let args_os: Vec<OsString> = args.into_iter().map(Into::into).collect();
|
||||
RmcpClient::new_stdio_client(command_os, args_os, env, &env_vars, cwd)
|
||||
let env_os = env.map(|env| {
|
||||
env.into_iter()
|
||||
.map(|(key, value)| (key.into(), value.into()))
|
||||
.collect::<HashMap<_, _>>()
|
||||
});
|
||||
RmcpClient::new_stdio_client(command_os, args_os, env_os, &env_vars, cwd)
|
||||
.await
|
||||
.map_err(|err| StartupOutcomeError::from(anyhow!(err)))
|
||||
}
|
||||
|
||||
@@ -13,18 +13,13 @@
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::OsString;
|
||||
|
||||
#[cfg(windows)]
|
||||
use std::env;
|
||||
#[cfg(windows)]
|
||||
use tracing::debug;
|
||||
|
||||
/// Resolves a program to its executable path on Unix systems.
|
||||
///
|
||||
/// Unix systems handle PATH resolution and script execution natively through
|
||||
/// the kernel's shebang (`#!`) mechanism, so this function simply returns
|
||||
/// the program name unchanged.
|
||||
#[cfg(unix)]
|
||||
pub fn resolve(program: OsString, _env: &HashMap<String, String>) -> std::io::Result<OsString> {
|
||||
pub fn resolve(program: OsString, _env: &HashMap<OsString, OsString>) -> std::io::Result<OsString> {
|
||||
Ok(program)
|
||||
}
|
||||
|
||||
@@ -38,25 +33,22 @@ pub fn resolve(program: OsString, _env: &HashMap<String, String>) -> std::io::Re
|
||||
/// This enables tools like `npx`, `pnpm`, and `yarn` to work correctly on Windows
|
||||
/// without requiring users to specify full paths or extensions in their configuration.
|
||||
#[cfg(windows)]
|
||||
pub fn resolve(program: OsString, env: &HashMap<String, String>) -> std::io::Result<OsString> {
|
||||
pub fn resolve(program: OsString, env: &HashMap<OsString, OsString>) -> std::io::Result<OsString> {
|
||||
// Get current directory for relative path resolution
|
||||
let cwd = env::current_dir()
|
||||
let cwd = std::env::current_dir()
|
||||
.map_err(|e| std::io::Error::other(format!("Failed to get current directory: {e}")))?;
|
||||
|
||||
// Extract PATH from environment for search locations
|
||||
let search_path = env.get("PATH");
|
||||
let search_path = env.get(std::ffi::OsStr::new("PATH"));
|
||||
|
||||
// Attempt resolution via which crate
|
||||
match which::which_in(&program, search_path, &cwd) {
|
||||
Ok(resolved) => {
|
||||
debug!("Resolved {:?} to {:?}", program, resolved);
|
||||
tracing::debug!("Resolved {program:?} to {resolved:?}");
|
||||
Ok(resolved.into_os_string())
|
||||
}
|
||||
Err(e) => {
|
||||
debug!(
|
||||
"Failed to resolve {:?}: {}. Using original path",
|
||||
program, e
|
||||
);
|
||||
tracing::debug!("Failed to resolve {program:?}: {e}. Using original path");
|
||||
// Fallback to original program - let Command::new() handle the error
|
||||
Ok(program)
|
||||
}
|
||||
@@ -146,7 +138,7 @@ mod tests {
|
||||
// Held to prevent the temporary directory from being deleted.
|
||||
_temp_dir: TempDir,
|
||||
program_name: String,
|
||||
mcp_env: HashMap<String, String>,
|
||||
mcp_env: HashMap<OsString, OsString>,
|
||||
}
|
||||
|
||||
impl TestExecutableEnv {
|
||||
@@ -160,10 +152,10 @@ mod tests {
|
||||
|
||||
// Build a clean environment with the temp dir in the PATH.
|
||||
let mut extra_env = HashMap::new();
|
||||
extra_env.insert("PATH".to_string(), Self::build_path(dir_path));
|
||||
extra_env.insert(OsString::from("PATH"), Self::build_path_env_var(dir_path));
|
||||
|
||||
#[cfg(windows)]
|
||||
extra_env.insert("PATHEXT".to_string(), Self::ensure_cmd_extension());
|
||||
extra_env.insert(OsString::from("PATHEXT"), Self::ensure_cmd_extension());
|
||||
|
||||
let mcp_env = create_env_for_mcp_server(Some(extra_env), &[]);
|
||||
|
||||
@@ -202,20 +194,30 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Prepends the given directory to the system's PATH variable.
|
||||
fn build_path(dir: &Path) -> String {
|
||||
let current = std::env::var("PATH").unwrap_or_default();
|
||||
let sep = if cfg!(windows) { ";" } else { ":" };
|
||||
format!("{}{sep}{current}", dir.to_string_lossy())
|
||||
fn build_path_env_var(dir: &Path) -> OsString {
|
||||
let mut path = OsString::from(dir.as_os_str());
|
||||
if let Some(current) = std::env::var_os("PATH") {
|
||||
let sep = if cfg!(windows) { ";" } else { ":" };
|
||||
path.push(sep);
|
||||
path.push(current);
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
/// Ensures `.CMD` is in the `PATHEXT` variable on Windows for script discovery.
|
||||
#[cfg(windows)]
|
||||
fn ensure_cmd_extension() -> String {
|
||||
let current = std::env::var("PATHEXT").unwrap_or_default();
|
||||
if current.to_uppercase().contains(".CMD") {
|
||||
fn ensure_cmd_extension() -> OsString {
|
||||
let current = std::env::var_os("PATHEXT").unwrap_or_default();
|
||||
if current
|
||||
.to_string_lossy()
|
||||
.to_ascii_uppercase()
|
||||
.contains(".CMD")
|
||||
{
|
||||
current
|
||||
} else {
|
||||
format!(".CMD;{current}")
|
||||
let mut path_ext = OsString::from(".CMD;");
|
||||
path_ext.push(current);
|
||||
path_ext
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,7 +390,7 @@ enum TransportRecipe {
|
||||
Stdio {
|
||||
program: OsString,
|
||||
args: Vec<OsString>,
|
||||
env: Option<HashMap<String, String>>,
|
||||
env: Option<HashMap<OsString, OsString>>,
|
||||
env_vars: Vec<String>,
|
||||
cwd: Option<PathBuf>,
|
||||
},
|
||||
@@ -478,7 +478,7 @@ impl RmcpClient {
|
||||
pub async fn new_stdio_client(
|
||||
program: OsString,
|
||||
args: Vec<OsString>,
|
||||
env: Option<HashMap<String, String>>,
|
||||
env: Option<HashMap<OsString, OsString>>,
|
||||
env_vars: &[String],
|
||||
cwd: Option<PathBuf>,
|
||||
) -> io::Result<Self> {
|
||||
|
||||
@@ -5,16 +5,17 @@ use reqwest::header::HeaderName;
|
||||
use reqwest::header::HeaderValue;
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::ffi::OsString;
|
||||
|
||||
pub(crate) fn create_env_for_mcp_server(
|
||||
extra_env: Option<HashMap<String, String>>,
|
||||
extra_env: Option<HashMap<OsString, OsString>>,
|
||||
env_vars: &[String],
|
||||
) -> HashMap<String, String> {
|
||||
) -> HashMap<OsString, OsString> {
|
||||
DEFAULT_ENV_VARS
|
||||
.iter()
|
||||
.copied()
|
||||
.chain(env_vars.iter().map(String::as_str))
|
||||
.filter_map(|var| env::var(var).ok().map(|value| (var.to_string(), value)))
|
||||
.filter_map(|var| env::var_os(var).map(|value| (OsString::from(var), value)))
|
||||
.chain(extra_env.unwrap_or_default())
|
||||
.collect()
|
||||
}
|
||||
@@ -140,7 +141,7 @@ mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use serial_test::serial;
|
||||
use std::ffi::OsString;
|
||||
use std::ffi::OsStr;
|
||||
|
||||
struct EnvVarGuard {
|
||||
key: String,
|
||||
@@ -148,10 +149,10 @@ mod tests {
|
||||
}
|
||||
|
||||
impl EnvVarGuard {
|
||||
fn set(key: &str, value: &str) -> Self {
|
||||
fn set(key: &str, value: impl AsRef<OsStr>) -> Self {
|
||||
let original = std::env::var_os(key);
|
||||
unsafe {
|
||||
std::env::set_var(key, value);
|
||||
std::env::set_var(key, value.as_ref());
|
||||
}
|
||||
Self {
|
||||
key: key.to_string(),
|
||||
@@ -177,9 +178,12 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn create_env_honors_overrides() {
|
||||
let value = "custom".to_string();
|
||||
let env =
|
||||
create_env_for_mcp_server(Some(HashMap::from([("TZ".into(), value.clone())])), &[]);
|
||||
assert_eq!(env.get("TZ"), Some(&value));
|
||||
let expected = OsString::from(&value);
|
||||
let env = create_env_for_mcp_server(
|
||||
Some(HashMap::from([(OsString::from("TZ"), expected.clone())])),
|
||||
&[],
|
||||
);
|
||||
assert_eq!(env.get(OsStr::new("TZ")), Some(&expected));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -187,8 +191,24 @@ mod tests {
|
||||
fn create_env_includes_additional_whitelisted_variables() {
|
||||
let custom_var = "EXTRA_RMCP_ENV";
|
||||
let value = "from-env";
|
||||
let expected = OsString::from(value);
|
||||
let _guard = EnvVarGuard::set(custom_var, value);
|
||||
let env = create_env_for_mcp_server(None, &[custom_var.to_string()]);
|
||||
assert_eq!(env.get(custom_var), Some(&value.to_string()));
|
||||
assert_eq!(env.get(OsStr::new(custom_var)), Some(&expected));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
#[serial(extra_rmcp_env)]
|
||||
fn create_env_preserves_path_when_it_is_not_utf8() {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
|
||||
let raw_path = std::ffi::OsStr::from_bytes(b"/tmp/codex-\xFF/bin");
|
||||
let expected = raw_path.to_os_string();
|
||||
let _guard = EnvVarGuard::set("PATH", raw_path);
|
||||
|
||||
let env = create_env_for_mcp_server(None, &[]);
|
||||
|
||||
assert_eq!(env.get(OsStr::new("PATH")), Some(&expected));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,8 +73,8 @@ async fn drop_kills_wrapper_process_group() -> Result<()> {
|
||||
),
|
||||
],
|
||||
Some(HashMap::from([(
|
||||
"CHILD_PID_FILE".to_string(),
|
||||
child_pid_file_str,
|
||||
OsString::from("CHILD_PID_FILE"),
|
||||
OsString::from(child_pid_file_str),
|
||||
)])),
|
||||
&[],
|
||||
None,
|
||||
|
||||
Reference in New Issue
Block a user