From 4d05d4e172dcea4c2e9907f3b1f06aaf498e7fd6 Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Sat, 13 Jun 2026 01:51:03 +0000 Subject: [PATCH] core: retain and persist executor environment identity --- codex-rs/core/src/agents_md.rs | 5 +- codex-rs/core/src/agents_md_tests.rs | 2 +- .../core/src/context/environment_context.rs | 96 +++++++++------- .../src/context/environment_context_tests.rs | 33 +++--- .../core/src/context_manager/history_tests.rs | 1 + codex-rs/core/src/context_manager/updates.rs | 7 +- codex-rs/core/src/environment_selection.rs | 36 +++--- codex-rs/core/src/guardian/review_session.rs | 2 +- codex-rs/core/src/session/mcp.rs | 21 ++-- .../session/rollout_reconstruction_tests.rs | 8 ++ codex-rs/core/src/session/session.rs | 17 ++- codex-rs/core/src/session/tests.rs | 107 ++++++++++++++---- codex-rs/core/src/session/turn.rs | 27 +++-- codex-rs/core/src/session/turn_context.rs | 66 +++++++---- codex-rs/core/src/thread_manager.rs | 75 +++++++++--- codex-rs/core/src/thread_manager_tests.rs | 50 +++++++- .../agent_jobs/spawn_agents_on_csv.rs | 8 +- .../core/src/tools/handlers/apply_patch.rs | 6 +- .../src/tools/handlers/extension_tools.rs | 7 +- .../src/tools/handlers/request_permissions.rs | 8 +- .../handlers/unified_exec/exec_command.rs | 12 +- .../core/src/tools/handlers/view_image.rs | 6 +- .../src/tools/runtimes/apply_patch_tests.rs | 2 +- codex-rs/core/src/tools/spec_plan_tests.rs | 6 +- .../core/tests/remote_env_windows/BUILD.bazel | 3 + .../remote_env_windows_test.rs | 3 +- codex-rs/core/tests/suite/resume_warning.rs | 1 + codex-rs/protocol/src/protocol.rs | 15 +++ codex-rs/rollout/src/recorder_tests.rs | 1 + codex-rs/state/src/extract.rs | 4 + 30 files changed, 448 insertions(+), 187 deletions(-) diff --git a/codex-rs/core/src/agents_md.rs b/codex-rs/core/src/agents_md.rs index d63342bdde..e04f6a9938 100644 --- a/codex-rs/core/src/agents_md.rs +++ b/codex-rs/core/src/agents_md.rs @@ -52,12 +52,15 @@ pub(crate) async fn load_project_instructions( ) -> Option { let mut loaded = LoadedAgentsMd::from_user_instructions(user_instructions); for turn_environment in &environments.turn_environments { + let Some(cwd) = turn_environment.compatible_cwd() else { + continue; + }; let filesystem = turn_environment.environment.get_filesystem(); match read_agents_md( config, filesystem.as_ref(), &turn_environment.environment_id, - turn_environment.cwd(), + &cwd, ) .await { diff --git a/codex-rs/core/src/agents_md_tests.rs b/codex-rs/core/src/agents_md_tests.rs index 8d0987ca4d..14c12f9b52 100644 --- a/codex-rs/core/src/agents_md_tests.rs +++ b/codex-rs/core/src/agents_md_tests.rs @@ -266,7 +266,7 @@ fn resolved_local_environments( .expect("local environment"), ), cwd, - /*shell*/ None, + crate::shell::default_user_shell(), ) }) .collect(), diff --git a/codex-rs/core/src/context/environment_context.rs b/codex-rs/core/src/context/environment_context.rs index 8aa07d1ed5..d93730cec8 100644 --- a/codex-rs/core/src/context/environment_context.rs +++ b/codex-rs/core/src/context/environment_context.rs @@ -7,9 +7,11 @@ use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSpecialPath; +use codex_protocol::protocol::TurnContextEnvironment; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::ApiPathString; use std::collections::HashSet; use std::path::PathBuf; @@ -28,7 +30,7 @@ pub(crate) struct EnvironmentContext { #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct EnvironmentContextEnvironment { pub(crate) id: String, - pub(crate) cwd: AbsolutePathBuf, + pub(crate) cwd: ApiPathString, pub(crate) shell: String, } @@ -36,25 +38,30 @@ impl EnvironmentContextEnvironment { fn legacy(cwd: AbsolutePathBuf, shell: String) -> Self { Self { id: String::new(), - cwd, + cwd: ApiPathString::new(cwd.to_string_lossy()), shell, } } - fn from_turn_environments(environments: &[TurnEnvironment], shell: &Shell) -> Vec { + fn from_turn_environments(environments: &[TurnEnvironment]) -> Vec { environments .iter() .map(|environment| Self { id: environment.environment_id.clone(), - cwd: environment.cwd().clone(), - shell: environment - .shell - .as_ref() - .map(|shell| shell.name().to_string()) - .unwrap_or_else(|| shell.name().to_string()), + cwd: environment.native_cwd(), + shell: environment.shell.name().to_string(), }) .collect() } + + fn from_turn_context_environment(environment: &TurnContextEnvironment) -> Option { + Some(Self { + id: environment.environment_id.clone(), + cwd: ApiPathString::from_path_uri(&environment.cwd, environment.path_convention) + .ok()?, + shell: environment.shell.clone(), + }) + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -382,21 +389,24 @@ impl EnvironmentContext { ) -> Self { let before_network = Self::network_from_turn_context_item(before); let before_filesystem = Self::filesystem_from_turn_context_item(before); - let environments = match &after.environments { - EnvironmentContextEnvironments::Single(environment) => { - if before.cwd.as_path() != environment.cwd.as_path() { - EnvironmentContextEnvironments::Single(EnvironmentContextEnvironment::legacy( - environment.cwd.clone(), - environment.shell.clone(), - )) - } else { - EnvironmentContextEnvironments::None - } - } - EnvironmentContextEnvironments::Multiple(environments) => { - EnvironmentContextEnvironments::Multiple(environments.clone()) - } - EnvironmentContextEnvironments::None => EnvironmentContextEnvironments::None, + let fallback_shell = match &after.environments { + EnvironmentContextEnvironments::Single(environment) => environment.shell.clone(), + EnvironmentContextEnvironments::Multiple(environments) => environments + .first() + .map(|environment| environment.shell.clone()) + .unwrap_or_default(), + EnvironmentContextEnvironments::None => String::new(), + }; + let before_environments = Self::from_turn_context_item(before, fallback_shell).environments; + let environments_match = if before.environments.is_some() { + before_environments == after.environments + } else { + before_environments.equals_except_shell(&after.environments) + }; + let environments = if environments_match { + EnvironmentContextEnvironments::None + } else { + after.environments.clone() }; let network = if before_network != after.network { after.network.clone() @@ -418,11 +428,10 @@ impl EnvironmentContext { ) } - pub(crate) fn from_turn_context(turn_context: &TurnContext, shell: &Shell) -> Self { + pub(crate) fn from_turn_context(turn_context: &TurnContext, _shell: &Shell) -> Self { let mut context = Self::new( EnvironmentContextEnvironment::from_turn_environments( &turn_context.environments.turn_environments, - shell, ), turn_context.current_date.clone(), turn_context.timezone.clone(), @@ -440,14 +449,27 @@ impl EnvironmentContext { turn_context_item: &TurnContextItem, shell: String, ) -> Self { - let cwd = match AbsolutePathBuf::try_from(turn_context_item.cwd.clone()) { - Ok(cwd) => cwd, - Err(_) => AbsolutePathBuf::resolve_path_against_base(&turn_context_item.cwd, "/"), + let environments = match turn_context_item.environments.as_ref() { + Some(environments) => EnvironmentContextEnvironments::from_vec( + environments + .iter() + .filter_map(EnvironmentContextEnvironment::from_turn_context_environment) + .collect(), + ), + None => { + let cwd = match AbsolutePathBuf::try_from(turn_context_item.cwd.clone()) { + Ok(cwd) => cwd, + Err(_) => { + AbsolutePathBuf::resolve_path_against_base(&turn_context_item.cwd, "/") + } + }; + EnvironmentContextEnvironments::from_vec(vec![ + EnvironmentContextEnvironment::legacy(cwd, shell), + ]) + } }; Self::new_with_environments( - EnvironmentContextEnvironments::from_vec(vec![EnvironmentContextEnvironment::legacy( - cwd, shell, - )]), + environments, turn_context_item.current_date.clone(), turn_context_item.timezone.clone(), Self::network_from_turn_context_item(turn_context_item), @@ -543,20 +565,14 @@ impl ContextualUserFragment for EnvironmentContext { let mut lines = Vec::new(); match &self.environments { EnvironmentContextEnvironments::Single(environment) => { - lines.push(format!( - " {}", - environment.cwd.to_string_lossy() - )); + lines.push(format!(" {}", environment.cwd)); lines.push(format!(" {}", environment.shell)); } EnvironmentContextEnvironments::Multiple(environments) => { lines.push(" ".to_string()); for environment in environments { lines.push(format!(" ", environment.id)); - lines.push(format!( - " {}", - environment.cwd.to_string_lossy() - )); + lines.push(format!(" {}", environment.cwd)); lines.push(format!(" {}", environment.shell)); lines.push(" ".to_string()); } diff --git a/codex-rs/core/src/context/environment_context_tests.rs b/codex-rs/core/src/context/environment_context_tests.rs index c8e491b68c..a9daa8322d 100644 --- a/codex-rs/core/src/context/environment_context_tests.rs +++ b/codex-rs/core/src/context/environment_context_tests.rs @@ -31,13 +31,17 @@ fn test_abs_path(unix_path: &str) -> AbsolutePathBuf { test_path_buf(unix_path).abs() } +fn test_native_path(unix_path: &str) -> ApiPathString { + ApiPathString::new(test_path_buf(unix_path).display().to_string()) +} + #[test] fn serialize_workspace_write_environment_context() { let cwd = test_path_buf("/repo"); let context = EnvironmentContext::new( vec![EnvironmentContextEnvironment { id: "local".to_string(), - cwd: cwd.abs(), + cwd: ApiPathString::new(cwd.display().to_string()), shell: fake_shell_name(), }], Some("2026-02-26".to_string()), @@ -68,7 +72,7 @@ fn serialize_environment_context_with_network() { let context = EnvironmentContext::new( vec![EnvironmentContextEnvironment { id: "local".to_string(), - cwd: test_path_buf("/repo").abs(), + cwd: test_native_path("/repo"), shell: fake_shell_name(), }], Some("2026-02-26".to_string()), @@ -130,7 +134,7 @@ fn serialize_environment_context_with_full_filesystem_profile() { let mut context = EnvironmentContext::new( vec![EnvironmentContextEnvironment { id: "local".to_string(), - cwd: test_path_buf("/repo").abs(), + cwd: test_native_path("/repo"), shell: fake_shell_name(), }], /*current_date*/ None, @@ -169,6 +173,7 @@ fn turn_context_item_filesystem_uses_workspace_roots_instead_of_cwd() { let item = TurnContextItem { turn_id: None, cwd: test_path_buf("/not-the-workspace"), + environments: None, workspace_roots: Some(vec![repo.clone(), other_repo.clone()]), current_date: None, timezone: None, @@ -235,7 +240,7 @@ fn equals_except_shell_compares_cwd() { let context1 = EnvironmentContext::new( vec![EnvironmentContextEnvironment { id: "local".to_string(), - cwd: test_abs_path("/repo"), + cwd: test_native_path("/repo"), shell: fake_shell_name(), }], /*current_date*/ None, @@ -246,7 +251,7 @@ fn equals_except_shell_compares_cwd() { let context2 = EnvironmentContext::new( vec![EnvironmentContextEnvironment { id: "local".to_string(), - cwd: test_abs_path("/repo"), + cwd: test_native_path("/repo"), shell: fake_shell_name(), }], /*current_date*/ None, @@ -262,7 +267,7 @@ fn equals_except_shell_compares_cwd_differences() { let context1 = EnvironmentContext::new( vec![EnvironmentContextEnvironment { id: "local".to_string(), - cwd: test_abs_path("/repo1"), + cwd: test_native_path("/repo1"), shell: fake_shell_name(), }], /*current_date*/ None, @@ -273,7 +278,7 @@ fn equals_except_shell_compares_cwd_differences() { let context2 = EnvironmentContext::new( vec![EnvironmentContextEnvironment { id: "local".to_string(), - cwd: test_abs_path("/repo2"), + cwd: test_native_path("/repo2"), shell: fake_shell_name(), }], /*current_date*/ None, @@ -290,7 +295,7 @@ fn equals_except_shell_ignores_shell() { let context1 = EnvironmentContext::new( vec![EnvironmentContextEnvironment { id: "local".to_string(), - cwd: test_abs_path("/repo"), + cwd: test_native_path("/repo"), shell: "bash".to_string(), }], /*current_date*/ None, @@ -301,7 +306,7 @@ fn equals_except_shell_ignores_shell() { let context2 = EnvironmentContext::new( vec![EnvironmentContextEnvironment { id: "other".to_string(), - cwd: test_abs_path("/repo"), + cwd: test_native_path("/repo"), shell: "zsh".to_string(), }], /*current_date*/ None, @@ -318,7 +323,7 @@ fn serialize_environment_context_with_subagents() { let context = EnvironmentContext::new( vec![EnvironmentContextEnvironment { id: "local".to_string(), - cwd: test_path_buf("/repo").abs(), + cwd: test_native_path("/repo"), shell: fake_shell_name(), }], Some("2026-02-26".to_string()), @@ -352,12 +357,12 @@ fn serialize_environment_context_with_multiple_selected_environments() { vec![ EnvironmentContextEnvironment { id: "local".to_string(), - cwd: local_cwd.abs(), + cwd: ApiPathString::new(local_cwd.display().to_string()), shell: "bash".to_string(), }, EnvironmentContextEnvironment { id: "remote".to_string(), - cwd: remote_cwd.abs(), + cwd: ApiPathString::new(remote_cwd.display().to_string()), shell: "bash".to_string(), }, ], @@ -397,12 +402,12 @@ fn serialize_environment_context_prefers_environment_shell_when_present() { vec![ EnvironmentContextEnvironment { id: "local".to_string(), - cwd: local_cwd.abs(), + cwd: ApiPathString::new(local_cwd.display().to_string()), shell: "powershell".to_string(), }, EnvironmentContextEnvironment { id: "remote".to_string(), - cwd: remote_cwd.abs(), + cwd: ApiPathString::new(remote_cwd.display().to_string()), shell: "cmd".to_string(), }, ], diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index f4bc87f5b8..7ae4b9948e 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -121,6 +121,7 @@ fn reference_context_item() -> TurnContextItem { TurnContextItem { turn_id: Some("reference-turn".to_string()), cwd: PathBuf::from("/tmp/reference-cwd"), + environments: None, workspace_roots: None, current_date: Some("2026-03-23".to_string()), timezone: Some("America/Los_Angeles".to_string()), diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index d7302bbbf8..1d853acb8e 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -30,7 +30,12 @@ fn build_environment_update_item( let prev = previous?; let prev_context = EnvironmentContext::from_turn_context_item(prev, shell.name().to_string()); let next_context = EnvironmentContext::from_turn_context(next, shell); - if prev_context.equals_except_shell(&next_context) { + let contexts_match = if prev.environments.is_some() { + prev_context == next_context + } else { + prev_context.equals_except_shell(&next_context) + }; + if contexts_match { return None; } diff --git a/codex-rs/core/src/environment_selection.rs b/codex-rs/core/src/environment_selection.rs index 809c08e502..f290aa6561 100644 --- a/codex-rs/core/src/environment_selection.rs +++ b/codex-rs/core/src/environment_selection.rs @@ -54,12 +54,14 @@ impl ResolvedTurnEnvironments { .map(|environment| environment.environment.get_filesystem()) } - pub(crate) fn single_local_environment_cwd(&self) -> Option<&AbsolutePathBuf> { + pub(crate) fn single_local_environment_cwd(&self) -> Option { let [environment] = self.turn_environments.as_slice() else { return None; }; - (!environment.environment.is_remote()).then_some(environment.cwd()) + (!environment.environment.is_remote()) + .then(|| environment.compatible_cwd()) + .flatten() } } @@ -87,6 +89,7 @@ pub(crate) async fn resolve_environment_selections( "failed to get info for environment `{environment_id}`: {err}" )) })?; + let path_convention = info.path_convention; let shell = Shell::from_environment_shell_info(info.shell).map_err(|err| { CodexErr::InvalidRequest(format!( "failed to resolve shell for environment `{environment_id}`: {err}" @@ -96,7 +99,8 @@ pub(crate) async fn resolve_environment_selections( environment_id, environment, selected_environment.cwd.clone(), - Some(shell), + path_convention, + shell, )?); } Ok(ResolvedTurnEnvironments { turn_environments }) @@ -233,18 +237,16 @@ url = "ws://127.0.0.1:8765" ); assert_eq!( resolved.primary().expect("primary environment").shell, - Some( - Shell::from_environment_shell_info( - manager - .get_environment("local") - .expect("local environment") - .info() - .await - .expect("local environment info") - .shell - ) - .expect("resolved shell") + Shell::from_environment_shell_info( + manager + .get_environment("local") + .expect("local environment") + .info() + .await + .expect("local environment info") + .shell ) + .expect("resolved shell") ); } @@ -270,7 +272,7 @@ url = "ws://127.0.0.1:8765" REMOTE_ENVIRONMENT_ID.to_string(), remote_environment.clone(), cwd.clone(), - /*shell*/ None, + crate::shell::default_user_shell(), )], }; let multiple = ResolvedTurnEnvironments { @@ -280,12 +282,12 @@ url = "ws://127.0.0.1:8765" REMOTE_ENVIRONMENT_ID.to_string(), remote_environment, cwd.clone(), - /*shell*/ None, + crate::shell::default_user_shell(), ), ], }; - assert_eq!(local.single_local_environment_cwd(), Some(&cwd)); + assert_eq!(local.single_local_environment_cwd(), Some(cwd.clone())); assert_eq!(remote.single_local_environment_cwd(), None); assert_eq!(multiple.single_local_environment_cwd(), None); } diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 2226a1b98b..560b9da057 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -754,7 +754,7 @@ async fn run_review_on_session( .parent_turn .environments .primary() - .map(|environment| environment.cwd().clone()) + .and_then(super::super::session::turn_context::TurnEnvironment::compatible_cwd) .unwrap_or_else(|| params.parent_turn.config.cwd.clone()); let submit_result = run_before_review_deadline( diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index c033511a75..c01740b3b3 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -317,17 +317,18 @@ impl Session { auth.as_ref(), ) .await; - let mcp_runtime_context = match turn_context.environments.primary() { - Some(turn_environment) => McpRuntimeContext::new( - Arc::clone(&self.services.environment_manager), - turn_environment.cwd().to_path_buf(), - ), - None => McpRuntimeContext::new( - Arc::clone(&self.services.environment_manager), + let cwd = turn_context + .environments + .primary() + .and_then(super::turn_context::TurnEnvironment::compatible_cwd) + .unwrap_or_else(|| { #[allow(deprecated)] - turn_context.cwd.to_path_buf(), - ), - }; + turn_context.cwd.clone() + }); + let mcp_runtime_context = McpRuntimeContext::new( + Arc::clone(&self.services.environment_manager), + cwd.into_path_buf(), + ); let mcp_startup_cancellation_token = { let mut guard = self.services.mcp_startup_cancellation_token.lock().await; guard.cancel(); diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index d243946761..c5d0737e6d 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -86,6 +86,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ turn_id: Some(turn_context.sub_id.clone()), #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), + environments: None, workspace_roots: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -126,6 +127,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif turn_id: Some(turn_context.sub_id.clone()), #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), + environments: None, workspace_roots: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -987,6 +989,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis turn_id: Some(turn_context.sub_id.clone()), #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), + environments: None, workspace_roots: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -1069,6 +1072,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis turn_id: Some(turn_context.sub_id.clone()), #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), + environments: None, workspace_roots: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -1099,6 +1103,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu turn_id: Some(turn_context.sub_id.clone()), #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), + environments: None, workspace_roots: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -1221,6 +1226,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo turn_id: Some(current_turn_id.clone()), #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), + environments: None, workspace_roots: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -1341,6 +1347,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea turn_id: Some(turn_context.sub_id.clone()), #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), + environments: None, workspace_roots: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -1504,6 +1511,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear turn_id: Some(turn_context.sub_id.clone()), #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), + environments: None, workspace_roots: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index af4bda3b4e..664e321eae 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -1133,16 +1133,13 @@ impl Session { })? .primary() .cloned(); - let mcp_runtime_context = match turn_environment { - Some(turn_environment) => McpRuntimeContext::new( - Arc::clone(&sess.services.environment_manager), - turn_environment.cwd().to_path_buf(), - ), - None => McpRuntimeContext::new( - Arc::clone(&sess.services.environment_manager), - session_configuration.cwd().to_path_buf(), - ), - }; + let cwd = turn_environment + .and_then(|environment| environment.compatible_cwd()) + .unwrap_or_else(|| session_configuration.cwd().clone()); + let mcp_runtime_context = McpRuntimeContext::new( + Arc::clone(&sess.services.environment_manager), + cwd.into_path_buf(), + ); let mcp_connection_manager = McpConnectionManager::new( &mcp_servers, config.mcp_oauth_credentials_store_mode, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 9535b4533f..6607669eef 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -131,10 +131,12 @@ use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; use codex_protocol::protocol::TurnAbortedEvent; use codex_protocol::protocol::TurnCompleteEvent; +use codex_protocol::protocol::TurnContextEnvironment; use codex_protocol::protocol::TurnStartedEvent; use codex_protocol::protocol::UserMessageEvent; use codex_protocol::protocol::W3cTraceContext; use codex_rmcp_client::ElicitationAction; +use codex_utils_path_uri::PathConvention; use core_test_support::PathBufExt; use core_test_support::PathExt; use core_test_support::context_snapshot; @@ -2656,6 +2658,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { turn_id: Some(turn_context.sub_id.clone()), #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), + environments: None, workspace_roots: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -4039,7 +4042,7 @@ fn turn_environments_for_tests( codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), Arc::clone(environment), cwd.clone(), - /*shell*/ None, + crate::shell::default_user_shell(), )], } } @@ -6218,7 +6221,10 @@ async fn default_turn_keeps_legacy_fallback_cwd_separate_from_stored_thread_envi &turn_environment.environment, &turn_environments.turn_environments[0].environment )); - assert_eq!(turn_environment.cwd(), &selected_cwd); + assert_eq!( + turn_environment.cwd(), + &PathUri::from_abs_path(&selected_cwd) + ); #[allow(deprecated)] let turn_cwd = turn_context.cwd.clone(); assert_eq!(turn_cwd, session_cwd); @@ -6259,7 +6265,7 @@ async fn primary_environment_uses_first_turn_environment() { "second".to_string(), Arc::clone(&first_environment.environment), second_cwd.clone(), - /*shell*/ None, + crate::shell::default_user_shell(), )); assert_eq!( @@ -6278,12 +6284,12 @@ async fn primary_environment_uses_first_turn_environment() { .find(|environment| environment.environment_id == "second") .expect("second environment") .cwd(), - &second_cwd + &PathUri::from_abs_path(&second_cwd) ); assert_eq!(turn_context.environments.turn_environments.len(), 2); assert_eq!( turn_context.environments.turn_environments[1].cwd(), - &second_cwd + &PathUri::from_abs_path(&second_cwd) ); } @@ -7230,39 +7236,25 @@ async fn build_settings_update_items_emits_environment_item_for_network_changes( } #[tokio::test] -async fn environment_context_uses_session_shell_when_environment_shell_is_absent() { +async fn environment_context_uses_selected_environment_shell() { let (mut session, mut turn_context) = make_session_and_context().await; session.services.user_shell = Arc::new(crate::shell::Shell { shell_type: crate::shell::ShellType::PowerShell, shell_path: PathBuf::from("powershell"), shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), }); - for environment in &mut turn_context.environments.turn_environments { - environment.shell = None; - } - - let session_shell = session.user_shell(); - let environment_context = crate::context::EnvironmentContext::from_turn_context( - &turn_context, - session_shell.as_ref(), - ) - .render(); - assert!( - environment_context.contains("powershell"), - "{environment_context}" - ); - let primary_environment = turn_context .environments .turn_environments .first_mut() .expect("primary environment"); - primary_environment.shell = Some(crate::shell::Shell { + primary_environment.shell = crate::shell::Shell { shell_type: crate::shell::ShellType::Cmd, shell_path: PathBuf::from("cmd"), shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), - }); + }; + let session_shell = session.user_shell(); let environment_context = crate::context::EnvironmentContext::from_turn_context( &turn_context, session_shell.as_ref(), @@ -7272,6 +7264,75 @@ async fn environment_context_uses_session_shell_when_environment_shell_is_absent environment_context.contains("cmd"), "{environment_context}" ); + assert!(!environment_context.contains("powershell")); +} + +#[tokio::test] +async fn windows_environment_context_persists_native_identity_without_redundant_update() { + let (session, mut turn_context) = make_session_and_context().await; + let current_environment = turn_context.environments.turn_environments[0].clone(); + let windows_cwd = PathUri::parse("file:///C:/workspace").expect("Windows cwd URI"); + let windows_shell = crate::shell::Shell { + shell_type: crate::shell::ShellType::PowerShell, + shell_path: PathBuf::from(r"C:\Program Files\PowerShell\7\pwsh.exe"), + shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), + }; + turn_context.environments.turn_environments[0] = TurnEnvironment::new_with_uri( + "windows".to_string(), + current_environment.environment, + windows_cwd.clone(), + PathConvention::Windows, + windows_shell, + ) + .expect("Windows environment"); + + let rendered = crate::context::EnvironmentContext::from_turn_context( + &turn_context, + session.user_shell().as_ref(), + ) + .render(); + assert!(rendered.contains(r"C:\workspace"), "{rendered}"); + assert!(rendered.contains("powershell"), "{rendered}"); + assert!(!rendered.contains("/C:/workspace"), "{rendered}"); + + let persisted = turn_context.to_turn_context_item(); + assert_eq!( + persisted.environments, + Some(vec![TurnContextEnvironment { + environment_id: "windows".to_string(), + cwd: windows_cwd, + path_convention: PathConvention::Windows, + shell: "powershell".to_string(), + }]) + ); + assert_eq!( + crate::context::EnvironmentContext::from_turn_context_item(&persisted, "bash".to_string(),) + .render(), + rendered + ); + + let update_items = session + .build_settings_update_items(Some(&persisted), &turn_context) + .await; + assert!( + user_input_texts(&update_items) + .iter() + .all(|text| !text.contains("")) + ); + + turn_context.environments.turn_environments[0].shell = crate::shell::Shell { + shell_type: crate::shell::ShellType::Cmd, + shell_path: PathBuf::from("cmd.exe"), + shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), + }; + let changed_shell_items = session + .build_settings_update_items(Some(&persisted), &turn_context) + .await; + let changed_environment = user_input_texts(&changed_shell_items) + .into_iter() + .find(|text| text.contains("")) + .expect("changed executor shell should update model context"); + assert!(changed_environment.contains("cmd")); } #[tokio::test] diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 6244274a40..90745c8ab7 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -414,13 +414,14 @@ pub(crate) async fn run_turn( async fn turn_diff_display_roots(turn_context: &TurnContext) -> Vec<(String, PathBuf)> { let mut display_roots = Vec::new(); for turn_environment in &turn_context.environments.turn_environments { - let root = get_git_repo_root_with_fs( - turn_environment.environment.get_filesystem().as_ref(), - turn_environment.cwd(), - ) - .await - .unwrap_or_else(|| turn_environment.cwd().clone()) - .into_path_buf(); + let Some(cwd) = turn_environment.compatible_cwd() else { + continue; + }; + let root = + get_git_repo_root_with_fs(turn_environment.environment.get_filesystem().as_ref(), &cwd) + .await + .unwrap_or(cwd) + .into_path_buf(); display_roots.push((turn_environment.environment_id.clone(), root)); } display_roots @@ -628,10 +629,14 @@ async fn build_extension_turn_input_items( .turn_environments .iter() .enumerate() - .map(|(index, environment)| TurnInputEnvironment { - environment_id: environment.environment_id.clone(), - cwd: environment.cwd().as_path().to_path_buf(), - is_primary: index == 0, + .filter_map(|(index, environment)| { + environment + .compatible_cwd() + .map(|cwd| TurnInputEnvironment { + environment_id: environment.environment_id.clone(), + cwd: cwd.into_path_buf(), + is_primary: index == 0, + }) }) .collect::>(); diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index ac509359f9..66f0f4bfac 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -13,10 +13,13 @@ use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ToolMode; use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::ThreadSource; +use codex_protocol::protocol::TurnContextEnvironment; use codex_protocol::protocol::TurnEnvironmentSelection; use codex_sandboxing::compatibility_sandbox_policy_for_permission_profile; use codex_sandboxing::policy_transforms::effective_file_system_sandbox_policy; use codex_sandboxing::policy_transforms::effective_network_sandbox_policy; +use codex_utils_path_uri::ApiPathString; +use codex_utils_path_uri::PathConvention; use codex_utils_path_uri::PathUri; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; @@ -41,14 +44,9 @@ impl TurnSkillsContext { pub(crate) struct TurnEnvironment { pub(crate) environment_id: String, pub(crate) environment: Arc, - // Keep both representations together while cwd consumers migrate to URI semantics. Keeping - // them synchronized means neither representation can be exposed through a mutable reference; - // updates must rebuild the validated pair through `TurnEnvironment::new`. Once - // `TurnEnvironment::cwd` itself becomes a `PathUri`, convert only at native filesystem and - // process-launch boundaries and remove this paired migration state. - cwd: AbsolutePathBuf, - cwd_uri: PathUri, - pub(crate) shell: Option, + cwd: PathUri, + path_convention: PathConvention, + pub(crate) shell: shell::Shell, } impl TurnEnvironment { @@ -57,13 +55,13 @@ impl TurnEnvironment { environment_id: String, environment: Arc, cwd: AbsolutePathBuf, - shell: Option, + shell: shell::Shell, ) -> Self { Self { environment_id, environment, - cwd_uri: PathUri::from_abs_path(&cwd), - cwd, + cwd: PathUri::from_abs_path(&cwd), + path_convention: PathConvention::native(), shell, } } @@ -71,35 +69,53 @@ impl TurnEnvironment { pub(crate) fn new_with_uri( environment_id: String, environment: Arc, - cwd_uri: PathUri, - shell: Option, + cwd: PathUri, + path_convention: PathConvention, + shell: shell::Shell, ) -> CodexResult { - let cwd = cwd_uri.to_abs_path().map_err(|err| { + ApiPathString::from_path_uri(&cwd, path_convention).map_err(|err| { CodexErr::InvalidRequest(format!( - "turn environment cwd `{cwd_uri}` cannot be projected onto this host: {err}" + "turn environment cwd `{cwd}` cannot be rendered for {path_convention}: {err}" )) })?; Ok(Self { environment_id, environment, cwd, - cwd_uri, + path_convention, shell, }) } - pub(crate) fn cwd(&self) -> &AbsolutePathBuf { + pub(crate) fn cwd(&self) -> &PathUri { &self.cwd } pub(crate) fn cwd_uri(&self) -> &PathUri { - &self.cwd_uri + &self.cwd + } + + pub(crate) fn path_convention(&self) -> PathConvention { + self.path_convention + } + + pub(crate) fn native_cwd(&self) -> ApiPathString { + match ApiPathString::from_path_uri(&self.cwd, self.path_convention) { + Ok(cwd) => cwd, + Err(error) => panic!("turn environment cwd was validated at construction: {error}"), + } + } + + pub(crate) fn compatible_cwd(&self) -> Option { + (self.path_convention == PathConvention::native()) + .then(|| self.cwd.to_abs_path().ok()) + .flatten() } pub(crate) fn selection(&self) -> TurnEnvironmentSelection { TurnEnvironmentSelection { environment_id: self.environment_id.clone(), - cwd: self.cwd_uri.clone(), + cwd: self.cwd.clone(), } } } @@ -400,6 +416,18 @@ impl TurnContext { turn_id: Some(self.sub_id.clone()), #[allow(deprecated)] cwd: self.cwd.to_path_buf(), + environments: Some( + self.environments + .turn_environments + .iter() + .map(|environment| TurnContextEnvironment { + environment_id: environment.environment_id.clone(), + cwd: environment.cwd().clone(), + path_convention: environment.path_convention(), + shell: environment.shell.name().to_string(), + }) + .collect(), + ), workspace_roots: (!workspace_roots.is_empty()).then_some(workspace_roots), current_date: self.current_date.clone(), timezone: self.timezone.clone(), diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 07506cb373..ce8623d8b2 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -101,6 +101,31 @@ fn should_use_test_thread_manager_behavior() -> bool { FORCE_TEST_THREAD_MANAGER_BEHAVIOR.load(Ordering::Relaxed) } +fn environment_selections_from_history( + history: &InitialHistory, +) -> Option> { + let items = history.get_rollout_items(); + let context = items.iter().rev().find_map(|item| match item { + RolloutItem::TurnContext(context) => Some(context), + RolloutItem::SessionMeta(_) + | RolloutItem::ResponseItem(_) + | RolloutItem::InterAgentCommunication(_) + | RolloutItem::Compacted(_) + | RolloutItem::EventMsg(_) => None, + })?; + Some( + context + .environments + .as_ref()? + .iter() + .map(|environment| TurnEnvironmentSelection { + environment_id: environment.environment_id.clone(), + cwd: environment.cwd.clone(), + }) + .collect(), + ) +} + struct TempCodexHomeGuard { path: PathBuf, } @@ -758,10 +783,13 @@ impl ThreadManager { auth_manager: Arc, parent_trace: Option, ) -> CodexResult { - let environments = default_thread_environment_selections( - self.state.environment_manager.as_ref(), - &config.cwd, - ); + let environments = + environment_selections_from_history(&initial_history).unwrap_or_else(|| { + default_thread_environment_selections( + self.state.environment_manager.as_ref(), + &config.cwd, + ) + }); let (session_source, thread_source) = initial_history .get_resumed_session_sources() .unwrap_or_else(|| (self.state.session_source.clone(), None)); @@ -821,10 +849,13 @@ impl ThreadManager { user_shell_override: crate::shell::Shell, ) -> CodexResult { let initial_history = self.initial_history_from_rollout_path(rollout_path).await?; - let environments = default_thread_environment_selections( - self.state.environment_manager.as_ref(), - &config.cwd, - ); + let environments = + environment_selections_from_history(&initial_history).unwrap_or_else(|| { + default_thread_environment_selections( + self.state.environment_manager.as_ref(), + &config.cwd, + ) + }); let (session_source, thread_source) = initial_history .get_resumed_session_sources() .unwrap_or_else(|| (self.state.session_source.clone(), None)); @@ -996,10 +1027,12 @@ impl ThreadManager { let interrupted_marker = InterruptedTurnHistoryMarker::from_config_and_version(&config, multi_agent_version); let history = fork_history_from_snapshot(snapshot, history, interrupted_marker); - let environments = default_thread_environment_selections( - self.state.environment_manager.as_ref(), - &config.cwd, - ); + let environments = environment_selections_from_history(&history).unwrap_or_else(|| { + default_thread_environment_selections( + self.state.environment_manager.as_ref(), + &config.cwd, + ) + }); Box::pin(self.state.spawn_thread( config, history, @@ -1288,7 +1321,12 @@ impl ThreadManagerState { inherited_exec_policy, } = options; let environments = - default_thread_environment_selections(self.environment_manager.as_ref(), &config.cwd); + environment_selections_from_history(&initial_history).unwrap_or_else(|| { + default_thread_environment_selections( + self.environment_manager.as_ref(), + &config.cwd, + ) + }); let thread_source = initial_history.get_resumed_thread_source(); Box::pin(self.spawn_thread_with_source( config, @@ -1325,9 +1363,14 @@ impl ThreadManagerState { inherited_exec_policy: Option>, environments: Option>, ) -> CodexResult { - let environments = environments.unwrap_or_else(|| { - default_thread_environment_selections(self.environment_manager.as_ref(), &config.cwd) - }); + let environments = environments + .or_else(|| environment_selections_from_history(&initial_history)) + .unwrap_or_else(|| { + default_thread_environment_selections( + self.environment_manager.as_ref(), + &config.cwd, + ) + }); Box::pin(self.spawn_thread_with_source( config, initial_history, diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index ca11266e33..71f9256959 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -643,7 +643,7 @@ async fn start_thread_seeds_extension_data_for_mcp_and_lifecycle_contributors() } #[tokio::test] -async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() { +async fn resume_and_fork_restore_thread_environments_from_rollout() { let temp_dir = tempdir().expect("tempdir"); let mut config = test_config().await; config.codex_home = temp_dir.path().join("codex-home").abs(); @@ -689,6 +689,13 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() { }) .await .expect("start source thread"); + let source_turn = source.thread.codex.session.new_default_turn().await; + source + .thread + .codex + .session + .record_context_updates_and_set_reference_context_item(&source_turn) + .await; source.thread.ensure_rollout_materialized().await; source .thread @@ -725,11 +732,11 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() { assert_eq!(resumed_turn.environments.turn_environments.len(), 1); assert_eq!( resumed_turn.environments.turn_environments[0].cwd(), - &default_cwd + &PathUri::from_abs_path(&selected_cwd) ); assert_ne!( resumed_turn.environments.turn_environments[0].cwd(), - &selected_cwd + &PathUri::from_abs_path(&default_cwd) ); let forked = manager @@ -752,11 +759,44 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() { assert_eq!(forked_turn.environments.turn_environments.len(), 1); assert_eq!( forked_turn.environments.turn_environments[0].cwd(), - &default_cwd + &PathUri::from_abs_path(&selected_cwd) ); assert_ne!( forked_turn.environments.turn_environments[0].cwd(), - &selected_cwd + &PathUri::from_abs_path(&default_cwd) + ); +} + +#[tokio::test] +async fn environment_selections_from_history_use_latest_context_and_legacy_fallback() { + let (_session, turn_context) = make_session_and_context().await; + let persisted = turn_context.to_turn_context_item(); + let expected = persisted + .environments + .as_ref() + .expect("current context persists environments") + .iter() + .map(|environment| TurnEnvironmentSelection { + environment_id: environment.environment_id.clone(), + cwd: environment.cwd.clone(), + }) + .collect::>(); + + assert_eq!( + environment_selections_from_history(&InitialHistory::Forked(vec![ + RolloutItem::TurnContext(persisted.clone()), + ])), + Some(expected) + ); + + let mut legacy = persisted.clone(); + legacy.environments = None; + assert_eq!( + environment_selections_from_history(&InitialHistory::Forked(vec![ + RolloutItem::TurnContext(persisted), + RolloutItem::TurnContext(legacy), + ])), + None ); } diff --git a/codex-rs/core/src/tools/handlers/agent_jobs/spawn_agents_on_csv.rs b/codex-rs/core/src/tools/handlers/agent_jobs/spawn_agents_on_csv.rs index 2556989621..7d8c2b774f 100644 --- a/codex-rs/core/src/tools/handlers/agent_jobs/spawn_agents_on_csv.rs +++ b/codex-rs/core/src/tools/handlers/agent_jobs/spawn_agents_on_csv.rs @@ -299,7 +299,7 @@ pub async fn handle( Ok(FunctionToolOutput::from_text(content, Some(true))) } -fn single_local_environment_cwd(turn: &TurnContext) -> Result<&AbsolutePathBuf, FunctionCallError> { +fn single_local_environment_cwd(turn: &TurnContext) -> Result { let [turn_environment] = turn.environments.turn_environments.as_slice() else { return Err(FunctionCallError::RespondToModel( "spawn_agents_on_csv requires exactly one local environment".to_string(), @@ -312,5 +312,9 @@ fn single_local_environment_cwd(turn: &TurnContext) -> Result<&AbsolutePathBuf, )); } - Ok(turn_environment.cwd()) + turn_environment.compatible_cwd().ok_or_else(|| { + FunctionCallError::RespondToModel( + "spawn_agents_on_csv requires a host-compatible cwd".to_string(), + ) + }) } diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index a719e9acd9..3fcf69746f 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -359,7 +359,11 @@ impl ApplyPatchHandler { "apply_patch is unavailable in this session".to_string(), )); }; - let cwd = turn_environment.cwd().clone(); + let cwd = turn_environment.compatible_cwd().ok_or_else(|| { + FunctionCallError::RespondToModel( + "apply_patch is not supported for a foreign-path environment".to_string(), + ) + })?; let fs = turn_environment.environment.get_filesystem(); let sandbox = turn.file_system_sandbox_context( /*additional_permissions*/ None, diff --git a/codex-rs/core/src/tools/handlers/extension_tools.rs b/codex-rs/core/src/tools/handlers/extension_tools.rs index 33625317fe..dfa53254bc 100644 --- a/codex-rs/core/src/tools/handlers/extension_tools.rs +++ b/codex-rs/core/src/tools/handlers/extension_tools.rs @@ -114,10 +114,13 @@ async fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall { ConversationHistory::new(invocation.session.clone_history().await.into_raw_items()); let mut environments = Vec::with_capacity(invocation.turn.environments.turn_environments.len()); for environment in &invocation.turn.environments.turn_environments { + let Some(cwd) = environment.compatible_cwd() else { + continue; + }; let additional_permissions = apply_granted_turn_permissions( invocation.session.as_ref(), &environment.environment_id, - environment.cwd().as_path(), + cwd.as_path(), SandboxPermissions::UseDefault, /*additional_permissions*/ None, ) @@ -128,7 +131,7 @@ async fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall { .file_system_sandbox_context(additional_permissions, environment.cwd_uri()); environments.push(ToolEnvironment { environment_id: environment.environment_id.clone(), - cwd: environment.cwd().clone(), + cwd, file_system: environment.environment.get_filesystem(), file_system_sandbox_context, }); diff --git a/codex-rs/core/src/tools/handlers/request_permissions.rs b/codex-rs/core/src/tools/handlers/request_permissions.rs index 53341b14f5..f9cf110cea 100644 --- a/codex-rs/core/src/tools/handlers/request_permissions.rs +++ b/codex-rs/core/src/tools/handlers/request_permissions.rs @@ -70,8 +70,12 @@ impl RequestPermissionsHandler { "request_permissions requires a primary environment".to_string(), )); }; - let mut args: RequestPermissionsArgs = - parse_arguments_with_base_path(&arguments, turn_environment.cwd())?; + let cwd = turn_environment.compatible_cwd().ok_or_else(|| { + FunctionCallError::RespondToModel( + "request_permissions is not supported for a foreign-path environment".to_string(), + ) + })?; + let mut args: RequestPermissionsArgs = parse_arguments_with_base_path(&arguments, &cwd)?; args.permissions = normalize_additional_permissions(args.permissions.into()) .map(codex_protocol::request_permissions::RequestPermissionProfile::from) .map_err(FunctionCallError::RespondToModel)?; diff --git a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs index 55d3befeeb..f9cf4d6020 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs @@ -129,14 +129,16 @@ impl ExecCommandHandler { "unified exec is unavailable in this session".to_string(), )); }; + let base_cwd = turn_environment.compatible_cwd().ok_or_else(|| { + FunctionCallError::RespondToModel( + "cross-platform unified exec requires native remote routing".to_string(), + ) + })?; let cwd = environment_args .workdir .as_deref() .filter(|workdir| !workdir.is_empty()) - .map_or_else( - || turn_environment.cwd().clone(), - |workdir| turn_environment.cwd().join(workdir), - ); + .map_or_else(|| base_cwd.clone(), |workdir| base_cwd.join(workdir)); let environment = Arc::clone(&turn_environment.environment); let fs = environment.get_filesystem(); let args: ExecCommandArgs = parse_arguments_with_base_path(&arguments, &cwd)?; @@ -270,7 +272,7 @@ impl ExecCommandHandler { yield_time_ms, max_output_tokens, cwd, - sandbox_cwd: turn_environment.cwd().clone(), + sandbox_cwd: base_cwd, environment, shell_mode, network: context.turn.network.clone(), diff --git a/codex-rs/core/src/tools/handlers/view_image.rs b/codex-rs/core/src/tools/handlers/view_image.rs index dc52e7e336..6dc4138c0f 100644 --- a/codex-rs/core/src/tools/handlers/view_image.rs +++ b/codex-rs/core/src/tools/handlers/view_image.rs @@ -143,7 +143,11 @@ impl ViewImageHandler { "view_image is unavailable in this session".to_string(), )); }; - let cwd = turn_environment.cwd().clone(); + let cwd = turn_environment.compatible_cwd().ok_or_else(|| { + FunctionCallError::RespondToModel( + "view_image is not supported for a foreign-path environment".to_string(), + ) + })?; let abs_path = cwd.join(path); let sandbox = turn.file_system_sandbox_context( /*additional_permissions*/ None, diff --git a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs index 044002f446..198ed73bd6 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs @@ -20,7 +20,7 @@ fn test_turn_environment(environment_id: &str) -> crate::session::turn_context:: environment_id.to_string(), std::sync::Arc::new(codex_exec_server::Environment::default_for_tests()), std::env::temp_dir().abs(), - /*shell*/ None, + crate::shell::default_user_shell(), ) } diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 7a1e5d8aa9..489c3dcaa2 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -573,8 +573,8 @@ async fn zsh_fork_unified_exec_keeps_shell_parameter_when_remote_environment_ava .environments .primary() .expect("primary environment") - .cwd() - .clone(); + .compatible_cwd() + .expect("host-compatible test cwd"); turn.environments.turn_environments.push( crate::session::turn_context::TurnEnvironment::new( "remote".to_string(), @@ -585,7 +585,7 @@ async fn zsh_fork_unified_exec_keeps_shell_parameter_when_remote_environment_ava .expect("remote test environment"), ), remote_cwd, - /*shell*/ None, + crate::shell::default_user_shell(), ), ); }) diff --git a/codex-rs/core/tests/remote_env_windows/BUILD.bazel b/codex-rs/core/tests/remote_env_windows/BUILD.bazel index 0c84bda045..e7dfa36c9f 100644 --- a/codex-rs/core/tests/remote_env_windows/BUILD.bazel +++ b/codex-rs/core/tests/remote_env_windows/BUILD.bazel @@ -6,6 +6,9 @@ wine_rust_test( srcs = ["remote_env_windows_test.rs"], crate_name = "remote_env_windows_test", crate_root = "remote_env_windows_test.rs", + # Environment identity validation ends this fixture's host-shell-mismatch premise. + # A hermetic target-shell smoke test replaces it once remote shell routing lands. + target_compatible_with = ["@platforms//:incompatible"], windows_binaries = { "wine-windows-exec-server": "//codex-rs/exec-server/testing:windows-exec-server", }, diff --git a/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs b/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs index aa2f555ecd..dd84902a3c 100644 --- a/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs +++ b/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs @@ -90,11 +90,12 @@ async fn windows_exec_server_records_host_shell_mismatch() -> Result<()> { let test = builder.build(&server).await?; let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path()); + let selected_cwd = PathUri::parse("file:///C:/workspace")?; let environments = TurnEnvironmentSelections::new( test.config.cwd.clone(), vec![TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), - cwd: PathUri::from_abs_path(&test.config.cwd), + cwd: selected_cwd.clone(), }], ); diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 2120559751..227d23b3af 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -28,6 +28,7 @@ fn resume_history( let turn_ctx = TurnContextItem { turn_id: Some(turn_id.clone()), cwd: config.cwd.to_path_buf(), + environments: None, workspace_roots: None, current_date: None, timezone: None, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index ca36c808a8..577e700b0f 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -52,6 +52,7 @@ use crate::request_permissions::RequestPermissionsResponse; use crate::request_user_input::RequestUserInputResponse; use crate::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathConvention; use codex_utils_path_uri::PathUri; use schemars::JsonSchema; use serde::Deserialize; @@ -2936,6 +2937,14 @@ pub struct TurnContextNetworkItem { pub denied_domains: Vec, } +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema, TS)] +pub struct TurnContextEnvironment { + pub environment_id: String, + pub cwd: PathUri, + pub path_convention: PathConvention, + pub shell: String, +} + /// Persist once per real user turn after computing that turn's model-visible /// context updates, and again after mid-turn compaction when replacement /// history re-establishes full context, so resume/fork replay can recover the @@ -2945,6 +2954,10 @@ pub struct TurnContextItem { #[serde(default, skip_serializing_if = "Option::is_none")] pub turn_id: Option, pub cwd: PathBuf, + /// Selected executor environments for this turn. `None` identifies legacy + /// rollout items that only persisted the app-host `cwd`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub environments: Option>, /// Effective workspace roots used to materialize symbolic /// `:workspace_roots` filesystem permissions in `permission_profile`. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -5183,6 +5196,7 @@ mod tests { "summary": "auto", }))?; + assert_eq!(item.environments, None); assert_eq!(item.network, None); assert_eq!(item.file_system_sandbox_policy, None); assert_eq!(item.comp_hash, None); @@ -5227,6 +5241,7 @@ mod tests { let item = TurnContextItem { turn_id: None, cwd: test_path_buf("/tmp"), + environments: None, workspace_roots: None, current_date: None, timezone: None, diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index 68896ad202..93d65b8b6e 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -1135,6 +1135,7 @@ async fn resume_candidate_matches_cwd_reads_latest_turn_context() -> std::io::Re item: RolloutItem::TurnContext(TurnContextItem { turn_id: Some("turn-1".to_string()), cwd: latest_cwd.clone(), + environments: None, workspace_roots: None, current_date: None, timezone: None, diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index fbb63dceae..a5663984a6 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -349,6 +349,7 @@ mod tests { &RolloutItem::TurnContext(TurnContextItem { turn_id: Some("turn-1".to_string()), cwd: PathBuf::from("/parent/workspace"), + environments: None, workspace_roots: None, current_date: None, timezone: None, @@ -388,6 +389,7 @@ mod tests { &RolloutItem::TurnContext(TurnContextItem { turn_id: Some("turn-1".to_string()), cwd: PathBuf::from("/workspace"), + environments: None, workspace_roots: None, current_date: None, timezone: None, @@ -424,6 +426,7 @@ mod tests { &RolloutItem::TurnContext(TurnContextItem { turn_id: Some("turn-1".to_string()), cwd: PathBuf::from("/fallback/workspace"), + environments: None, workspace_roots: None, current_date: None, timezone: None, @@ -456,6 +459,7 @@ mod tests { &RolloutItem::TurnContext(TurnContextItem { turn_id: Some("turn-1".to_string()), cwd: PathBuf::from("/fallback/workspace"), + environments: None, workspace_roots: None, current_date: None, timezone: None,