Keep host MCP and plugin configuration local with remote environments (#53990)

## Why

A remote primary environment could supply its working directory and workspace roots to host plugins and MCP processes, breaking relative MCP executable resolution and applying the wrong workspace permissions.

## What changed

- Build host configuration from the local environment, falling back to the session's host working directory and runtime workspace roots when no local environment is selected. Preserve explicitly empty local roots.
- Use the same host configuration for MCP startup and refresh, and refresh MCP state when runtime workspace roots change.
- Keep repository skill discovery paired with the primary executor's working directory and filesystem.
- Box skill warmup and turn context construction futures to reduce enclosing future sizes.

## Testing

Extend the MCP integration test to cover a remote primary with populated, empty, or absent local roots; relative executable resolution before and after refresh; workspace-root revocation; and remote repository skill discovery.

GitOrigin-RevId: 21a73c90507b55fd94261e7bf348695ff8466a24
This commit is contained in:
jif
2026-10-14 14:08:26 +00:00
committed by copyberry
parent 835cd7552d
commit 0b73618146
9 changed files with 258 additions and 93 deletions
+8 -16
View File
@@ -13,7 +13,6 @@ use codex_exec_server::EnvironmentConnectionState;
use codex_exec_server::EnvironmentInfo;
use codex_exec_server::EnvironmentManager;
use codex_exec_server::ExecServerError;
use codex_exec_server::ExecutorFileSystem;
use codex_exec_server::SelectedCapabilityRootsStatus;
use codex_protocol::capabilities::CapabilityRootLocation;
use codex_protocol::capabilities::EnvironmentCapabilityRoots;
@@ -1152,14 +1151,6 @@ impl TurnEnvironmentSnapshot {
self.turn_environments().next()
}
/// Returns the first selected environment's host folders, even if setup is not ready yet.
pub(crate) fn primary_workspace_roots(&self) -> Vec<AbsolutePathBuf> {
self.primary_workspace_root_uris()
.iter()
.filter_map(|root| root.to_abs_path().ok())
.collect()
}
/// Returns the first selection's executor paths, even when setup is not ready yet.
pub(crate) fn primary_workspace_root_uris(&self) -> &[PathUri] {
let selection = match self.environments.first() {
@@ -1187,20 +1178,26 @@ impl TurnEnvironmentSnapshot {
}
pub(crate) fn local_environment_cwd(&self) -> Option<AbsolutePathBuf> {
self.local_selection()
.and_then(|selection| selection.cwd.to_abs_path().ok())
}
/// The local workspace remains host-owned while its executor is still starting.
pub(crate) fn local_selection(&self) -> Option<&TurnEnvironmentSelection> {
self.environments
.iter()
.find_map(|environment| match environment {
TurnEnvironmentState::Ready(environment)
if !environment.environment.is_remote() =>
{
environment.cwd().to_abs_path().ok()
Some(&environment.selection)
}
TurnEnvironmentState::Ready(_) => None,
TurnEnvironmentState::Starting(environment)
if environment.selection.environment_id
== codex_exec_server::LOCAL_ENVIRONMENT_ID =>
{
environment.selection.cwd.to_abs_path().ok()
Some(&environment.selection)
}
TurnEnvironmentState::Starting(_) | TurnEnvironmentState::Failed { .. } => None,
})
@@ -1277,11 +1274,6 @@ impl TurnEnvironmentSnapshot {
.collect()
}
pub(crate) fn primary_filesystem(&self) -> Option<Arc<dyn ExecutorFileSystem>> {
self.primary()
.map(|environment| environment.environment.get_filesystem())
}
pub(crate) fn single_local_environment(&self) -> Option<&TurnEnvironment> {
if self.starting().next().is_some() {
return None;
+1
View File
@@ -135,6 +135,7 @@ pub mod sandboxing;
mod session_prefix;
mod skills;
pub(crate) use skills::maybe_emit_implicit_skill_invocation;
#[cfg(test)]
pub(crate) use skills::skills_load_input_from_config;
mod stream_events_utils;
pub mod test_support;
+6 -22
View File
@@ -39,6 +39,7 @@ impl Session {
next: &SessionConfiguration,
) -> bool {
current.cwd() != next.cwd()
|| current.runtime_workspace_roots != next.runtime_workspace_roots
|| current.step_settings.approval_policy.value()
!= next.step_settings.approval_policy.value()
|| current.step_settings.approvals_reviewer != next.step_settings.approvals_reviewer
@@ -79,19 +80,9 @@ impl Session {
state.active_disabled_plugin_ids.clone(),
)
};
let cwd = environments
.primary()
.and_then(|environment| environment.cwd().to_abs_path().ok())
.unwrap_or_else(|| session_configuration.cwd().clone());
let config = self.build_per_turn_config(
&session_configuration,
cwd,
environments.primary_workspace_roots(),
);
let local_process_cwd = environments
.local_environment_cwd()
.unwrap_or_else(|| session_configuration.cwd().clone())
.to_path_buf();
let config =
self.build_per_turn_config(&session_configuration, environments.local_selection());
let local_process_cwd = config.cwd.to_path_buf();
McpDesiredState {
config: Arc::new(config),
@@ -111,19 +102,12 @@ impl Session {
auth: Option<CodexAuth>,
mcp_projection: McpRuntimeProjection,
resolved_environments: &TurnEnvironmentSnapshot,
mcp_runtime_cwd: PathBuf,
) -> anyhow::Result<()> {
let cwd = AbsolutePathBuf::from_absolute_path(mcp_runtime_cwd)
.unwrap_or_else(|_| session_configuration.cwd().clone());
let config = self.build_per_turn_config(
session_configuration,
cwd,
resolved_environments.primary_workspace_roots(),
resolved_environments.local_selection(),
);
let local_process_cwd = resolved_environments
.local_environment_cwd()
.unwrap_or_else(|| session_configuration.cwd().clone())
.to_path_buf();
let local_process_cwd = config.cwd.to_path_buf();
let desired = McpDesiredState {
config: Arc::new(config),
auth,
-1
View File
@@ -53,7 +53,6 @@ use crate::session::step_settings::ResolvedStepSettings;
use crate::session::step_settings::StepSettings;
use crate::session::turn_context::TurnEnvironment;
use crate::shell_snapshot::SnapshotCredentialBrokerState;
use crate::skills_load_input_from_config;
use crate::stream_events_utils::mark_thread_memory_mode_polluted_if_external_context;
use crate::turn_metadata::TurnMetadataState;
use crate::turn_timing::now_unix_timestamp_ms;
+9 -12
View File
@@ -646,11 +646,13 @@ async fn warm_plugins_and_skills_for_session_init(
return Vec::new();
}
let fs = turn_environments.primary_filesystem();
let effective_skill_roots = plugin_outcome.effective_plugin_skill_roots();
let plugin_skill_snapshots = plugins_manager.plugin_skill_snapshots_for_config(&plugins_input);
let skills_input = skills_load_input_from_config(config.as_ref(), effective_skill_roots)
.with_plugin_skill_snapshots(plugin_skill_snapshots);
let (skills_input, fs) = crate::skills::skills_load_input_for_environments(
&config,
&plugins_manager,
effective_skill_roots,
turn_environments,
);
skills_service
.snapshot_for_config(&skills_input, fs)
.await
@@ -1173,11 +1175,6 @@ impl Session {
let mcp_originator = session_configuration.originator.clone();
let mcp_session_source = session_configuration.session_source.clone();
let mcp_disabled_plugin_ids = session_configuration.disabled_plugin_ids.clone();
let mcp_runtime_cwd = environment_selections
.first()
.and_then(|environment| environment.cwd.to_abs_path().ok())
.map(|cwd| cwd.to_path_buf())
.unwrap_or_else(|| session_configuration.cwd().to_path_buf());
let auth_and_mcp_fut = async move {
let auth = mcp_auth.await;
if config_for_mcp.features.plugin_recommendations_enabled() {
@@ -1480,14 +1477,15 @@ impl Session {
session_configuration.environments = turn_environments.selections();
let resolved_environments = turn_environments.snapshot().await;
let agents_md_manager = Arc::new(AgentsMdManager::new(instructions));
let plugin_skill_warmup = warm_plugins_and_skills_for_session_init(
// Keep discovery's future out of the large embedded startup/resume frame.
let plugin_skill_warmup = Box::pin(warm_plugins_and_skills_for_session_init(
Arc::clone(&config),
Arc::clone(&plugins_manager),
Arc::clone(&skills_service),
&resolved_environments,
extensions.as_ref(),
&session_configuration.session_source,
)
))
.instrument(info_span!(
"session_init.plugin_skill_warmup",
otel.name = "session_init.plugin_skill_warmup",
@@ -1943,7 +1941,6 @@ impl Session {
latest_auth,
mcp_projection,
&resolved_environments,
mcp_runtime_cwd,
)
.await?;
sess.start_mcp_prewarm_worker(mcp_prewarm_rx, mcp_auth_changes);
+2 -8
View File
@@ -7022,10 +7022,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
};
let per_turn_config = session.build_per_turn_config(
&session_configuration,
session_configuration.cwd().clone(),
crate::environment_selection::ThreadEnvironments::primary_workspace_roots_for(
&session_configuration.environments,
),
resolved_turn_environments.local_selection(),
);
let plugins_input = per_turn_config.plugins_config_input();
let plugin_outcome = session
@@ -9290,10 +9287,7 @@ where
});
let per_turn_config = session.build_per_turn_config(
&session_configuration,
session_configuration.cwd().clone(),
crate::environment_selection::ThreadEnvironments::primary_workspace_roots_for(
&session_configuration.environments,
),
resolved_turn_environments.local_selection(),
);
let plugins_input = per_turn_config.plugins_config_input();
let plugin_outcome = session
+40 -33
View File
@@ -939,13 +939,26 @@ impl Session {
pub(crate) fn build_per_turn_config(
&self,
session_configuration: &SessionConfiguration,
cwd: AbsolutePathBuf,
workspace_roots: Vec<AbsolutePathBuf>,
local_environment: Option<&TurnEnvironmentSelection>,
) -> Config {
// todo(aibrahim): store this state somewhere else so we don't need to mut config
let config = session_configuration.original_config_do_not_use.clone();
let mut per_turn_config = (*config).clone();
per_turn_config.cwd = cwd;
// Config is consumed by host plugins and MCP processes. Executor paths and
// permissions remain on their captured environment attachments.
per_turn_config.cwd = local_environment
.and_then(|environment| environment.cwd.to_abs_path().ok())
.unwrap_or_else(|| session_configuration.cwd().clone());
let workspace_roots = local_environment.map_or_else(
|| session_configuration.runtime_workspace_roots.clone(),
|environment| {
environment
.workspace_roots
.iter()
.filter_map(|root| root.to_abs_path().ok())
.collect()
},
);
per_turn_config.permissions.approval_policy =
session_configuration.step_settings.approval_policy.clone();
per_turn_config.workspace_roots = workspace_roots.clone();
@@ -989,14 +1002,15 @@ impl Session {
&self,
session_configuration: &SessionConfiguration,
) -> Config {
let workspace_roots =
crate::environment_selection::ThreadEnvironments::primary_workspace_roots_for(
&session_configuration.environments,
);
let mut config = self.build_per_turn_config(
session_configuration,
session_configuration.cwd().clone(),
workspace_roots,
session_configuration
.environments
.iter()
.find(|environment| {
environment.environment_id == codex_exec_server::LOCAL_ENVIRONMENT_ID
&& !matches!(environment.config, EnvironmentConfigState::Failed(_))
}),
);
config.model = Some(
session_configuration
@@ -1210,14 +1224,15 @@ impl Session {
turn_environments: TurnEnvironmentSnapshot,
options: NewTurnContextOptions,
) -> Arc<TurnContext> {
self.new_turn_context_from_configuration(
// Keep context construction out of the enclosing turn and history futures.
Box::pin(self.new_turn_context_from_configuration(
sub_id,
session_configuration,
turn_environments,
options,
TurnContextBuildMode::Full,
self.git_enrichment_policy,
)
))
.await
}
@@ -1227,14 +1242,14 @@ impl Session {
session_configuration: SessionConfiguration,
) -> Arc<TurnContext> {
let turn_environments = self.services.turn_environments.snapshot().await;
self.new_turn_context_from_configuration(
Box::pin(self.new_turn_context_from_configuration(
sub_id,
session_configuration,
turn_environments,
NewTurnContextOptions::default(),
TurnContextBuildMode::StartupPrewarm,
GitEnrichmentPolicy::Skip,
)
))
.await
}
@@ -1249,17 +1264,12 @@ impl Session {
git_enrichment_policy: GitEnrichmentPolicy,
) -> Arc<TurnContext> {
let primary_turn_environment = turn_environments.primary();
// TODO(anp): Migrate per-turn config and legacy TurnContext cwd consumers to PathUri so
// a foreign primary environment does not fall back to the session's host cwd.
let per_turn_config =
self.build_per_turn_config(&session_configuration, turn_environments.local_selection());
// Legacy turn consumers still refer to the primary executor's repository.
let cwd = primary_turn_environment
.as_ref()
.and_then(|turn_environment| turn_environment.cwd().to_abs_path().ok())
.and_then(|environment| environment.cwd().to_abs_path().ok())
.unwrap_or_else(|| session_configuration.cwd().clone());
let per_turn_config = self.build_per_turn_config(
&session_configuration,
cwd.clone(),
turn_environments.primary_workspace_roots(),
);
let network_permission_profile = primary_turn_environment
.map(TurnEnvironment::permission_profile)
.cloned()
@@ -1308,15 +1318,12 @@ impl Session {
HostSkillsSnapshot::new(Arc::new(SkillLoadOutcome::default()))
} else {
let effective_skill_roots = plugin_outcome.effective_plugin_skill_roots();
let plugin_skill_snapshots = self
.services
.plugins_manager
.plugin_skill_snapshots_for_config(&plugins_input);
let skills_input =
skills_load_input_from_config(&per_turn_config, effective_skill_roots)
.with_plugin_skill_snapshots(plugin_skill_snapshots);
let fs = primary_turn_environment
.map(|turn_environment| turn_environment.environment.get_filesystem());
let (skills_input, fs) = crate::skills::skills_load_input_for_environments(
&per_turn_config,
&self.services.plugins_manager,
effective_skill_roots,
&turn_environments,
);
self.services
.skills_service
.snapshot_for_config(&skills_input, fs)
@@ -1444,14 +1451,14 @@ impl Session {
async fn new_default_turn_for(&self, build_mode: TurnContextBuildMode) -> Arc<TurnContext> {
let session_configuration = self.default_turn_configuration().await;
let turn_environments = self.services.turn_environments.snapshot().await;
self.new_turn_context_from_configuration(
Box::pin(self.new_turn_context_from_configuration(
self.next_internal_sub_id(),
session_configuration,
turn_environments,
NewTurnContextOptions::default(),
build_mode,
self.git_enrichment_policy,
)
))
.await
}
+36
View File
@@ -1,4 +1,5 @@
use crate::config::Config;
use crate::environment_selection::TurnEnvironmentSnapshot;
use crate::session::session::Session;
use crate::session::turn_context::TurnContext;
use codex_analytics::InvocationType;
@@ -6,6 +7,8 @@ use codex_analytics::SkillInvocation;
use codex_analytics::SkillInvocationLocation;
use codex_analytics::TrackEventsContext;
use codex_analytics::build_track_events_context;
use codex_core_plugins::PluginsManager;
use codex_exec_server::ExecutorFileSystem;
use codex_extension_api::SkillInvocationInput;
use codex_extension_api::SkillInvocationKind;
use codex_otel::SkillInvocationEvent;
@@ -21,11 +24,13 @@ use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
use codex_utils_plugins::PluginSkillRoot;
use std::collections::HashSet;
use std::sync::Arc;
use tokio::sync::Mutex;
#[derive(Debug, Default)]
struct ImplicitSkillInvocations(Mutex<HashSet<String>>);
#[cfg(test)]
pub(crate) fn skills_load_input_from_config(
config: &Config,
effective_skill_roots: Vec<PluginSkillRoot>,
@@ -37,6 +42,37 @@ pub(crate) fn skills_load_input_from_config(
)
}
/// Pair repository discovery inputs with their captured filesystem owner.
/// Keep preparation synchronous so it adds no nested startup or resume future.
pub(crate) fn skills_load_input_for_environments(
config: &Config,
plugins_manager: &PluginsManager,
effective_skill_roots: Vec<PluginSkillRoot>,
environments: &TurnEnvironmentSnapshot,
) -> (HostSkillsLoadInput, Option<Arc<dyn ExecutorFileSystem>>) {
// Preserve primary-repository discovery independently of the host config cwd.
let workspace = environments.primary().and_then(|environment| {
environment
.cwd()
.to_abs_path()
.ok()
.map(|cwd| (cwd, environment.environment.get_filesystem()))
});
let (cwd, fs) = match workspace {
Some((cwd, fs)) => (cwd, Some(fs)),
None => (config.cwd.clone(), None),
};
let input = HostSkillsLoadInput::new(
cwd,
effective_skill_roots,
config.config_layer_stack.clone(),
)
.with_plugin_skill_snapshots(
plugins_manager.plugin_skill_snapshots_for_config(&config.plugins_config_input()),
);
(input, fs)
}
pub(crate) async fn emit_explicit_skill_invocations(
sess: &Session,
turn_context: &TurnContext,
+156 -1
View File
@@ -2072,6 +2072,10 @@ async fn stdio_server_uses_configured_cwd_before_runtime_fallback() -> anyhow::R
#[serial(mcp_cwd)]
async fn local_stdio_server_uses_runtime_fallback_cwd_when_config_omits_cwd() -> anyhow::Result<()>
{
use codex_core::StartThreadOptions;
use core_test_support::exec_server::ExecServerProcess;
use core_test_support::test_codex::local;
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
@@ -2085,9 +2089,26 @@ async fn local_stdio_server_uses_runtime_fallback_cwd_when_config_omits_cwd() ->
.expect("test stdio server binary should have a file name"),
);
let relative_command = relative_server_path.to_string_lossy().into_owned();
let additional_workspace = tempdir()?;
let additional_root = AbsolutePathBuf::try_from(additional_workspace.path().to_path_buf())?;
let fixture = test_codex()
let mut extensions = codex_extension_api::ExtensionRegistryBuilder::new();
codex_skills_extension::install(&mut extensions, |config: &Config| {
codex_skills_extension::SkillsExtensionConfig {
include_instructions: config.include_skill_instructions,
max_context_tokens: config.skill_max_context_tokens,
bundled_skills_enabled: config.bundled_skills_enabled(),
cloud_skill_enabled: config.cloud_skill_enabled,
shadow_selection_enabled: config.features.enabled(Feature::SkillSearch),
}
});
let mut fixture = test_codex()
.with_extensions(Arc::new(extensions.build()))
.with_config(move |config| {
config.workspace_roots = vec![config.cwd.clone(), additional_root.clone()];
config
.permissions
.set_workspace_roots(config.workspace_roots.clone());
*expected_cwd_for_config
.lock()
.expect("expected cwd lock should not be poisoned") =
@@ -2128,6 +2149,140 @@ async fn local_stdio_server_uses_runtime_fallback_cwd_when_config_omits_cwd() ->
call_cwd_tool(&server, &fixture, server_name, "call-local-fallback-cwd").await?;
assert_cwd_tool_output(&structured, &expected_cwd);
// The relative MCP executable exists only in the host workspace. A remote
// primary must not change its command resolution at startup or refresh.
let executor = ExecServerProcess::start().await?;
let remote_workspace = tempdir()?;
let remote_skill = remote_workspace
.path()
.join(".agents/skills/remote-fixture");
fs::create_dir_all(&remote_skill)?;
fs::write(
remote_skill.join("SKILL.md"),
"---\nname: remote-fixture\ndescription: Remote repository skill fixture\n---\nRemote instructions\n",
)?;
let manager = fixture.thread_manager.environment_manager();
manager.upsert_environment(
"other".to_string(),
executor.websocket_url.clone(),
/*connect_timeout*/ None,
)?;
for local_roots in [
Some(vec![fixture.config.cwd.clone()]),
Some(Vec::new()),
None,
] {
let mut environments = vec![TurnEnvironmentRequest {
environment_id: "other".to_string(),
cwd: PathUri::from_host_native_path(remote_workspace.path())?,
workspace_roots: vec![PathUri::from_host_native_path(remote_workspace.path())?],
config: EnvironmentConfigState::FromThread,
}];
if let Some(roots) = &local_roots {
let mut local = local(fixture.config.cwd.clone()).into_request();
local.workspace_roots = roots.iter().map(PathUri::from_abs_path).collect();
environments.push(local);
}
let started = fixture
.thread_manager
.start_thread(StartThreadOptions {
environments: Some(environments),
..StartThreadOptions::new(fixture.config.clone())
})
.await?;
fixture.codex = started.thread;
fixture.session_configured = started.session_configured;
wait_for_mcp_server(&fixture.codex, server_name).await?;
// A fresh thread must still discover the primary executor's repository
// skills; host routing must not silently replace them with local skills.
let request = responses::mount_sse_once(
&server,
responses::sse(vec![responses::ev_completed("remote-skills")]),
)
.await;
fixture.submit_turn("list repository skills").await?;
assert!(
request
.single_request()
.message_input_texts("developer")
.join("\n")
.contains("Remote repository skill fixture")
);
let initial = call_cwd_tool(&server, &fixture, server_name, "before-refresh").await?;
assert_cwd_tool_output(&initial, &expected_cwd);
fixture.codex.submit(Op::RefreshMcpServers).await?;
let refreshed = call_cwd_tool(&server, &fixture, server_name, "after-refresh").await?;
assert_cwd_tool_output(&refreshed, &expected_cwd);
// Host MCP policy follows explicit local roots, including an empty list,
// and falls back to the saved host roots only when local is absent.
for (phase, updated_roots) in [("initial", None), ("revoked", Some(Vec::new()))] {
let expected_roots = local_roots
.as_ref()
.or(updated_roots.as_ref())
.unwrap_or(&fixture.config.workspace_roots)
.clone();
let call_id = format!("host-sandbox-{phase}");
mount_sse_once(
&server,
responses::sse(vec![
responses::ev_function_call_with_namespace(
&call_id,
&format!("mcp__{server_name}"),
"sandbox_meta",
"{}",
),
responses::ev_completed("sandbox-meta"),
]),
)
.await;
let final_request = mount_sse_once(
&server,
responses::sse(vec![responses::ev_completed("sandbox-meta-done")]),
)
.await;
fixture
.codex
.start_or_steer_turn(
TurnInputRequest::user_input(vec![UserInput::Text {
text: "inspect host MCP permissions".into(),
text_elements: Vec::new(),
}])
.with_thread_settings(ThreadSettingsOverrides {
permission_profile: Some(PermissionProfile::workspace_write()),
runtime_workspace_roots: updated_roots,
..Default::default()
}),
)
.await?;
wait_for_event(&fixture.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
let output = final_request
.single_request()
.function_call_output(&call_id);
let metadata: Value = serde_json::from_str(split_wall_time_wrapped_output(
output["output"]
.as_str()
.context("sandbox metadata output")?,
))?;
assert_eq!(
metadata.get(MCP_SANDBOX_STATE_CAPABILITY),
Some(&serde_json::to_value(SandboxState {
permission_profile: PermissionProfile::workspace_write()
.materialize_project_roots_with_workspace_roots(&expected_roots),
codex_linux_sandbox_exe: fixture.config.codex_linux_sandbox_exe.clone(),
sandbox_cwd: PathUri::from_abs_path(&fixture.config.cwd),
use_legacy_landlock: false,
use_mxc: false,
})?),
);
}
}
server.verify().await;
Ok(())
}