mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
3310fc8ae5
commit
67009bc53f
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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`"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"))),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user