mirror of
https://github.com/openai/codex.git
synced 2026-11-14 21:40:39 +00:00
Add process-local launch directories for shared MCP servers (#61444)
## What changed - Add hidden app-server options `--plugin-mcp-launch-cwd` and `--mcp-launch-cwd` for host-supplied local stdio server launch directories without writing them to shared configuration. - Apply these directories when both `mcp_shared_repl_cwd` and `mcp_connection_sharing` are enabled. For configured servers, require a matching executable and preserve any explicit working directory. - Attach thread-specific sandbox state to direct MCP tool calls for servers advertising support, stripping caller-supplied sandbox and launcher metadata first. ## Testing Add regression tests for launch-directory feature toggling and executable matching, and for direct MCP calls using each thread's sandbox working directory despite forged caller metadata. GitOrigin-RevId: d7f78ea92163ec0ee9311f68ef17e87a6694b13b
This commit is contained in:
@@ -11,6 +11,7 @@ use codex_config::loader::load_managed_requirements_state;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigBuilder;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::config::McpLaunchCwds;
|
||||
use codex_exec_server::LOCAL_FS;
|
||||
use codex_features::feature_for_key;
|
||||
use codex_login::AuthManager;
|
||||
@@ -49,6 +50,7 @@ pub(crate) struct ConfigManager {
|
||||
strict_config: bool,
|
||||
cloud_config_bundle: Arc<RwLock<CloudConfigBundleLoader>>,
|
||||
arg0_paths: Arg0DispatchPaths,
|
||||
mcp_launch_cwds: Arc<McpLaunchCwds>,
|
||||
thread_config_loader: Arc<dyn ThreadConfigLoader>,
|
||||
network_policy: codex_http_client::NetworkPolicyController,
|
||||
local_network_policy: codex_http_client::NetworkPolicyController,
|
||||
@@ -108,6 +110,7 @@ impl ConfigManager {
|
||||
strict_config,
|
||||
cloud_config_bundle: Arc::new(RwLock::new(cloud_config_bundle)),
|
||||
arg0_paths,
|
||||
mcp_launch_cwds: Arc::default(),
|
||||
thread_config_loader,
|
||||
network_policy,
|
||||
local_network_policy: Default::default(),
|
||||
@@ -117,6 +120,11 @@ impl ConfigManager {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_mcp_launch_cwds(mut self, cwds: McpLaunchCwds) -> Self {
|
||||
self.mcp_launch_cwds = Arc::new(cwds);
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn codex_home(&self) -> &Path {
|
||||
self.codex_home.as_path()
|
||||
}
|
||||
@@ -283,7 +291,7 @@ impl ConfigManager {
|
||||
self.check_application_policy_load(&policy_load)?;
|
||||
self.apply_network_policy(&mut config);
|
||||
self.apply_runtime_feature_enablement(&mut config);
|
||||
self.apply_arg0_paths(&mut config);
|
||||
self.apply_process_settings(&mut config);
|
||||
config.config_layer_stack = config
|
||||
.config_layer_stack
|
||||
.with_mcp_ema_cancellation(mcp_ema_cancellation);
|
||||
@@ -309,7 +317,7 @@ impl ConfigManager {
|
||||
.await?;
|
||||
self.apply_network_policy(&mut config);
|
||||
self.apply_runtime_feature_enablement(&mut config);
|
||||
self.apply_arg0_paths(&mut config);
|
||||
self.apply_process_settings(&mut config);
|
||||
config.config_layer_stack = config
|
||||
.config_layer_stack
|
||||
.with_mcp_ema_cancellation(mcp_ema_cancellation);
|
||||
@@ -338,7 +346,7 @@ impl ConfigManager {
|
||||
.await?;
|
||||
self.apply_network_policy(&mut config);
|
||||
self.apply_runtime_feature_enablement(&mut config);
|
||||
self.apply_arg0_paths(&mut config);
|
||||
self.apply_process_settings(&mut config);
|
||||
config.config_layer_stack = config
|
||||
.config_layer_stack
|
||||
.with_mcp_ema_cancellation(mcp_ema_cancellation);
|
||||
@@ -478,7 +486,7 @@ impl ConfigManager {
|
||||
.await?;
|
||||
self.apply_network_policy(&mut config);
|
||||
self.apply_runtime_feature_enablement(&mut config);
|
||||
self.apply_arg0_paths(&mut config);
|
||||
self.apply_process_settings(&mut config);
|
||||
config.config_layer_stack = config
|
||||
.config_layer_stack
|
||||
.with_mcp_ema_cancellation(mcp_ema_cancellation);
|
||||
@@ -700,7 +708,7 @@ impl ConfigManager {
|
||||
self.check_application_policy_load(&policy_load)?;
|
||||
self.apply_network_policy(&mut config);
|
||||
self.apply_runtime_feature_enablement(&mut config);
|
||||
self.apply_arg0_paths(&mut config);
|
||||
self.apply_process_settings(&mut config);
|
||||
config.config_layer_stack = config
|
||||
.config_layer_stack
|
||||
.with_mcp_ema_cancellation(mcp_ema_cancellation);
|
||||
@@ -764,9 +772,10 @@ impl ConfigManager {
|
||||
);
|
||||
}
|
||||
|
||||
fn apply_arg0_paths(&self, config: &mut Config) {
|
||||
fn apply_process_settings(&self, config: &mut Config) {
|
||||
config.codex_self_exe = self.arg0_paths.codex_self_exe.clone();
|
||||
config.codex_linux_sandbox_exe = self.arg0_paths.codex_linux_sandbox_exe.clone();
|
||||
config.mcp_launch_cwds = Arc::clone(&self.mcp_launch_cwds);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -108,6 +108,7 @@ mod code_mode_host;
|
||||
mod codex_home_metrics;
|
||||
mod command_exec;
|
||||
mod config_layer;
|
||||
mod plugin_mcp_launch;
|
||||
mod startup_config;
|
||||
pub use startup_config::StartupConfigArgs;
|
||||
pub use startup_config::print_startup_config;
|
||||
@@ -160,6 +161,7 @@ pub use crate::code_mode_host::AppServerCodeModeHostArgs;
|
||||
pub use crate::code_mode_host::CodeModeHostTransport;
|
||||
pub use crate::error_code::INPUT_TOO_LARGE_ERROR_CODE;
|
||||
pub use crate::error_code::INVALID_PARAMS_ERROR_CODE;
|
||||
pub use crate::plugin_mcp_launch::PluginMcpLaunchArgs;
|
||||
pub use crate::transport::AppServerTransport;
|
||||
pub use crate::transport::RemoteControlStartupMode;
|
||||
pub use crate::transport::app_server_control_socket_path;
|
||||
@@ -492,6 +494,7 @@ pub struct AppServerRuntimeOptions {
|
||||
pub remote_control_startup_mode: RemoteControlStartupMode,
|
||||
pub install_shutdown_signal_handler: bool,
|
||||
pub managed_daemon: bool,
|
||||
pub plugin_mcp_launch: PluginMcpLaunchArgs,
|
||||
}
|
||||
|
||||
impl Default for AppServerRuntimeOptions {
|
||||
@@ -503,6 +506,7 @@ impl Default for AppServerRuntimeOptions {
|
||||
remote_control_startup_mode: RemoteControlStartupMode::ResolvePersisted,
|
||||
install_shutdown_signal_handler: true,
|
||||
managed_daemon: false,
|
||||
plugin_mcp_launch: PluginMcpLaunchArgs::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -570,7 +574,8 @@ pub async fn run_main_with_transport_options(
|
||||
Default::default(),
|
||||
arg0_paths.clone(),
|
||||
Arc::new(NoopThreadConfigLoader),
|
||||
);
|
||||
)
|
||||
.with_mcp_launch_cwds(runtime_options.plugin_mcp_launch.into_cwds()?);
|
||||
let startup = config_manager
|
||||
.load_managed_startup_config(startup_cwd.clone())
|
||||
.await?;
|
||||
|
||||
@@ -4,6 +4,7 @@ use clap::Parser;
|
||||
use codex_app_server::AppServerCodeModeHostArgs;
|
||||
use codex_app_server::AppServerRuntimeOptions;
|
||||
use codex_app_server::AppServerTransport;
|
||||
use codex_app_server::PluginMcpLaunchArgs;
|
||||
use codex_app_server::PluginStartupTasks;
|
||||
use codex_app_server::run_main_with_transport_options;
|
||||
use codex_arg0::Arg0DispatchPaths;
|
||||
@@ -77,6 +78,9 @@ struct AppServerArgs {
|
||||
/// Save loaded threads during managed daemon shutdown.
|
||||
#[arg(long, hide = true)]
|
||||
managed_daemon: bool,
|
||||
|
||||
#[command(flatten)]
|
||||
plugin_mcp_launch: PluginMcpLaunchArgs,
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
@@ -94,6 +98,7 @@ fn main() -> anyhow::Result<()> {
|
||||
disable_plugin_startup_tasks_for_tests,
|
||||
remote_control,
|
||||
managed_daemon,
|
||||
plugin_mcp_launch,
|
||||
} = AppServerArgs::parse();
|
||||
let loader_overrides = if disable_managed_config_from_debug_env() {
|
||||
LoaderOverrides::without_managed_config_for_tests()
|
||||
@@ -108,6 +113,7 @@ fn main() -> anyhow::Result<()> {
|
||||
cloud_skills_snapshot,
|
||||
code_mode_host_transport: code_mode_host.into(),
|
||||
managed_daemon,
|
||||
plugin_mcp_launch,
|
||||
..Default::default()
|
||||
};
|
||||
#[cfg(debug_assertions)]
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
//! Parses the host's process-local launch directories for stdio MCP servers.
|
||||
//! Validates server names, plugin IDs, and absolute paths before passing the
|
||||
//! directories to Core. These overrides are never written to shared config.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
use clap::Args;
|
||||
use codex_core::config::McpLaunchCwds;
|
||||
use codex_plugin::PluginId;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
|
||||
/// Launch options for stdio servers in this app-server process.
|
||||
#[derive(Args, Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct PluginMcpLaunchArgs {
|
||||
#[arg(long = "plugin-mcp-launch-cwd", hide = true, num_args = 3, value_names = ["PLUGIN_ID", "SERVER", "DIRECTORY"])]
|
||||
entries: Vec<OsString>,
|
||||
#[arg(long = "mcp-launch-cwd", hide = true, num_args = 3, value_names = ["SERVER", "EXECUTABLE", "DIRECTORY"])]
|
||||
configured_entries: Vec<OsString>,
|
||||
}
|
||||
|
||||
impl PluginMcpLaunchArgs {
|
||||
pub(crate) fn into_cwds(self) -> io::Result<McpLaunchCwds> {
|
||||
let mut cwds = McpLaunchCwds::default();
|
||||
for [plugin_id, server, directory] in self.entries.as_chunks::<3>().0 {
|
||||
let plugin_id = plugin_id.to_str().ok_or_else(|| {
|
||||
io::Error::new(io::ErrorKind::InvalidInput, "plugin ID must be UTF-8")
|
||||
})?;
|
||||
PluginId::parse(plugin_id)
|
||||
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
|
||||
let server = server_name(server)?;
|
||||
let cwd = AbsolutePathBuf::from_absolute_path_checked(Path::new(directory))?;
|
||||
cwds.plugins
|
||||
.entry(plugin_id.to_owned())
|
||||
.or_default()
|
||||
.insert(server.to_owned(), cwd);
|
||||
}
|
||||
for [server, executable, directory] in self.configured_entries.as_chunks::<3>().0 {
|
||||
let server = server_name(server)?;
|
||||
let executable = executable.to_str().ok_or_else(|| {
|
||||
io::Error::new(io::ErrorKind::InvalidInput, "executable must be UTF-8")
|
||||
})?;
|
||||
AbsolutePathBuf::from_absolute_path_checked(Path::new(executable))?;
|
||||
let cwd = AbsolutePathBuf::from_absolute_path_checked(Path::new(directory))?;
|
||||
cwds.configured
|
||||
.insert(server.to_owned(), (executable.to_owned(), cwd));
|
||||
}
|
||||
Ok(cwds)
|
||||
}
|
||||
}
|
||||
|
||||
fn server_name(value: &OsString) -> io::Result<&str> {
|
||||
value
|
||||
.to_str()
|
||||
.filter(|name| !name.is_empty())
|
||||
.ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"server name must be nonempty UTF-8",
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -76,6 +76,7 @@ const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[
|
||||
"mcp_2026_07_28",
|
||||
"mcp_connection_sharing",
|
||||
"mcp_lazy_stdio_startup",
|
||||
"mcp_shared_repl_cwd",
|
||||
"memories",
|
||||
"mentions_v2",
|
||||
"remote_control",
|
||||
|
||||
@@ -42,6 +42,8 @@ use codex_app_server_protocol::TurnStartParams;
|
||||
use codex_app_server_protocol::TurnStartResponse;
|
||||
use codex_app_server_protocol::UserInput as V2UserInput;
|
||||
use codex_features::Feature;
|
||||
use codex_mcp::MCP_SANDBOX_STATE_CAPABILITY;
|
||||
use codex_mcp::MCP_SANDBOX_STATE_META_CAPABILITY;
|
||||
use codex_protocol::mcp::OPENAI_STANDARD_FORM_INPUT_EXTENSION_ID;
|
||||
use codex_protocol::protocol::SessionSource as CoreSessionSource;
|
||||
use codex_protocol::protocol::ThreadSource as CoreThreadSource;
|
||||
@@ -100,6 +102,63 @@ const URL_ELICITATION_TRIGGER_MESSAGE: &str = "auth";
|
||||
const URL_ELICITATION_MESSAGE: &str = "Sign in to GitHub to continue.";
|
||||
const URL_ELICITATION_URL: &str = "https://github.example/login/device";
|
||||
const LATE_ENVIRONMENT_ID: &str = "late-environment";
|
||||
const SANDBOX_STATE_TOOL_NAME: &str = "sandbox_state";
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn mcp_server_tool_call_uses_each_threads_sandbox_cwd() -> Result<()> {
|
||||
let responses_server = responses::start_mock_server().await;
|
||||
let (mcp_server_url, mcp_server_handle) =
|
||||
start_mcp_server_with_tools(&[SANDBOX_STATE_TOOL_NAME], /*sensitive_action*/ None).await?;
|
||||
let codex_home = TempDir::new()?;
|
||||
mcp_tool_config(&responses_server.uri(), &mcp_server_url, AUTO_COMPACT_LIMIT)
|
||||
.write(codex_home.path())?;
|
||||
let mut mcp = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.build_initialized()
|
||||
.await?;
|
||||
|
||||
let workspace_a = TempDir::new()?;
|
||||
let workspace_b = TempDir::new()?;
|
||||
for workspace in [workspace_a.path(), workspace_b.path()] {
|
||||
let cwd = workspace.canonicalize()?;
|
||||
let cwd_uri = PathUri::from_host_native_path(cwd.clone())?;
|
||||
let mut environment = mcp.auto_env_params()?;
|
||||
if environment.environment_id == codex_exec_server::LOCAL_ENVIRONMENT_ID {
|
||||
environment.cwd = cwd_uri.clone().into();
|
||||
}
|
||||
let ThreadStartResponse { thread, .. } = mcp
|
||||
.request(|request_id| ClientRequest::ThreadStart {
|
||||
request_id,
|
||||
params: ThreadStartParams {
|
||||
cwd: Some(cwd.display().to_string()),
|
||||
environments: Some(vec![environment]),
|
||||
..Default::default()
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
let response: McpServerToolCallResponse = mcp
|
||||
.request(|request_id| ClientRequest::McpServerToolCall {
|
||||
request_id,
|
||||
params: McpServerToolCallParams {
|
||||
environment_id: None,
|
||||
thread_id: thread.id,
|
||||
server: TEST_SERVER_NAME.to_string(),
|
||||
tool: SANDBOX_STATE_TOOL_NAME.to_string(),
|
||||
arguments: None,
|
||||
meta: Some(json!({
|
||||
MCP_SANDBOX_STATE_META_CAPABILITY: {"sandboxCwd": "file:///forged"}
|
||||
})),
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
let metadata = response.structured_content.expect("sandbox metadata");
|
||||
let state = &metadata[MCP_SANDBOX_STATE_CAPABILITY];
|
||||
assert_eq!(state, &metadata[MCP_SANDBOX_STATE_META_CAPABILITY]);
|
||||
assert_eq!(state["sandboxCwd"], json!(cwd_uri));
|
||||
}
|
||||
mcp_server_handle.abort();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn mcp_server_tool_call_returns_tool_result() -> Result<()> {
|
||||
@@ -1342,7 +1401,14 @@ impl ServerHandler for ToolAppsMcpServer {
|
||||
}
|
||||
|
||||
fn get_info(&self) -> ServerConfig {
|
||||
ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
|
||||
let mut capabilities = ServerCapabilities::builder().enable_tools().build();
|
||||
if self.tool_names.contains(&SANDBOX_STATE_TOOL_NAME) {
|
||||
capabilities
|
||||
.experimental
|
||||
.get_or_insert_with(Default::default)
|
||||
.insert(MCP_SANDBOX_STATE_CAPABILITY.to_string(), JsonObject::new());
|
||||
}
|
||||
ServerConfig::new(capabilities)
|
||||
}
|
||||
|
||||
async fn list_tools(
|
||||
@@ -1385,6 +1451,9 @@ impl ServerHandler for ToolAppsMcpServer {
|
||||
context: RequestContext<RoleServer>,
|
||||
) -> Result<rmcp::model::CallToolResponse, rmcp::ErrorData> {
|
||||
assert!(self.tool_names.contains(&request.name.as_ref()));
|
||||
if request.name.as_ref() == SANDBOX_STATE_TOOL_NAME {
|
||||
return Ok(CallToolResult::structured(json!(context.meta.0.0)).into());
|
||||
}
|
||||
if matches!(request.name.as_ref(), "js_reset" | "js_add_node_module_dir") {
|
||||
return Ok(CallToolResult::success(vec![ContentBlock::text("setup complete")]).into());
|
||||
}
|
||||
|
||||
@@ -619,6 +619,9 @@ struct AppServerCommand {
|
||||
#[arg(long, hide = true)]
|
||||
managed_daemon: bool,
|
||||
|
||||
#[command(flatten)]
|
||||
plugin_mcp_launch: codex_app_server::PluginMcpLaunchArgs,
|
||||
|
||||
/// Controls whether analytics are enabled by default.
|
||||
///
|
||||
/// Analytics are disabled by default for app-server. Users have to explicitly opt in
|
||||
@@ -1288,6 +1291,7 @@ async fn dispatch_cli(
|
||||
stdio,
|
||||
remote_control,
|
||||
managed_daemon,
|
||||
plugin_mcp_launch,
|
||||
analytics_default_enabled,
|
||||
auth,
|
||||
} = app_server_cli;
|
||||
@@ -1333,6 +1337,7 @@ async fn dispatch_cli(
|
||||
cloud_skills_snapshot,
|
||||
code_mode_host_transport: code_mode_host.into(),
|
||||
managed_daemon,
|
||||
plugin_mcp_launch,
|
||||
remote_control_startup_mode: match (remote_control, remote_control_disabled)
|
||||
{
|
||||
(true, _) => {
|
||||
|
||||
@@ -331,31 +331,12 @@ impl PreparedMcpCall {
|
||||
/// loopback) or remote executor does not establish a shared filesystem.
|
||||
/// Capability probes are cached process-wide by absolute executable path.
|
||||
pub async fn sandbox_codex_executable(&self) -> Option<AbsolutePathBuf> {
|
||||
let server = self
|
||||
.config
|
||||
.mcp_server_catalog
|
||||
.server(self.server_key())?
|
||||
.config();
|
||||
if self.server_environment_id() != codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID
|
||||
|| !server.is_local_environment()
|
||||
|| !matches!(
|
||||
server.transport,
|
||||
codex_config::McpServerTransportConfig::Stdio { .. }
|
||||
)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
// Require a literal absolute path before the constructor can expand `~`.
|
||||
let executable = self
|
||||
.config
|
||||
.codex_self_exe
|
||||
.as_deref()
|
||||
.filter(|path| path.is_absolute() && path.to_str().is_some())?;
|
||||
let executable = AbsolutePathBuf::from_absolute_path_checked(executable).ok()?;
|
||||
crate::sandbox_executable::supports_sandbox_state(&executable)
|
||||
.await
|
||||
.then_some(executable)
|
||||
crate::sandbox_metadata::sandbox_codex_executable(
|
||||
&self.config,
|
||||
self.server_key(),
|
||||
self.server_environment_id(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn server_name(&self) -> &str {
|
||||
|
||||
@@ -61,6 +61,8 @@ use crate::rmcp_client::prepare_regular_mcp_tools_for_model;
|
||||
use crate::runtime::McpPublicationGate;
|
||||
use crate::runtime::McpRuntimeInput;
|
||||
use crate::runtime::McpStartupPolicy;
|
||||
use crate::sandbox_metadata;
|
||||
use crate::sandbox_metadata::SandboxState;
|
||||
use crate::server::McpServerConnectionIdentity;
|
||||
use crate::server::McpServerMetadata;
|
||||
use crate::tools::ToolFilter;
|
||||
@@ -1540,9 +1542,39 @@ impl McpConnectionSet {
|
||||
}
|
||||
(server_timeout, requested_timeout) => server_timeout.or(requested_timeout),
|
||||
};
|
||||
// Direct callers cannot supply host-owned entitlement metadata, even for unlisted tools.
|
||||
if let Some(serde_json::Value::Object(meta)) = meta.as_mut() {
|
||||
meta.remove(ENTITLEMENT_CONTEXT_KEY);
|
||||
// Direct callers cannot set host-owned authority, even for unlisted tools.
|
||||
if let Some(serde_json::Value::Object(metadata)) = meta.as_mut() {
|
||||
metadata.remove(ENTITLEMENT_CONTEXT_KEY);
|
||||
metadata.remove(sandbox_metadata::MCP_SANDBOX_STATE_CAPABILITY);
|
||||
metadata.remove(sandbox_metadata::MCP_SANDBOX_STATE_META_CAPABILITY);
|
||||
metadata.remove(sandbox_metadata::MCP_SANDBOX_LAUNCHER_META_KEY);
|
||||
}
|
||||
let environment_id = &view.metadata.environment_id;
|
||||
let permission_profile = self
|
||||
.config
|
||||
.as_ref()
|
||||
.and_then(|config| config.permission_profile_for_server(server_key));
|
||||
if client.server_supports_sandbox_state_meta_capability
|
||||
&& let Some(config) = self.config.as_ref()
|
||||
&& let Some(permission_profile) = permission_profile
|
||||
&& let Some(sandbox_cwd) = config.environment_cwds.get(environment_id)
|
||||
{
|
||||
sandbox_metadata::augment_metadata(
|
||||
&mut meta,
|
||||
SandboxState {
|
||||
permission_profile: permission_profile.clone(),
|
||||
codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(),
|
||||
sandbox_cwd: sandbox_cwd.clone(),
|
||||
use_legacy_landlock: config.use_legacy_landlock,
|
||||
use_mxc: config
|
||||
.environment_use_mxc
|
||||
.get(environment_id)
|
||||
.copied()
|
||||
.unwrap_or(false),
|
||||
},
|
||||
sandbox_metadata::sandbox_codex_executable(config, server_key, environment_id)
|
||||
.await,
|
||||
)?;
|
||||
}
|
||||
let result: rmcp::model::CallToolResult = client
|
||||
.call_tool(
|
||||
@@ -1550,10 +1582,8 @@ impl McpConnectionSet {
|
||||
arguments,
|
||||
self.network_policy_meta_for_call(
|
||||
&client,
|
||||
self.config
|
||||
.as_ref()
|
||||
.and_then(|config| config.permission_profile_for_server(server_key)),
|
||||
&view.metadata.environment_id,
|
||||
permission_profile,
|
||||
environment_id,
|
||||
meta,
|
||||
),
|
||||
effective_timeout,
|
||||
|
||||
@@ -11,6 +11,9 @@ use serde_json::Map;
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::McpConfig;
|
||||
use crate::McpServerKey;
|
||||
|
||||
/// Servers advertise this experimental capability to receive opaque sandbox state
|
||||
/// in tool-call `_meta`, along with a local launcher when one is available.
|
||||
pub const MCP_SANDBOX_STATE_CAPABILITY: &str = "codex/sandbox-state";
|
||||
@@ -50,6 +53,33 @@ struct SandboxLauncher {
|
||||
codex_executable: AbsolutePathBuf,
|
||||
}
|
||||
|
||||
/// Return a verified CLI on the same host and filesystem as a local stdio server.
|
||||
pub(crate) async fn sandbox_codex_executable(
|
||||
config: &McpConfig,
|
||||
server_key: &McpServerKey,
|
||||
environment_id: &str,
|
||||
) -> Option<AbsolutePathBuf> {
|
||||
let server = config.mcp_server_catalog.server(server_key)?.config();
|
||||
if environment_id != codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID
|
||||
|| !server.is_local_environment()
|
||||
|| !matches!(
|
||||
server.transport,
|
||||
codex_config::McpServerTransportConfig::Stdio { .. }
|
||||
)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let executable = config
|
||||
.codex_self_exe
|
||||
.as_deref()
|
||||
.filter(|path| path.is_absolute() && path.to_str().is_some())?;
|
||||
let executable = AbsolutePathBuf::from_absolute_path_checked(executable).ok()?;
|
||||
crate::sandbox_executable::supports_sandbox_state(&executable)
|
||||
.await
|
||||
.then_some(executable)
|
||||
}
|
||||
|
||||
pub(crate) fn augment_metadata(
|
||||
meta: &mut Option<Value>,
|
||||
state: SandboxState,
|
||||
|
||||
@@ -1193,6 +1193,9 @@
|
||||
"mcp_oauth_refresh_coordination": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"mcp_shared_repl_cwd": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"mcp_tool_list_changed": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -8362,6 +8365,9 @@
|
||||
"mcp_oauth_refresh_coordination": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"mcp_shared_repl_cwd": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"mcp_tool_list_changed": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
@@ -6100,6 +6100,90 @@ async fn rebuild_with_session_layers_refreshes_plugin_derived_mcp_config() -> an
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mcp_launch_cwds_follow_the_runtime_feature() -> anyhow::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let plugin_root = codex_home.path().join("plugins/cache/test/sample/local");
|
||||
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
|
||||
std::fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
r#"{"name":"sample"}"#,
|
||||
)?;
|
||||
std::fs::write(
|
||||
plugin_root.join(".mcp.json"),
|
||||
r#"{"mcpServers":{"sample":{"command":"sample","enabled":false},"other":{"command":"other"}}}"#,
|
||||
)?;
|
||||
let user_config = toml::toml! {
|
||||
[features]
|
||||
plugins = true
|
||||
mcp_connection_sharing = true
|
||||
|
||||
[plugins."sample@test"]
|
||||
enabled = true
|
||||
|
||||
[mcp_servers.node_repl]
|
||||
command = "test-repl"
|
||||
|
||||
[mcp_servers.artifact_session]
|
||||
command = "test-repl"
|
||||
|
||||
[mcp_servers.other_repl]
|
||||
command = "custom-repl"
|
||||
};
|
||||
std::fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
toml::to_string(&user_config)?,
|
||||
)?;
|
||||
|
||||
let mut config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.build()
|
||||
.await?;
|
||||
config.mcp_launch_cwds = Arc::new(McpLaunchCwds {
|
||||
plugins: HashMap::from([(
|
||||
"sample@test".to_string(),
|
||||
HashMap::from([("sample".to_string(), config.codex_home.clone())]),
|
||||
)]),
|
||||
configured: ["node_repl", "artifact_session", "other_repl"]
|
||||
.into_iter()
|
||||
.map(|name| {
|
||||
(
|
||||
name.to_string(),
|
||||
("test-repl".to_string(), config.codex_home.clone()),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
});
|
||||
let plugins_manager =
|
||||
plugins_manager_for_config(&config, auth_manager_from_optional_auth(/*auth*/ None));
|
||||
assert!(!config.features.enabled(Feature::McpSharedReplCwd));
|
||||
for enabled in [false, true, false] {
|
||||
let mut incoming = config.clone();
|
||||
incoming
|
||||
.features
|
||||
.set_enabled(Feature::McpSharedReplCwd, enabled)?;
|
||||
config = config.resolve_runtime_refresh(&incoming, RuntimeConfigRefresh::User)?;
|
||||
let mcp_config = config.to_mcp_config(&plugins_manager).await;
|
||||
let configured_servers = mcp_config.mcp_server_catalog.configured_servers();
|
||||
for (name, command) in [
|
||||
("sample", "sample"),
|
||||
("node_repl", "test-repl"),
|
||||
("artifact_session", "test-repl"),
|
||||
] {
|
||||
let mut expected = stdio_mcp(command);
|
||||
expected.enabled = name != "sample";
|
||||
let McpServerTransportConfig::Stdio { cwd, .. } = &mut expected.transport else {
|
||||
unreachable!();
|
||||
};
|
||||
*cwd = enabled.then(|| LegacyAppPathString::from_abs_path(&config.codex_home));
|
||||
assert_eq!(configured_servers[name], expected);
|
||||
}
|
||||
assert_eq!(configured_servers["other"], stdio_mcp("other"));
|
||||
assert_eq!(configured_servers["other_repl"], stdio_mcp("custom-repl"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn to_mcp_config_omits_plugin_id_when_user_server_shadows_plugin_mcp() -> anyhow::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
@@ -154,6 +154,7 @@ impl ManagedFeatures {
|
||||
for feature in [
|
||||
Feature::EnableMcpApps,
|
||||
Feature::McpConnectionSharing,
|
||||
Feature::McpSharedReplCwd,
|
||||
Feature::McpLazyStdioStartup,
|
||||
Feature::SecretAuthStorage,
|
||||
Feature::McpOAuthRefreshCoordination,
|
||||
|
||||
@@ -47,6 +47,7 @@ use codex_config::types::AuthCredentialsStoreMode;
|
||||
use codex_config::types::AuthKeyringBackendKind;
|
||||
use codex_config::types::History;
|
||||
use codex_config::types::McpServerConfig;
|
||||
use codex_config::types::McpServerTransportConfig;
|
||||
use codex_config::types::MemoriesConfig;
|
||||
use codex_config::types::ModelAvailabilityNuxConfig;
|
||||
use codex_config::types::Notice;
|
||||
@@ -143,6 +144,7 @@ use codex_sandboxing::SandboxType;
|
||||
pub use codex_thread_store::ExtraConfig;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_absolute_path::AbsolutePathBufGuard;
|
||||
use codex_utils_path_uri::LegacyAppPathString;
|
||||
use codex_utils_path_uri::PathConvention;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
use codex_utils_path_uri::Platform;
|
||||
@@ -346,6 +348,14 @@ pub(crate) async fn test_config() -> Config {
|
||||
.expect("load default test config")
|
||||
}
|
||||
|
||||
/// Process-local launch directories for stdio servers supplied by the host.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct McpLaunchCwds {
|
||||
pub plugins: HashMap<String, HashMap<String, AbsolutePathBuf>>,
|
||||
/// Server name, expected executable, and launch directory.
|
||||
pub configured: HashMap<String, (String, AbsolutePathBuf)>,
|
||||
}
|
||||
|
||||
/// Application configuration loaded from disk and merged with overrides.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Permissions {
|
||||
@@ -1047,6 +1057,9 @@ pub struct Config {
|
||||
/// output will be hyperlinked using the specified URI scheme.
|
||||
pub file_opener: UriBasedFileOpener,
|
||||
|
||||
/// Process-local launch directories for stdio servers supplied by the host.
|
||||
pub mcp_launch_cwds: Arc<McpLaunchCwds>,
|
||||
|
||||
/// Path to the current Codex executable. This cannot be set in the config
|
||||
/// file: it must be set in code via [`ConfigOverrides`].
|
||||
pub codex_self_exe: Option<PathBuf>,
|
||||
@@ -1947,6 +1960,8 @@ impl Config {
|
||||
{
|
||||
catalog.enable_ema(auth.idp.clone());
|
||||
}
|
||||
let use_launch_cwds = self.features.enabled(Feature::McpSharedReplCwd)
|
||||
&& self.features.enabled(Feature::McpConnectionSharing);
|
||||
for (plugin_order, plugin) in loaded_plugins
|
||||
.plugins()
|
||||
.iter()
|
||||
@@ -1967,7 +1982,17 @@ impl Config {
|
||||
)
|
||||
}
|
||||
.with_host_root(PathUri::from_abs_path(&plugin.root));
|
||||
for (name, plugin_server) in plugin_mcp_servers {
|
||||
let launch_cwds = self.mcp_launch_cwds.plugins.get(&plugin.config_name);
|
||||
for (name, mut plugin_server) in plugin_mcp_servers {
|
||||
if use_launch_cwds
|
||||
&& let Some(path) = launch_cwds.and_then(|cwds| cwds.get(&name))
|
||||
&& plugin_server.is_local_environment()
|
||||
&& !plugin.is_agent_plugin()
|
||||
&& let McpServerTransportConfig::Stdio { cwd, .. } =
|
||||
&mut plugin_server.transport
|
||||
{
|
||||
*cwd = Some(LegacyAppPathString::from_abs_path(path));
|
||||
}
|
||||
let registration = McpServerRegistration::from_plugin(
|
||||
name,
|
||||
attribution.clone(),
|
||||
@@ -1981,10 +2006,17 @@ impl Config {
|
||||
catalog.register(registration);
|
||||
}
|
||||
for (name, server) in self.mcp_servers.get() {
|
||||
catalog.register(McpServerRegistration::from_config(
|
||||
name.clone(),
|
||||
server.clone(),
|
||||
));
|
||||
let mut server = server.clone();
|
||||
if use_launch_cwds
|
||||
&& let Some((executable, path)) = self.mcp_launch_cwds.configured.get(name)
|
||||
&& server.is_local_environment()
|
||||
&& let McpServerTransportConfig::Stdio { command, cwd, .. } = &mut server.transport
|
||||
&& command == executable
|
||||
&& cwd.is_none()
|
||||
{
|
||||
*cwd = Some(LegacyAppPathString::from_abs_path(path));
|
||||
}
|
||||
catalog.register(McpServerRegistration::from_config(name.clone(), server));
|
||||
}
|
||||
|
||||
McpConfig {
|
||||
@@ -4696,6 +4728,7 @@ impl Config {
|
||||
extra_config: None,
|
||||
bypass_hook_trust,
|
||||
file_opener: cfg.file_opener.unwrap_or(UriBasedFileOpener::VsCode),
|
||||
mcp_launch_cwds: Arc::default(),
|
||||
codex_self_exe,
|
||||
codex_linux_sandbox_exe,
|
||||
main_execve_wrapper_exe: None,
|
||||
|
||||
@@ -89,6 +89,7 @@ impl Config {
|
||||
// Enterprise opt-in always comes from trusted layers instead.
|
||||
for feature in [
|
||||
Feature::McpConnectionSharing,
|
||||
Feature::McpSharedReplCwd,
|
||||
Feature::McpLazyStdioStartup,
|
||||
Feature::SecretAuthStorage,
|
||||
Feature::McpOAuthRefreshCoordination,
|
||||
|
||||
@@ -290,6 +290,8 @@ pub enum Feature {
|
||||
McpToolListChanged,
|
||||
/// Discover and share eligible local stdio connections across chats.
|
||||
McpConnectionSharing,
|
||||
/// Use the app server's fixed launch directories for shared REPL servers.
|
||||
McpSharedReplCwd,
|
||||
/// Defer optional stdio server startup in regular chats with a warm tool catalog.
|
||||
McpLazyStdioStartup,
|
||||
/// Keep same-named HTTP servers discovered on different executors separate.
|
||||
@@ -1768,6 +1770,12 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
stage: Stage::UnderDevelopment,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::McpSharedReplCwd,
|
||||
key: "mcp_shared_repl_cwd",
|
||||
stage: Stage::UnderDevelopment,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::McpLazyStdioStartup,
|
||||
key: "mcp_lazy_stdio_startup",
|
||||
|
||||
@@ -360,6 +360,7 @@ async fn new_config(
|
||||
allow_persistent_dynamic_tool_updates: false,
|
||||
extra_config: None,
|
||||
file_opener: UriBasedFileOpener::VsCode,
|
||||
mcp_launch_cwds: Default::default(),
|
||||
codex_self_exe: arg0_paths.codex_self_exe,
|
||||
codex_linux_sandbox_exe: arg0_paths.codex_linux_sandbox_exe,
|
||||
main_execve_wrapper_exe: None,
|
||||
|
||||
Reference in New Issue
Block a user