mcp: accept foreign absolute cwd for remote stdio (#29493)

## Why

Remote stdio MCP servers can run in an environment whose path convention
differs from the Codex host. A Windows cwd such as
`C:\Users\openai\share` is absolute for the executor but was rejected by
a POSIX orchestrator.

Built on #29501, now merged, which only clarifies the host-native
`PathUri` constructor name.

## What changed

- Deserialize MCP cwd values as `LegacyAppPathString` so config does not
apply host path rules.
- Interpret that spelling as host-native for local launches and convert
it to `PathUri` at executor launch.
- Skip host filesystem and command resolution checks for remote stdio in
`codex doctor`.
- Add host-independent config and executor-boundary coverage using the
foreign path convention for each test platform.

## Validation

- `just test -p codex-utils-path-uri -p codex-config -p codex-mcp -p
codex-rmcp-client` (408 passed)
- `just test -p codex-cli -p codex-rmcp-client` (372 passed)
- `cargo check --workspace --tests`
- `just test` (11,311 passed; 43 unrelated environment/timing failures)
- `just fix -p codex-cli -p codex-config -p codex-core -p codex-mcp -p
codex-mcp-extension -p codex-rmcp-client -p codex-tui`
This commit is contained in:
Adam Perry @ OpenAI
2026-06-22 18:33:51 -07:00
committed by GitHub
Unverified
parent 3310fc8ae5
commit 67009bc53f
20 changed files with 250 additions and 162 deletions
@@ -11,10 +11,10 @@
*
* When converting from [`PathUri`], "native" refers to the supplied
* [`PathConvention`], which may be foreign to the operating system running
* this process. The inner string is private so path-producing code must convert
* from [`AbsolutePathBuf`] or use [`Self::from_path_uri`] instead of bypassing
* the intended conversion boundary. Non-UTF-8 paths are converted to UTF-8
* lossily because this API value is serialized as a JSON string.
* this process. The inner string is private so path-producing code must use a
* path conversion method instead of bypassing the intended conversion
* boundary. Non-UTF-8 paths are converted to UTF-8 lossily because this API
* value is serialized as a JSON string.
*
* Deserialization accepts any UTF-8 string without interpreting or validating
* it. That unrestricted construction path is intentionally available only to
+98 -16
View File
@@ -1520,19 +1520,35 @@ async fn mcp_check_from_servers(servers: &HashMap<String, McpServerConfig>) -> D
if disabled_server {
continue;
}
if let Some(cwd) = cwd
&& !cwd.exists()
{
missing_env.push(format!("{name}: cwd does not exist ({})", cwd.display()));
}
if command.trim().is_empty() {
let command_is_empty = command.trim().is_empty();
if command_is_empty {
missing_env.push(format!("{name}: stdio command is empty"));
} else if let Err(err) =
stdio_command_resolves(command, cwd.as_deref(), env.as_ref())
{
missing_env.push(format!(
"{name}: stdio command {command:?} is not resolvable ({err})"
));
}
if server.is_local_environment() {
let host_native_cwd = cwd.as_ref().map(|cwd| Path::new(cwd.as_str()));
if let Some(cwd) = host_native_cwd
&& !cwd.exists()
{
missing_env.push(format!("{name}: cwd does not exist ({})", cwd.display()));
}
if !command_is_empty
&& let Err(err) =
stdio_command_resolves(command, host_native_cwd, env.as_ref())
{
missing_env.push(format!(
"{name}: stdio command {command:?} is not resolvable ({err})"
));
}
} else {
match cwd {
Some(cwd) if cwd.to_inferred_path_uri().is_none() => {
missing_env
.push(format!("{name}: remote stdio cwd is not absolute ({cwd})"));
}
None => missing_env
.push(format!("{name}: remote stdio requires an explicit cwd")),
Some(_) => {}
}
}
if let Some(env) = env {
for key in env.keys().filter(|key| key.trim().is_empty()) {
@@ -1541,10 +1557,12 @@ async fn mcp_check_from_servers(servers: &HashMap<String, McpServerConfig>) -> D
}
for env_var in env_vars {
if env_var.is_remote_source() {
missing_env.push(format!(
"{name}: env_vars entry `{}` uses source `remote`, which requires remote MCP stdio",
env_var.name()
));
if server.is_local_environment() {
missing_env.push(format!(
"{name}: env_vars entry `{}` uses source `remote`, which requires remote MCP stdio",
env_var.name()
));
}
} else if !env_var_present(env_var.name()) {
missing_env.push(format!("{name}: env var {} is not set", env_var.name()));
}
@@ -3863,6 +3881,70 @@ mod tests {
}));
}
#[tokio::test]
async fn mcp_check_skips_host_path_checks_for_remote_stdio() {
#[cfg(not(windows))]
let cwd = r"C:\Users\openai\share";
#[cfg(windows)]
let cwd = "/home/openai/share";
let cwd = toml::Value::String(cwd.to_string());
let remote_server: McpServerConfig = toml::from_str(&format!(
r#"
command = "definitely-missing-codex-doctor-mcp"
environment_id = "remote"
cwd = {cwd}
required = true
env_vars = [{{ name = "REMOTE_ONLY_TOKEN", source = "remote" }}]
"#,
))
.expect("should deserialize remote MCP config");
let servers = HashMap::from([("remote".to_string(), remote_server)]);
let check = mcp_check_from_servers(&servers).await;
assert_eq!(check.status, CheckStatus::Ok);
assert_eq!(check.summary, "MCP configuration is locally consistent");
}
#[tokio::test]
async fn mcp_check_validates_remote_stdio_cwd() {
let missing_cwd: McpServerConfig = toml::from_str(
r#"
command = "echo"
environment_id = "remote"
required = true
"#,
)
.expect("should deserialize remote MCP config without cwd");
let relative_cwd: McpServerConfig = toml::from_str(
r#"
command = "echo"
environment_id = "remote"
cwd = "relative"
required = true
"#,
)
.expect("should deserialize remote MCP config with relative cwd");
let servers = HashMap::from([
("missing".to_string(), missing_cwd),
("relative".to_string(), relative_cwd),
]);
let check = mcp_check_from_servers(&servers).await;
assert_eq!(check.status, CheckStatus::Fail);
assert!(
check
.details
.contains(&"missing: remote stdio requires an explicit cwd".to_string())
);
assert!(
check
.details
.contains(&"relative: remote stdio cwd is not absolute (relative)".to_string())
);
}
#[cfg(unix)]
#[test]
fn read_probe_file_rejects_unreadable_file() {
+2 -2
View File
@@ -640,7 +640,7 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) ->
let env_display = format_env_display(env.as_ref(), env_vars);
let cwd_display = cwd
.as_ref()
.map(|path| path.display().to_string())
.map(ToString::to_string)
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "-".to_string());
let status = format_mcp_status(cfg);
@@ -897,7 +897,7 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re
println!(" args: {args_display}");
let cwd_display = cwd
.as_ref()
.map(|path| path.display().to_string())
.map(ToString::to_string)
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "-".to_string());
println!(" cwd: {cwd_display}");
@@ -7,6 +7,7 @@ use codex_config::McpServerConfig;
use codex_config::McpServerEnvVar;
use codex_config::McpServerOAuthConfig;
use codex_config::McpServerTransportConfig;
use codex_utils_path_uri::LegacyAppPathString;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
use std::collections::HashMap;
@@ -31,7 +32,7 @@ fn stdio_server(
args: Vec::new(),
env: None,
env_vars,
cwd: Some(cwd.to_path_buf()),
cwd: Some(LegacyAppPathString::from_path(cwd)),
},
environment_id: environment_id.to_string(),
enabled: true,
+1
View File
@@ -645,6 +645,7 @@ async fn make_rmcp_client(
)) as Arc<dyn StdioServerLauncher>
};
let cwd = cwd.map(codex_utils_path_uri::LegacyAppPathString::into_string);
RmcpClient::new_stdio_client(command_os, args_os, env_os, &env_vars, cwd, launcher)
.await
.map_err(|err| StartupOutcomeError::from(anyhow!(err)))
+2 -53
View File
@@ -66,9 +66,6 @@ impl McpRuntimeContext {
.environment_manager
.get_environment(&config.environment_id)
{
if !config.is_local_environment() {
ensure_remote_stdio_cwd(server_name, config)?;
}
return Ok(Some(environment));
}
@@ -88,27 +85,6 @@ impl McpRuntimeContext {
}
}
fn ensure_remote_stdio_cwd(
server_name: &str,
config: &codex_config::McpServerConfig,
) -> Result<(), String> {
let codex_config::McpServerTransportConfig::Stdio { cwd, .. } = &config.transport else {
return Ok(());
};
let Some(cwd) = cwd else {
return Err(format!(
"remote stdio MCP server `{server_name}` requires an absolute cwd"
));
};
if cwd.is_absolute() {
return Ok(());
}
Err(format!(
"remote stdio MCP server `{server_name}` requires an absolute cwd, got `{}`",
cwd.display()
))
}
pub(crate) fn emit_duration(metric: &str, duration: Duration, tags: &[(&str, &str)]) {
if let Some(metrics) = codex_otel::global() {
let _ = metrics.record_duration(metric, duration, tags);
@@ -123,6 +99,7 @@ mod tests {
use codex_config::McpServerConfig;
use codex_config::McpServerTransportConfig;
use codex_exec_server::EnvironmentManager;
use codex_utils_path_uri::LegacyAppPathString;
use pretty_assertions::assert_eq;
use super::*;
@@ -236,7 +213,7 @@ mod tests {
let McpServerTransportConfig::Stdio { cwd, .. } = &mut remote_stdio.transport else {
unreachable!("stdio helper should build stdio transport");
};
*cwd = Some(std::env::temp_dir());
*cwd = Some(LegacyAppPathString::from_path(&std::env::temp_dir()));
for resolved_runtime in [
runtime_context.resolve_server_environment("stdio", &remote_stdio),
runtime_context.resolve_server_environment("http", &http_server("remote")),
@@ -264,32 +241,4 @@ mod tests {
};
assert!(resolved_runtime.is_some());
}
#[tokio::test]
async fn remote_stdio_requires_absolute_cwd() {
let runtime_context = McpRuntimeContext::new(
Arc::new(
EnvironmentManager::create_for_tests(
Some("ws://127.0.0.1:8765".to_string()),
/*local_runtime_paths*/ None,
)
.await,
),
PathBuf::from("/tmp"),
);
let mut remote_stdio = stdio_server("remote");
let McpServerTransportConfig::Stdio { cwd, .. } = &mut remote_stdio.transport else {
unreachable!("stdio helper should build stdio transport");
};
*cwd = Some(PathBuf::from("relative"));
let error = match runtime_context.resolve_server_environment("stdio", &remote_stdio) {
Ok(_) => panic!("remote stdio MCP should require absolute cwd"),
Err(error) => error,
};
assert_eq!(
error,
"remote stdio MCP server `stdio` requires an absolute cwd, got `relative`"
);
}
}
+1 -1
View File
@@ -146,7 +146,7 @@ fn serialize_mcp_server(config: &McpServerConfig) -> TomlItem {
entry["env_vars"] = array_from_env_vars(env_vars);
}
if let Some(cwd) = cwd {
entry["cwd"] = value(cwd.to_string_lossy().to_string());
entry["cwd"] = value(cwd.as_str());
}
}
McpServerTransportConfig::StreamableHttp {
+3 -28
View File
@@ -2,9 +2,9 @@
use std::collections::HashMap;
use std::fmt;
use std::path::PathBuf;
use std::time::Duration;
use codex_utils_path_uri::LegacyAppPathString;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Deserializer;
@@ -224,7 +224,7 @@ pub struct RawMcpServerConfig {
#[serde(default)]
pub env_vars: Option<Vec<McpServerEnvVar>>,
#[serde(default)]
pub cwd: Option<PathBuf>,
pub cwd: Option<LegacyAppPathString>,
pub http_headers: Option<HashMap<String, String>>,
#[serde(default)]
pub env_http_headers: Option<HashMap<String, String>>,
@@ -358,7 +358,6 @@ impl TryFrom<RawMcpServerConfig> for McpServerConfig {
let environment_id =
environment_id.unwrap_or_else(|| DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string());
validate_remote_stdio_cwd(&transport, &environment_id)?;
Ok(Self {
transport,
@@ -395,30 +394,6 @@ const fn default_enabled() -> bool {
true
}
fn validate_remote_stdio_cwd(
transport: &McpServerTransportConfig,
environment_id: &str,
) -> Result<(), String> {
if environment_id == DEFAULT_MCP_SERVER_ENVIRONMENT_ID {
return Ok(());
}
let McpServerTransportConfig::Stdio { cwd, .. } = transport else {
return Ok(());
};
let Some(cwd) = cwd else {
return Err(format!(
"remote stdio MCP servers require an absolute cwd when environment_id is `{environment_id}`"
));
};
if cwd.is_absolute() {
return Ok(());
}
Err(format!(
"remote stdio MCP servers require an absolute cwd when environment_id is `{environment_id}`, got `{}`",
cwd.display()
))
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)]
#[serde(untagged, deny_unknown_fields, rename_all = "snake_case")]
pub enum McpServerTransportConfig {
@@ -432,7 +407,7 @@ pub enum McpServerTransportConfig {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
env_vars: Vec<McpServerEnvVar>,
#[serde(default, skip_serializing_if = "Option::is_none")]
cwd: Option<PathBuf>,
cwd: Option<LegacyAppPathString>,
},
/// https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http
StreamableHttp {
+10 -35
View File
@@ -1,7 +1,8 @@
use super::*;
use codex_utils_path_uri::LegacyAppPathString;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
use std::path::PathBuf;
use std::path::Path;
#[test]
fn deserialize_stdio_command_server_config() {
@@ -52,38 +53,12 @@ fn deserialize_stdio_command_server_config_with_args() {
}
#[test]
fn deserialize_remote_stdio_server_requires_absolute_cwd() {
let missing_cwd = toml::from_str::<McpServerConfig>(
r#"
command = "echo"
environment_id = "remote"
"#,
)
.expect_err("remote stdio MCP should require cwd");
assert!(
missing_cwd
.to_string()
.contains("remote stdio MCP servers require an absolute cwd"),
"unexpected error: {missing_cwd}"
);
let relative_cwd = toml::from_str::<McpServerConfig>(
r#"
command = "echo"
environment_id = "remote"
cwd = "relative"
"#,
)
.expect_err("remote stdio MCP should require absolute cwd");
assert!(
relative_cwd.to_string().contains("got `relative`"),
"unexpected error: {relative_cwd}"
);
}
#[test]
fn deserialize_remote_stdio_server_accepts_absolute_cwd() {
let cwd = std::env::temp_dir();
fn deserialize_remote_stdio_server_accepts_foreign_absolute_cwd() {
#[cfg(not(windows))]
let cwd = r"C:\Users\openai\share";
#[cfg(windows)]
let cwd = "/home/openai/share";
let expected_cwd = LegacyAppPathString::from_path(Path::new(cwd));
let cfg: McpServerConfig = match toml::from_str(&format!(
r#"
command = "echo"
@@ -102,7 +77,7 @@ fn deserialize_remote_stdio_server_accepts_absolute_cwd() {
args: vec![],
env: None,
env_vars: Vec::new(),
cwd: Some(cwd),
cwd: Some(expected_cwd),
}
);
}
@@ -223,7 +198,7 @@ fn deserialize_stdio_command_server_config_with_cwd() {
args: vec![],
env: None,
env_vars: Vec::new(),
cwd: Some(PathBuf::from("/tmp")),
cwd: Some(LegacyAppPathString::from_path(Path::new("/tmp"))),
}
);
}
+9 -2
View File
@@ -1324,6 +1324,9 @@
],
"description": "One action binding value in config.\n\nThis accepts either:\n\n1. A single key spec string (`\"ctrl-a\"`). 2. A list of key spec strings (`[\"ctrl-a\", \"alt-a\"]`).\n\nAn empty list explicitly unbinds the action in that scope. Because an explicit empty list is still a configured value, runtime resolution must not fall through to global or built-in defaults for that action."
},
"LegacyAppPathString": {
"type": "string"
},
"MarketplaceConfig": {
"additionalProperties": false,
"properties": {
@@ -2440,8 +2443,12 @@
"type": "string"
},
"cwd": {
"default": null,
"type": "string"
"allOf": [
{
"$ref": "#/definitions/LegacyAppPathString"
}
],
"default": null
},
"default_tools_approval_mode": {
"allOf": [
+7 -4
View File
@@ -89,6 +89,7 @@ use codex_protocol::protocol::MultiAgentVersion;
use codex_protocol::protocol::NetworkAccess;
use codex_protocol::protocol::RealtimeVoice;
use codex_protocol::protocol::SandboxPolicy;
use codex_utils_path_uri::LegacyAppPathString;
use serde::Deserialize;
use tempfile::tempdir;
@@ -5554,6 +5555,7 @@ async fn load_global_mcp_servers_returns_empty_if_missing() -> anyhow::Result<()
#[tokio::test]
async fn replace_mcp_servers_round_trips_entries() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let expected_cwd = LegacyAppPathString::from_path(codex_home.path());
let mut servers = BTreeMap::new();
servers.insert(
@@ -5564,7 +5566,7 @@ async fn replace_mcp_servers_round_trips_entries() -> anyhow::Result<()> {
args: vec!["hello".to_string()],
env: None,
env_vars: Vec::new(),
cwd: Some(codex_home.path().to_path_buf()),
cwd: Some(expected_cwd.clone()),
},
environment_id: "remote".to_string(),
enabled: true,
@@ -5603,7 +5605,7 @@ async fn replace_mcp_servers_round_trips_entries() -> anyhow::Result<()> {
assert_eq!(args, &vec!["hello".to_string()]);
assert!(env.is_none());
assert!(env_vars.is_empty());
assert_eq!(cwd, &Some(codex_home.path().to_path_buf()));
assert_eq!(cwd, &Some(expected_cwd));
}
other => panic!("unexpected transport {other:?}"),
}
@@ -6104,6 +6106,7 @@ async fn replace_mcp_servers_serializes_cwd() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let cwd_path = PathBuf::from("/tmp/codex-mcp");
let cwd = LegacyAppPathString::from_path(&cwd_path);
let servers = BTreeMap::from([(
"docs".to_string(),
McpServerConfig {
@@ -6112,7 +6115,7 @@ async fn replace_mcp_servers_serializes_cwd() -> anyhow::Result<()> {
args: Vec::new(),
env: None,
env_vars: Vec::new(),
cwd: Some(cwd_path.clone()),
cwd: Some(cwd.clone()),
},
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
@@ -6147,7 +6150,7 @@ async fn replace_mcp_servers_serializes_cwd() -> anyhow::Result<()> {
let docs = loaded.get("docs").expect("docs entry");
match &docs.transport {
McpServerTransportConfig::Stdio { cwd, .. } => {
assert_eq!(cwd.as_deref(), Some(Path::new("/tmp/codex-mcp")));
assert_eq!(cwd, &Some(LegacyAppPathString::from_path(&cwd_path)));
}
other => panic!("unexpected transport {other:?}"),
}
@@ -69,7 +69,7 @@ fn serialize_mcp_server_table(config: &McpServerConfig) -> TomlTable {
entry["env_vars"] = array_from_env_vars(env_vars);
}
if let Some(cwd) = cwd {
entry["cwd"] = value(cwd.to_string_lossy().to_string());
entry["cwd"] = value(cwd.as_str());
}
}
McpServerTransportConfig::StreamableHttp {
+2 -1
View File
@@ -27,6 +27,7 @@ use codex_exec_server::HttpRequestParams;
use codex_login::CodexAuth;
use codex_mcp::MCP_SANDBOX_STATE_META_CAPABILITY;
use codex_models_manager::manager::RefreshStrategy;
use codex_utils_path_uri::LegacyAppPathString;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::models::PermissionProfile;
@@ -297,7 +298,7 @@ fn stdio_transport_with_cwd(
args: Vec::new(),
env,
env_vars,
cwd,
cwd: cwd.map(|cwd| LegacyAppPathString::from_path(&cwd)),
}
}
@@ -18,6 +18,7 @@ use codex_plugin::manifest::PluginManifest;
use codex_plugin::manifest::PluginManifestMcpServers;
use codex_plugin::manifest::PluginManifestPaths;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::LegacyAppPathString;
use codex_utils_path_uri::PathUri;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
@@ -169,7 +170,7 @@ async fn reads_declared_config_only_through_executor_file_system() {
args: Vec::new(),
env: None,
env_vars: Vec::new(),
cwd: Some(plugin_root.to_path_buf()),
cwd: Some(LegacyAppPathString::from_path(plugin_root.as_path())),
},
environment_id: "executor-test".to_string(),
enabled: true,
@@ -223,7 +224,7 @@ async fn reads_manifest_object_config_without_executor_file_system_access() {
args: Vec::new(),
env: None,
env_vars: Vec::new(),
cwd: Some(plugin_root.to_path_buf()),
cwd: Some(LegacyAppPathString::from_path(plugin_root.as_path())),
},
environment_id: "executor-test".to_string(),
enabled: true,
+1 -2
View File
@@ -2,7 +2,6 @@ use std::collections::HashMap;
use std::ffi::OsString;
use std::future::Future;
use std::io;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
@@ -354,7 +353,7 @@ impl RmcpClient {
args: Vec<OsString>,
env: Option<HashMap<OsString, OsString>>,
env_vars: &[McpServerEnvVar],
cwd: Option<PathBuf>,
cwd: Option<String>,
launcher: Arc<dyn StdioServerLauncher>,
) -> io::Result<Self> {
let transport_recipe = TransportRecipe::Stdio {
@@ -15,6 +15,7 @@ use std::collections::HashMap;
use std::ffi::OsString;
use std::future::Future;
use std::io;
use std::path::Path;
use std::path::PathBuf;
use std::process::Stdio;
use std::sync::Arc;
@@ -35,6 +36,7 @@ use codex_exec_server::ExecEnvPolicy;
use codex_exec_server::ExecParams;
use codex_exec_server::ExecProcess;
use codex_protocol::config_types::ShellEnvironmentPolicyInherit;
use codex_utils_path_uri::LegacyAppPathString;
use codex_utils_path_uri::PathUri;
#[cfg(unix)]
use codex_utils_pty::process_group::kill_process_group;
@@ -82,7 +84,7 @@ pub struct StdioServerCommand {
args: Vec<OsString>,
env: Option<HashMap<OsString, OsString>>,
env_vars: Vec<McpServerEnvVar>,
cwd: Option<PathBuf>,
cwd: Option<String>,
}
/// Client-side rmcp transport for a launched MCP stdio server.
@@ -149,7 +151,7 @@ impl StdioServerCommand {
args: Vec<OsString>,
env: Option<HashMap<OsString, OsString>>,
env_vars: Vec<McpServerEnvVar>,
cwd: Option<PathBuf>,
cwd: Option<String>,
) -> Self {
Self {
program,
@@ -247,7 +249,7 @@ impl LocalStdioServerLauncher {
} = command;
let program_name = program.to_string_lossy().into_owned();
let envs = create_env_for_mcp_server(env, &env_vars).map_err(io::Error::other)?;
let cwd = cwd.unwrap_or(fallback_cwd);
let cwd = cwd.map(PathBuf::from).unwrap_or(fallback_cwd);
let resolved_program =
program_resolver::resolve(program, &envs, &cwd).map_err(io::Error::other)?;
@@ -479,6 +481,9 @@ impl ExecutorStdioServerLauncher {
"executor stdio server requires an explicit cwd",
));
};
let cwd: PathUri = LegacyAppPathString::from_path(Path::new(&cwd))
.try_into()
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
let program_name = program.to_string_lossy().into_owned();
let envs = create_env_overlay_for_remote_mcp_server(env, &env_vars);
let remote_env_vars = remote_mcp_env_var_names(&env_vars);
@@ -488,7 +493,6 @@ impl ExecutorStdioServerLauncher {
// before sending an executor request.
let argv = Self::process_api_argv(&program, &args).map_err(io::Error::other)?;
let env = Self::process_api_env(envs).map_err(io::Error::other)?;
let cwd = PathUri::from_host_native_path(cwd)?;
let process_id = ExecutorProcessTransport::next_process_id();
// Start the MCP server process on the executor with raw pipes. `tty=false`
// keeps stdout as a clean protocol stream, while `pipe_stdin=true` lets
@@ -0,0 +1,67 @@
use std::ffi::OsString;
use std::sync::Arc;
use std::sync::Mutex;
use codex_exec_server::ExecBackend;
use codex_exec_server::ExecBackendFuture;
use codex_exec_server::ExecParams;
use codex_exec_server::ExecServerError;
use codex_rmcp_client::ExecutorStdioServerLauncher;
use codex_rmcp_client::RmcpClient;
use codex_utils_path_uri::PathUri;
use pretty_assertions::assert_eq;
#[derive(Default)]
struct RecordingExecBackend {
params: Mutex<Option<ExecParams>>,
}
impl ExecBackend for RecordingExecBackend {
fn start(&self, params: ExecParams) -> ExecBackendFuture<'_> {
let mut recorded_params = match self.params.lock() {
Ok(recorded_params) => recorded_params,
Err(poisoned) => poisoned.into_inner(),
};
*recorded_params = Some(params);
Box::pin(async {
Err(ExecServerError::Protocol(
"stop after recording executor request".to_string(),
))
})
}
}
#[tokio::test]
async fn executor_stdio_forwards_foreign_absolute_cwd_as_path_uri() {
#[cfg(not(windows))]
let cwd = r"C:\Users\openai\share";
#[cfg(windows)]
let cwd = "/home/openai/share";
#[cfg(not(windows))]
let expected_cwd: PathUri = "file:///C:/Users/openai/share"
.parse()
.expect("expected cwd should be a path URI");
#[cfg(windows)]
let expected_cwd: PathUri = "file:///home/openai/share"
.parse()
.expect("expected cwd should be a path URI");
let backend = Arc::new(RecordingExecBackend::default());
let launcher = Arc::new(ExecutorStdioServerLauncher::new(backend.clone()));
let _ = RmcpClient::new_stdio_client(
OsString::from("echo"),
Vec::new(),
/*env*/ None,
&[],
Some(cwd.to_string()),
launcher,
)
.await;
let params = backend
.params
.lock()
.expect("recorded params lock should not be poisoned")
.take()
.expect("executor start request should be recorded");
assert_eq!(params.cwd, expected_cwd);
}
+1 -1
View File
@@ -414,7 +414,7 @@ pub(crate) fn new_mcp_tools_output(
lines.push(vec![" • Command: ".into(), cmd_display.into()].into());
if let Some(cwd) = cwd.as_ref() {
lines.push(vec![" • Cwd: ".into(), cwd.display().to_string().into()].into());
lines.push(vec![" • Cwd: ".into(), cwd.to_string().into()].into());
}
let env_display = format_env_display(env.as_ref(), env_vars);
+11 -5
View File
@@ -7,6 +7,7 @@ use serde::Deserialize;
use serde::Serialize;
use serde::Serializer;
use std::fmt;
use std::path::Path;
use thiserror::Error;
use ts_rs::TS;
@@ -18,10 +19,10 @@ use ts_rs::TS;
///
/// When converting from [`PathUri`], "native" refers to the supplied
/// [`PathConvention`], which may be foreign to the operating system running
/// this process. The inner string is private so path-producing code must convert
/// from [`AbsolutePathBuf`] or use [`Self::from_path_uri`] instead of bypassing
/// the intended conversion boundary. Non-UTF-8 paths are converted to UTF-8
/// lossily because this API value is serialized as a JSON string.
/// this process. The inner string is private so path-producing code must use a
/// path conversion method instead of bypassing the intended conversion
/// boundary. Non-UTF-8 paths are converted to UTF-8 lossily because this API
/// value is serialized as a JSON string.
///
/// Deserialization accepts any UTF-8 string without interpreting or validating
/// it. That unrestricted construction path is intentionally available only to
@@ -35,9 +36,14 @@ use ts_rs::TS;
pub struct LegacyAppPathString(String);
impl LegacyAppPathString {
/// Preserves path text without interpreting it using the current host.
pub fn from_path(path: &Path) -> Self {
Self(path.to_string_lossy().into_owned())
}
/// Renders an absolute path using the current host's path convention.
pub fn from_abs_path(path: &AbsolutePathBuf) -> Self {
Self(path.to_string_lossy().into_owned())
Self::from_path(path.as_path())
}
/// Renders a path URI using the requested native path convention.
@@ -496,6 +496,23 @@ fn foreign_absolute_syntax_deserializes_without_host_interpretation() {
}
}
#[test]
fn from_path_preserves_foreign_absolute_path_for_uri_conversion() {
#[cfg(not(windows))]
let (foreign_path, expected_uri) = (r"C:\Users\openai\share", "file:///C:/Users/openai/share");
#[cfg(windows)]
let (foreign_path, expected_uri) = ("/home/openai/share", "file:///home/openai/share");
let path: PathUri = LegacyAppPathString::from_path(std::path::Path::new(foreign_path))
.try_into()
.expect("foreign absolute path should convert");
assert_eq!(
path,
PathUri::parse(expected_uri).expect("valid expected URI")
);
}
#[test]
fn renders_an_absolute_path_using_the_host_convention() {
#[cfg(unix)]