Files
codex/codex-rs/codex-mcp/src/runtime.rs
T
jif 3e39e92f03 Keep executor plugin MCP paths URI-native (#29628)
## Why

Executor-owned plugin roots are `PathUri`, but MCP config normalization
still converts them into a native `Path` using the app-server host's
rules. Relative `cwd` values can therefore resolve against the wrong
filesystem when host and executor path conventions differ.

This PR keeps executor MCP paths URI-native until the selected
environment launches the server, while retaining the existing host
parser behavior.

## What changed

- Keep one shared MCP normalization path with narrow host-`Path` and
executor-`PathUri` entrypoints.
- Preserve native host resolution for locally installed plugin MCP
configs.
- For executor configs, default `cwd` to the plugin root and resolve
relative working directories with the root URI's path convention.
- Accept explicit executor `file:` URIs only when they remain within the
selected plugin root.
- Preserve the selected environment id and existing remote
environment-variable ownership rules.
- Route the executor plugin provider through the URI-native entrypoint
without converting the root on the host.
- Ensure `codex doctor` does not probe executor-owned stdio commands or
foreign working directories on the host.
- Cover foreign Windows roots, relative and absolute executor working
directories, traversal rejection, runtime resolution, and doctor
behavior.

```text
plugin root:    file:///C:/plugins/demo
configured cwd: scripts
                  |
                  v
resolved cwd:  file:///C:/plugins/demo/scripts
                  |
                  v
launch through the selected executor
```

No new provider or filesystem abstraction is introduced.

## Stack

1. #29614 — add lexical `PathUri` containment.
2. #29620 — share URI-native manifest path resolution.
3. #28918 — keep selected plugin roots and resources URI-native.
4. #29626 — load executor skills without host path conversion.
5. **This PR** — resolve executor MCP working directories without host
path conversion.
2026-06-24 09:46:07 +01:00

273 lines
9.4 KiB
Rust

//! Runtime support for Model Context Protocol (MCP) servers.
//!
//! This module contains data that describes the runtime environment in which MCP
//! servers execute, plus the sandbox state payload sent to capable servers and a
//! tiny shared metrics helper. Transport startup and orchestration live in
//! [`crate::rmcp_client`] and [`crate::connection_manager`].
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use codex_exec_server::Environment;
use codex_exec_server::EnvironmentManager;
use codex_protocol::models::PermissionProfile;
use codex_utils_path_uri::PathUri;
use serde::Deserialize;
use serde::Serialize;
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SandboxState {
pub permission_profile: PermissionProfile,
pub codex_linux_sandbox_exe: Option<PathBuf>,
pub sandbox_cwd: PathUri,
#[serde(default)]
pub use_legacy_landlock: bool,
}
/// Runtime context used when resolving per-server MCP environments.
///
/// `McpConfig` describes what servers exist. This value carries the canonical
/// environment registry plus the local stdio fallback cwd used when a local
/// stdio server omits its own working directory.
#[derive(Clone)]
pub struct McpRuntimeContext {
environment_manager: Arc<EnvironmentManager>,
local_stdio_fallback_cwd: PathBuf,
}
impl McpRuntimeContext {
pub fn new(
environment_manager: Arc<EnvironmentManager>,
local_stdio_fallback_cwd: PathBuf,
) -> Self {
Self {
environment_manager,
local_stdio_fallback_cwd,
}
}
pub(crate) fn local_stdio_fallback_cwd(&self) -> PathBuf {
self.local_stdio_fallback_cwd.clone()
}
pub(crate) fn resolve_server_environment(
&self,
server_name: &str,
config: &codex_config::McpServerConfig,
) -> Result<Option<Arc<Environment>>, String> {
// Resolve `"local"` through the shared registry when available. Local
// HTTP is the one current exception: it can use the ambient HTTP client
// even when no local Environment is configured.
if let Some(environment) = self
.environment_manager
.get_environment(&config.environment_id)
{
return Ok(Some(environment));
}
if config.is_local_environment() {
return match config.transport {
codex_config::McpServerTransportConfig::Stdio { .. } => Err(format!(
"local stdio MCP server `{server_name}` requires a local environment"
)),
codex_config::McpServerTransportConfig::StreamableHttp { .. } => Ok(None),
};
}
Err(format!(
"MCP server `{server_name}` references unknown environment id `{}`",
config.environment_id
))
}
}
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);
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID;
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::*;
fn stdio_server(environment_id: &str) -> McpServerConfig {
McpServerConfig {
transport: McpServerTransportConfig::Stdio {
command: "echo".to_string(),
args: Vec::new(),
env: None,
env_vars: Vec::new(),
cwd: None,
},
environment_id: environment_id.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
}
}
fn http_server(environment_id: &str) -> McpServerConfig {
McpServerConfig {
transport: McpServerTransportConfig::StreamableHttp {
url: "http://127.0.0.1:1".to_string(),
bearer_token_env_var: None,
http_headers: None,
env_http_headers: None,
},
environment_id: environment_id.to_string(),
..stdio_server(environment_id)
}
}
#[test]
fn local_stdio_requires_local_stdio_availability() {
let runtime_context = McpRuntimeContext::new(
Arc::new(EnvironmentManager::without_environments()),
PathBuf::from("/tmp"),
);
let error = match runtime_context
.resolve_server_environment("stdio", &stdio_server(DEFAULT_MCP_SERVER_ENVIRONMENT_ID))
{
Ok(_) => panic!("local stdio MCP should require a local environment"),
Err(error) => error,
};
assert_eq!(
error,
"local stdio MCP server `stdio` requires a local environment"
);
}
#[test]
fn local_http_does_not_require_local_stdio_availability() {
let runtime_context = McpRuntimeContext::new(
Arc::new(EnvironmentManager::without_environments()),
PathBuf::from("/tmp"),
);
let resolved_runtime = match runtime_context
.resolve_server_environment("http", &http_server(DEFAULT_MCP_SERVER_ENVIRONMENT_ID))
{
Ok(resolved_runtime) => resolved_runtime,
Err(error) => panic!("local HTTP MCP should resolve: {error}"),
};
assert!(resolved_runtime.is_none());
}
#[test]
fn unknown_explicit_environment_is_rejected() {
let runtime_context = McpRuntimeContext::new(
Arc::new(EnvironmentManager::without_environments()),
PathBuf::from("/tmp"),
);
let error =
match runtime_context.resolve_server_environment("stdio", &stdio_server("remote")) {
Ok(_) => panic!("unknown MCP environment should fail"),
Err(error) => error,
};
assert_eq!(
error,
"MCP server `stdio` references unknown environment id `remote`"
);
}
#[tokio::test]
async fn explicit_remote_stdio_and_http_accept_named_environment() {
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(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")),
] {
let resolved_runtime = match resolved_runtime {
Ok(resolved_runtime) => resolved_runtime,
Err(error) => panic!("remote MCP should resolve: {error}"),
};
assert!(resolved_runtime.is_some());
}
}
#[tokio::test]
async fn remote_stdio_accepts_foreign_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(
PathUri::parse("file:///C:/plugins/demo")
.expect("foreign cwd URI")
.into(),
);
let resolved_runtime =
match runtime_context.resolve_server_environment("stdio", &remote_stdio) {
Ok(resolved_runtime) => resolved_runtime,
Err(error) => panic!("foreign cwd should resolve: {error}"),
};
assert!(resolved_runtime.is_some());
}
#[tokio::test]
async fn local_stdio_accepts_local_environment_when_available() {
let runtime_context = McpRuntimeContext::new(
Arc::new(EnvironmentManager::default_for_tests()),
PathBuf::from("/tmp"),
);
let resolved_runtime = match runtime_context
.resolve_server_environment("stdio", &stdio_server(DEFAULT_MCP_SERVER_ENVIRONMENT_ID))
{
Ok(resolved_runtime) => resolved_runtime,
Err(error) => panic!("local stdio MCP should resolve: {error}"),
};
assert!(resolved_runtime.is_some());
}
}