From 0b73618146d55a2565589bf4e2d5e40e8f298d9e Mon Sep 17 00:00:00 2001 From: jif Date: Wed, 14 Oct 2026 14:00:19 +0000 Subject: [PATCH] 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 --- codex-rs/core/src/environment_selection.rs | 24 ++-- codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/session/mcp_runtime.rs | 28 +--- codex-rs/core/src/session/mod.rs | 1 - codex-rs/core/src/session/session.rs | 21 ++- codex-rs/core/src/session/tests.rs | 10 +- codex-rs/core/src/session/turn_context.rs | 73 +++++----- codex-rs/core/src/skills.rs | 36 +++++ codex-rs/core/tests/suite/rmcp_client.rs | 157 ++++++++++++++++++++- 9 files changed, 258 insertions(+), 93 deletions(-) diff --git a/codex-rs/core/src/environment_selection.rs b/codex-rs/core/src/environment_selection.rs index f85efc1e79..f0a70ef14d 100644 --- a/codex-rs/core/src/environment_selection.rs +++ b/codex-rs/core/src/environment_selection.rs @@ -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 { - 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 { + 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> { - 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; diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 08b52fc2e1..e722464838 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -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; diff --git a/codex-rs/core/src/session/mcp_runtime.rs b/codex-rs/core/src/session/mcp_runtime.rs index b813458ffa..a249e36da2 100644 --- a/codex-rs/core/src/session/mcp_runtime.rs +++ b/codex-rs/core/src/session/mcp_runtime.rs @@ -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, 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, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index b285c606be..516db0886a 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -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; diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 725f213d2b..6de001d0dc 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -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); diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index e18314e5cc..592d0e37f7 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -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 diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 40cf7657a6..aa94539f19 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -939,13 +939,26 @@ impl Session { pub(crate) fn build_per_turn_config( &self, session_configuration: &SessionConfiguration, - cwd: AbsolutePathBuf, - workspace_roots: Vec, + 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 { - 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 { 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 { 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 { 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 } diff --git a/codex-rs/core/src/skills.rs b/codex-rs/core/src/skills.rs index 9dca956b9b..43ca328cf6 100644 --- a/codex-rs/core/src/skills.rs +++ b/codex-rs/core/src/skills.rs @@ -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>); +#[cfg(test)] pub(crate) fn skills_load_input_from_config( config: &Config, effective_skill_roots: Vec, @@ -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, + environments: &TurnEnvironmentSnapshot, +) -> (HostSkillsLoadInput, Option>) { + // 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, diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index fb0572ae45..2c0fcf270b 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -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(()) }