diff --git a/codex-rs/core/src/agent/control/spawn.rs b/codex-rs/core/src/agent/control/spawn.rs index 9e06fd4e74..b4dcbc81e6 100644 --- a/codex-rs/core/src/agent/control/spawn.rs +++ b/codex-rs/core/src/agent/control/spawn.rs @@ -455,10 +455,10 @@ impl AgentControl { let owner_environment = parent_environments .turn_environments() .find(|environment| { - environment.selection.environment_id == *environment_id - && environment.cwd() == &selection.cwd - && environment.workspace_roots() - == selection.workspace_roots.as_slice() + let parent_selection = &environment.selection; + parent_selection.environment_id == selection.environment_id + && parent_selection.cwd == selection.cwd + && parent_selection.workspace_roots == selection.workspace_roots }) .ok_or_else(|| { invalid_environment("no longer matches a ready parent environment") @@ -493,8 +493,8 @@ impl AgentControl { let cwd = selection.cwd.to_abs_path().map_err(|_| { invalid_environment("working directory is not a local absolute path") })?; - let roots = selection - .workspace_roots + let roots = owner_environment + .workspace_roots() .iter() .map(PathUri::to_abs_path) .collect::, _>>() diff --git a/codex-rs/core/src/agents_md_tests.rs b/codex-rs/core/src/agents_md_tests.rs index adeb74241d..bfa1f8768a 100644 --- a/codex-rs/core/src/agents_md_tests.rs +++ b/codex-rs/core/src/agents_md_tests.rs @@ -368,6 +368,7 @@ fn resolved_local_environments( workspace_roots: Vec::new(), config: EnvironmentConfigState::Ready(EnvironmentConfig { allow_login_shell: true, + workspace_roots: Vec::new(), windows_sandbox_level: WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: true, use_legacy_landlock: false, diff --git a/codex-rs/core/src/context/environment_context.rs b/codex-rs/core/src/context/environment_context.rs index bc28012e99..1b6b086b1d 100644 --- a/codex-rs/core/src/context/environment_context.rs +++ b/codex-rs/core/src/context/environment_context.rs @@ -34,13 +34,9 @@ impl FileSystemContext { permission_profile: &PermissionProfile, workspace_roots: &[PathUri], ) -> Self { - let materialized_workspace_roots = workspace_roots - .iter() - .filter_map(|workspace_root| workspace_root.to_abs_path().ok()) - .collect::>(); let permission_profile = permission_profile .clone() - .materialize_project_roots_with_workspace_roots(&materialized_workspace_roots); + .materialize_project_roots_with_path_uris(workspace_roots); let workspace_roots = workspace_roots .iter() .map(PathUri::inferred_native_path_string) diff --git a/codex-rs/core/src/environment_selection.rs b/codex-rs/core/src/environment_selection.rs index bbd04e9657..465aaf4fec 100644 --- a/codex-rs/core/src/environment_selection.rs +++ b/codex-rs/core/src/environment_selection.rs @@ -168,7 +168,10 @@ fn resolve_selection_config( ) -> (TurnEnvironmentSelection, EnvironmentConfigOrigin) { let (config, origin) = match selection.config { EnvironmentConfigState::FromThread => ( - EnvironmentConfigState::Ready(thread_config.clone()), + EnvironmentConfigState::Ready(thread_config_for_selection( + &selection.workspace_roots, + thread_config, + )), EnvironmentConfigOrigin::Thread, ), config @ (EnvironmentConfigState::Ready(_) @@ -179,10 +182,23 @@ fn resolve_selection_config( (selection, origin) } +fn thread_config_for_selection( + workspace_roots: &[PathUri], + thread_config: &EnvironmentConfig, +) -> EnvironmentConfig { + EnvironmentConfig { + workspace_roots: workspace_roots.to_vec(), + ..thread_config.clone() + } +} + impl SelectedTurnEnvironment { fn refresh_thread_config(&mut self, config: &EnvironmentConfig) { if self.config_origin == EnvironmentConfigOrigin::Thread { - self.selection.config = EnvironmentConfigState::Ready(config.clone()); + self.selection.config = EnvironmentConfigState::Ready(thread_config_for_selection( + &self.selection.workspace_roots, + config, + )); } } } @@ -916,6 +932,7 @@ mod tests { fn test_environment_config() -> EnvironmentConfig { EnvironmentConfig { allow_login_shell: true, + workspace_roots: Vec::new(), windows_sandbox_level: WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: true, use_legacy_landlock: false, @@ -1093,6 +1110,7 @@ url = "ws://127.0.0.1:8765" }; let expected_config = EnvironmentConfig { allow_login_shell: false, + workspace_roots: Vec::new(), windows_sandbox_level: WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: true, use_legacy_landlock: false, @@ -1344,6 +1362,7 @@ url = "ws://127.0.0.1:8765" let cwd = AbsolutePathBuf::current_dir().expect("cwd"); let expected_config = EnvironmentConfig { allow_login_shell: false, + workspace_roots: Vec::new(), windows_sandbox_level: WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: true, use_legacy_landlock: false, @@ -1701,6 +1720,7 @@ url = "ws://127.0.0.1:8765" .expect("replacement environment"); let child_config = EnvironmentConfig { allow_login_shell: false, + workspace_roots: Vec::new(), windows_sandbox_level: WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: true, use_legacy_landlock: false, @@ -1764,6 +1784,7 @@ url = "ws://127.0.0.1:8765" resolve_turn_environments(Arc::clone(&manager), std::slice::from_ref(&selection)).await; let parent_owner_config = EnvironmentConfig { allow_login_shell: false, + workspace_roots: selection.workspace_roots.clone(), windows_sandbox_level: WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: true, use_legacy_landlock: false, diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 908ec42807..34e019fcc6 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -649,7 +649,7 @@ async fn build_guardian_prompt_includes_parent_turn_denied_reads() -> anyhow::Re }; environment.config_mut().permission_profile = PermissionProfileSnapshot::legacy(environment_permission_profile); - environment.selection.workspace_roots = vec![ + environment.config_mut().workspace_roots = vec![ PathUri::from_abs_path(&workspace_root), PathUri::from_abs_path(&second_workspace_root), ]; diff --git a/codex-rs/core/src/mcp_openai_file.rs b/codex-rs/core/src/mcp_openai_file.rs index d135f60a25..9958f035a3 100644 --- a/codex-rs/core/src/mcp_openai_file.rs +++ b/codex-rs/core/src/mcp_openai_file.rs @@ -286,6 +286,7 @@ mod tests { }; primary.selection.cwd = PathUri::from_abs_path(&cwd); primary.selection.workspace_roots.clear(); + primary.config_mut().workspace_roots.clear(); } #[tokio::test] diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 1e4cbb5066..37f8f2eb59 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -1178,6 +1178,7 @@ async fn mcp_sandbox_cwd_uses_matching_server_environment_uri() -> anyhow::Resul workspace_roots: Vec::new(), config: EnvironmentConfigState::Ready(EnvironmentConfig { allow_login_shell: true, + workspace_roots: Vec::new(), windows_sandbox_level: turn_context.windows_sandbox_level, windows_sandbox_private_desktop: turn_context .config diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 59689978aa..ffbec36a73 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -142,6 +142,7 @@ impl SessionConfiguration { pub(super) fn inferred_environment_config(&self) -> EnvironmentConfig { EnvironmentConfig { allow_login_shell: self.allow_login_shell, + workspace_roots: Vec::new(), permission_profile: self.permission_profile_state.snapshot(), shell_environment_policy: self.shell_environment_policy.clone(), windows_sandbox_level: self.windows_sandbox_level, @@ -167,6 +168,21 @@ impl SessionConfiguration { .materialize_project_roots_with_workspace_roots(&workspace_roots) } + fn effective_permission_profile( + &self, + environments: &[TurnEnvironmentSelection], + ) -> PermissionProfile { + ThreadEnvironments::primary_config_for(environments) + .map(|config| { + config + .permission_profile + .permission_profile() + .clone() + .materialize_project_roots_with_path_uris(&config.workspace_roots) + }) + .unwrap_or_else(|| self.materialized_permission_profile(environments)) + } + pub(super) fn active_permission_profile(&self) -> Option { self.permission_profile_state.active_permission_profile() } @@ -227,10 +243,7 @@ impl SessionConfiguration { service_tier: self.step_settings.service_tier.clone(), approval_policy: self.step_settings.approval_policy.value(), approvals_reviewer: self.step_settings.approvals_reviewer, - permission_profile: permission_profile - .permission_profile() - .clone() - .materialize_project_roots_with_workspace_roots(&workspace_roots), + permission_profile: self.effective_permission_profile(&environment_selections), active_permission_profile: permission_profile.active_permission_profile(), environments: TurnEnvironmentSelections::new( self.legacy_fallback_cwd.clone(), @@ -314,13 +327,7 @@ impl SessionConfiguration { &self, environments: &[TurnEnvironmentSelection], ) -> StepSettingsConstraints<'_> { - let permission_profile = ThreadEnvironments::primary_config_for(environments) - .map(|config| config.permission_profile.permission_profile()) - .unwrap_or_else(|| self.permission_profile_state.permission_profile()) - .clone() - .materialize_project_roots_with_workspace_roots( - &ThreadEnvironments::primary_workspace_roots_for(environments), - ); + let permission_profile = self.effective_permission_profile(environments); StepSettingsConstraints { requirements: self .original_config_do_not_use diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 3b37ac4239..b51db867c0 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -9432,6 +9432,7 @@ async fn record_context_updates_use_environment_permission_profile_and_workspace let cwd = environment.cwd().clone(); let workspace_root = current_context.config.cwd.join("selected-workspace"); let mut environment_config = environment.config().clone(); + environment_config.workspace_roots = vec![PathUri::from_abs_path(&workspace_root)]; environment_config.permission_profile = PermissionProfileSnapshot::legacy(PermissionProfile::workspace_write()); current_context.environments.environments[0] = diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index b086669106..617e8c22a1 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -104,7 +104,7 @@ impl TurnEnvironment { } pub(crate) fn workspace_roots(&self) -> &[PathUri] { - &self.selection.workspace_roots + &self.config().workspace_roots } pub(crate) fn permission_profile(&self) -> &PermissionProfile { @@ -160,7 +160,7 @@ impl std::fmt::Debug for TurnEnvironment { .field("environment_id", &self.selection.environment_id) .field("environment", &self.environment) .field("cwd", &self.selection.cwd) - .field("workspace_roots", &self.selection.workspace_roots) + .field("workspace_roots", &self.config().workspace_roots) .field("temporary_directories", &self.temporary_directories) .field("shell", &self.shell) .field("config", self.config()) diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 575475cf6b..6f5cc37e29 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -1062,6 +1062,7 @@ async fn spawn_internal_session_preserves_parent_lineage_without_forking_history reviewer_environment.config = EnvironmentConfigState::Ready(codex_protocol::protocol::EnvironmentConfig { allow_login_shell: true, + workspace_roots: reviewer_environment.workspace_roots.clone(), permission_profile: config.permissions.permission_profile_state().snapshot(), shell_environment_policy: Default::default(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), diff --git a/codex-rs/core/src/tools/handlers/view_image.rs b/codex-rs/core/src/tools/handlers/view_image.rs index 8e7054fb7d..5c2d83ea4a 100644 --- a/codex-rs/core/src/tools/handlers/view_image.rs +++ b/codex-rs/core/src/tools/handlers/view_image.rs @@ -284,12 +284,13 @@ mod tests { use tokio::sync::Mutex; fn replace_primary_environment_cwd(turn: &mut crate::TurnContext, cwd: AbsolutePathBuf) { - let current = turn + let mut current = turn .environments .turn_environments() .next() .cloned() .expect("default local turn environment"); + current.config_mut().workspace_roots.clear(); let mut selection = current.selection; selection.cwd = PathUri::from_abs_path(&cwd); selection.workspace_roots.clear(); 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 3a5e64d1a1..6effb9591d 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs @@ -27,6 +27,7 @@ fn test_turn_environment(environment_id: &str) -> crate::session::turn_context:: workspace_roots: Vec::new(), config: EnvironmentConfigState::Ready(EnvironmentConfig { allow_login_shell: true, + workspace_roots: Vec::new(), windows_sandbox_level: WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: true, use_legacy_landlock: false, diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 89ba4c734e..c1e1be80ab 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -553,6 +553,7 @@ mod tests { workspace_roots: Vec::new(), config: EnvironmentConfigState::Ready(EnvironmentConfig { allow_login_shell: true, + workspace_roots: Vec::new(), windows_sandbox_level: WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: true, use_legacy_landlock: false, diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index b61f7e0f8b..50ab556077 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -637,7 +637,7 @@ async fn internal_guardian_sessions_require_managed_secondary_environments() { else { panic!("secondary environment should be ready"); }; - secondary.selection.workspace_roots = vec![secondary_workspace_root]; + secondary.config_mut().workspace_roots = vec![secondary_workspace_root]; secondary.config_mut().permission_profile = codex_protocol::models::PermissionProfileSnapshot::legacy(secondary_profile); let turn = Arc::new(turn); @@ -1086,6 +1086,7 @@ async fn zsh_fork_unified_exec_keeps_shell_parameter_when_remote_environment_ava config: EnvironmentConfigState::Ready( codex_protocol::protocol::EnvironmentConfig { allow_login_shell: true, + workspace_roots: Vec::new(), windows_sandbox_level: turn.windows_sandbox_level, windows_sandbox_private_desktop: turn .config diff --git a/codex-rs/core/tests/suite/agent_execution.rs b/codex-rs/core/tests/suite/agent_execution.rs index c7710707be..ace2f50a74 100644 --- a/codex-rs/core/tests/suite/agent_execution.rs +++ b/codex-rs/core/tests/suite/agent_execution.rs @@ -310,8 +310,22 @@ async fn v2_residency_reload_preserves_inherited_environment_and_tools( .await?; } } else { + let mut owner_workspace_roots = child_environment.workspace_roots.clone(); + let owner_workspace_root = test.workspace_path_uri("owner-only-root")?; + test.fs() + .create_directory( + &owner_workspace_root, + CreateDirectoryOptions { + recursive: true, + follow_symlinks: true, + }, + /*sandbox*/ None, + ) + .await?; + owner_workspace_roots.push(owner_workspace_root); child_environment.config = EnvironmentConfigState::Ready(EnvironmentConfig { allow_login_shell: test.config.permissions.allow_login_shell, + workspace_roots: owner_workspace_roots, permission_profile: PermissionProfileSnapshot::legacy(child_permissions), shell_environment_policy: Default::default(), windows_sandbox_level: WindowsSandboxLevel::from_config(&test.config), diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index 8603aa7ea8..449a9cb178 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -716,6 +716,7 @@ async fn environment_command_restrictions_override_saved_prefix_approvals() -> R &selection, EnvironmentConfig { allow_login_shell: true, + workspace_roots: selection.workspace_roots.clone(), permission_profile: PermissionProfileSnapshot::legacy(PermissionProfile::Disabled), shell_environment_policy: Default::default(), windows_sandbox_level: WindowsSandboxLevel::from_config(&test.config), @@ -745,6 +746,7 @@ async fn environment_command_restrictions_override_saved_prefix_approvals() -> R &selection, EnvironmentConfig { allow_login_shell: true, + workspace_roots: selection.workspace_roots.clone(), permission_profile: PermissionProfileSnapshot::legacy(PermissionProfile::Disabled), shell_environment_policy: Default::default(), windows_sandbox_level: WindowsSandboxLevel::from_config(&test.config), @@ -853,6 +855,7 @@ async fn environment_command_policy_changes_invalidate_session_approvals() -> Re &selection, EnvironmentConfig { allow_login_shell: true, + workspace_roots: selection.workspace_roots.clone(), permission_profile: PermissionProfileSnapshot::legacy( PermissionProfile::Disabled, ), diff --git a/codex-rs/core/tests/suite/guardian_review.rs b/codex-rs/core/tests/suite/guardian_review.rs index d030669a76..6d06a74ec5 100644 --- a/codex-rs/core/tests/suite/guardian_review.rs +++ b/codex-rs/core/tests/suite/guardian_review.rs @@ -824,6 +824,7 @@ async fn guardian_session_is_reused_for_consecutive_tool_reviews_without_prewarm let mut parent_environments = local_selections(test.config.cwd.clone()); let parent_environment_config = EnvironmentConfig { allow_login_shell: test.config.permissions.allow_login_shell, + workspace_roots: parent_environments.environments[0].workspace_roots.clone(), permission_profile: PermissionProfileSnapshot::legacy( test.config.permissions.permission_profile().clone(), ), diff --git a/codex-rs/core/tests/suite/hooks_executor.rs b/codex-rs/core/tests/suite/hooks_executor.rs index a16a0f7e0e..2691e4866b 100644 --- a/codex-rs/core/tests/suite/hooks_executor.rs +++ b/codex-rs/core/tests/suite/hooks_executor.rs @@ -398,6 +398,7 @@ impl ExecutorStopHookFixture { &selection, EnvironmentConfig { allow_login_shell: false, + workspace_roots: selection.workspace_roots.clone(), permission_profile: PermissionProfileSnapshot::legacy( self.test.config.permissions.permission_profile().clone(), ), diff --git a/codex-rs/core/tests/suite/mcp_startup_refresh_http_proxy.rs b/codex-rs/core/tests/suite/mcp_startup_refresh_http_proxy.rs index 16af085f95..a22bbb7195 100644 --- a/codex-rs/core/tests/suite/mcp_startup_refresh_http_proxy.rs +++ b/codex-rs/core/tests/suite/mcp_startup_refresh_http_proxy.rs @@ -362,6 +362,7 @@ async fn skill_mcp_dependency_oauth_uses_configured_http_client() -> Result<()> let mut environments = local_selections(fixture.config.cwd.clone()); environments.environments[0].config = EnvironmentConfigState::Ready(EnvironmentConfig { allow_login_shell: fixture.config.permissions.allow_login_shell, + workspace_roots: environments.environments[0].workspace_roots.clone(), permission_profile: PermissionProfileSnapshot::legacy(PermissionProfile::Disabled), shell_environment_policy: Default::default(), windows_sandbox_level: WindowsSandboxLevel::from_config(&fixture.config), diff --git a/codex-rs/core/tests/suite/mcp_tool_exposure.rs b/codex-rs/core/tests/suite/mcp_tool_exposure.rs index a6fbecdafd..e76028cce8 100644 --- a/codex-rs/core/tests/suite/mcp_tool_exposure.rs +++ b/codex-rs/core/tests/suite/mcp_tool_exposure.rs @@ -489,6 +489,7 @@ async fn root_reconciliation_reuses_pending_apps_startup() -> Result<()> { &selection, EnvironmentConfig { allow_login_shell: false, + workspace_roots: selection.workspace_roots.clone(), permission_profile: PermissionProfileSnapshot::legacy( test.config.permissions.permission_profile().clone(), ), diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 57abdaac2b..c7fe7d1de2 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -213,6 +213,7 @@ async fn model_visible_environment_context_preserves_foreign_workspace_roots() - text_elements: Vec::new(), }]) .with_thread_settings(ThreadSettingsOverrides { + permission_profile: Some(PermissionProfile::workspace_write()), environments: Some(TurnEnvironmentSelections::new( test.config.cwd.clone(), vec![TurnEnvironmentSelection { @@ -241,6 +242,10 @@ async fn model_visible_environment_context_preserves_foreign_workspace_roots() - environment_context.contains("C:\\workspace"), "foreign workspace root should remain visible to the model: {environment_context}" ); + assert!( + environment_context.contains("C:\\workspace"), + "foreign workspace root should retain its permissions: {environment_context}" + ); Ok(()) } diff --git a/codex-rs/core/tests/suite/network_approval.rs b/codex-rs/core/tests/suite/network_approval.rs index d02c16bf74..8414303c88 100644 --- a/codex-rs/core/tests/suite/network_approval.rs +++ b/codex-rs/core/tests/suite/network_approval.rs @@ -2020,6 +2020,7 @@ async fn owner_network_policy_follows_the_selected_remote_command() -> Result<() proxy_config.set_allowed_domains(vec![allowed_domain.to_string()]); let owner_config = EnvironmentConfig { allow_login_shell: test.config.permissions.allow_login_shell, + workspace_roots: remote.workspace_roots.clone(), permission_profile: PermissionProfileSnapshot::legacy(if restricted { PermissionProfile::workspace_write() } else { @@ -2042,6 +2043,7 @@ async fn owner_network_policy_follows_the_selected_remote_command() -> Result<() }; let mut primary = local(test.cwd.path().abs()); primary.config = EnvironmentConfigState::Ready(EnvironmentConfig { + workspace_roots: primary.workspace_roots.clone(), permission_profile: PermissionProfileSnapshot::legacy(PermissionProfile::Disabled), network_policy: None, ..owner_config.clone() diff --git a/codex-rs/core/tests/suite/remote_env.rs b/codex-rs/core/tests/suite/remote_env.rs index d4f011254a..f22ad130b7 100644 --- a/codex-rs/core/tests/suite/remote_env.rs +++ b/codex-rs/core/tests/suite/remote_env.rs @@ -557,6 +557,7 @@ async fn environment_permissions_follow_configuration_ownership() -> Result<()> vec![TurnEnvironmentSelection { config: EnvironmentConfigState::Ready(EnvironmentConfig { allow_login_shell: test.config.permissions.allow_login_shell, + workspace_roots: selection.workspace_roots.clone(), permission_profile: owner_permission_profile, shell_environment_policy: Default::default(), windows_sandbox_level: WindowsSandboxLevel::from_config(&test.config), @@ -1322,6 +1323,7 @@ async fn shared_executor_keeps_ready_capability_roots_scoped_to_each_attachment( EnvironmentConfigState::Pending, EnvironmentConfigState::Ready(EnvironmentConfig { allow_login_shell: false, + workspace_roots: selection.workspace_roots.clone(), permission_profile: permission_profile.clone(), shell_environment_policy: Default::default(), windows_sandbox_level: WindowsSandboxLevel::from_config(&test.config), @@ -1366,6 +1368,7 @@ async fn shared_executor_keeps_ready_capability_roots_scoped_to_each_attachment( environments: Some(vec![TurnEnvironmentSelection { config: EnvironmentConfigState::Ready(EnvironmentConfig { allow_login_shell: true, + workspace_roots: selection.workspace_roots.clone(), permission_profile: permission_profile.clone(), shell_environment_policy: Default::default(), windows_sandbox_level: WindowsSandboxLevel::from_config(&test.config), @@ -1394,6 +1397,7 @@ async fn shared_executor_keeps_ready_capability_roots_scoped_to_each_attachment( vec![TurnEnvironmentSelection { config: EnvironmentConfigState::Ready(EnvironmentConfig { allow_login_shell: false, + workspace_roots: selection.workspace_roots.clone(), permission_profile: permission_profile.clone(), shell_environment_policy: Default::default(), windows_sandbox_level: WindowsSandboxLevel::from_config(&test.config), @@ -1461,6 +1465,7 @@ async fn shared_executor_keeps_ready_capability_roots_scoped_to_each_attachment( vec![TurnEnvironmentSelection { config: EnvironmentConfigState::Ready(EnvironmentConfig { allow_login_shell: false, + workspace_roots: selection.workspace_roots.clone(), permission_profile: permission_profile.clone(), shell_environment_policy: Default::default(), windows_sandbox_level: WindowsSandboxLevel::from_config(&test.config), @@ -1536,6 +1541,7 @@ async fn owner_network_policy_rejects_unsupported_environment_authority() -> Res .context("thread should select its executor environment")?; let owner_config = EnvironmentConfig { allow_login_shell: test.config.permissions.allow_login_shell, + workspace_roots: selection.workspace_roots.clone(), permission_profile: PermissionProfileSnapshot::legacy(PermissionProfile::Disabled), shell_environment_policy: test.config.permissions.shell_environment_policy.clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&test.config), @@ -1614,6 +1620,7 @@ async fn pending_attachment_installs_configuration_before_waiting_turn_resumes() config: EnvironmentConfigState::Pending, ..selection.clone() }; + let owner_workspace_root = selection.cwd.join("owner-workspace")?; let root = |id: &str| SelectedCapabilityRoot { id: id.to_string(), location: CapabilityRootLocation::Environment { @@ -1623,6 +1630,7 @@ async fn pending_attachment_installs_configuration_before_waiting_turn_resumes() }; let owner_config = |id: &str, allow_login_shell: bool| EnvironmentConfig { allow_login_shell, + workspace_roots: vec![selection.cwd.clone(), owner_workspace_root.clone()], permission_profile: PermissionProfileSnapshot::legacy(PermissionProfile::read_only()), shell_environment_policy: Default::default(), windows_sandbox_level: WindowsSandboxLevel::from_config(&test.config), @@ -1642,6 +1650,7 @@ async fn pending_attachment_installs_configuration_before_waiting_turn_resumes() let waiting = timeout(Duration::from_secs(5), start_pending_thread()) .await .context("pending thread startup should not block")??; + let requested_workspace_roots = waiting.thread.config_snapshot().await.workspace_roots; let independent = start_pending_thread().await?; let failed = start_pending_thread().await?; @@ -1757,22 +1766,19 @@ async fn pending_attachment_installs_configuration_before_waiting_turn_resumes() config: EnvironmentConfigState::Ready(waiting_config.clone()), ..pending_selection.clone() }; - submit_thread_settings( - &waiting.thread, - ThreadSettingsOverrides { - environments: Some(TurnEnvironmentSelections::new( - test.config.cwd.clone(), - vec![ready_selection.clone()], - )), - ..Default::default() - }, - ) - .await?; + waiting + .thread + .environment_ready(&pending_selection, waiting_config) + .await?; wait_for_response_request_count(&response_mock, /*expected_count*/ 2).await; assert_eq!( waiting.thread.environment_selections().await, vec![ready_selection] ); + assert_eq!( + waiting.thread.config_snapshot().await.workspace_roots, + requested_workspace_roots + ); let ready_request = response_mock .last_request() @@ -1800,6 +1806,13 @@ async fn pending_attachment_installs_configuration_before_waiting_turn_resumes() .rfind(|text| text.contains("")), Some("waiting-root".to_string()) ); + assert!( + ready_request + .message_input_texts("user") + .iter() + .any(|text| text.contains(&owner_workspace_root.inferred_native_path_string())), + "waiting turn should observe owner-resolved workspace roots" + ); let recovered_selection = TurnEnvironmentSelection { config: EnvironmentConfigState::Ready(owner_config( @@ -2276,6 +2289,7 @@ async fn deferred_executor_spawn_agent_inherits_ready_step_environments( workspace_roots: vec![PathUri::from_abs_path(&test.config.cwd)], config: EnvironmentConfigState::Ready(EnvironmentConfig { allow_login_shell: test.config.permissions.allow_login_shell, + workspace_roots: vec![PathUri::from_abs_path(&test.config.cwd)], permission_profile: PermissionProfileSnapshot::active_with_profile_workspace_roots( PermissionProfile::read_only(), owner_active_profile.clone(), diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 9e4cd7b02a..aa403d1e23 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -801,6 +801,7 @@ async fn environment_mcp_policy_filters_runtime_config_and_model_tools( &selection, EnvironmentConfig { allow_login_shell: true, + workspace_roots: selection.workspace_roots.clone(), permission_profile: PermissionProfileSnapshot::legacy( fixture.config.permissions.permission_profile().clone(), ), @@ -1743,14 +1744,11 @@ async fn stdio_mcp_tool_call_includes_sandbox_state_meta( .into_iter() .find(|selection| selection.environment_id == remote_aware_environment_id()) .context("thread should select the MCP server's executor environment")?; - let selection = TurnEnvironmentSelection { - workspace_roots: vec![PathUri::parse(if cfg!(windows) { - "file:///foreign/workspace" - } else { - "file:///C:/workspace" - })?], - ..selection - }; + let workspace_roots = vec![PathUri::parse(if cfg!(windows) { + "file:///foreign/workspace" + } else { + "file:///C:/workspace" + })?]; submit_thread_settings( &fixture.codex, ThreadSettingsOverrides { @@ -1765,13 +1763,13 @@ async fn stdio_mcp_tool_call_includes_sandbox_state_meta( }, ) .await?; - let workspace_roots = selection.workspace_roots.clone(); fixture .codex .environment_ready( &selection, EnvironmentConfig { allow_login_shell: fixture.config.permissions.allow_login_shell, + workspace_roots: workspace_roots.clone(), permission_profile: PermissionProfileSnapshot::legacy( owner_permission_profile.clone(), ), diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 8976088ed3..0466fd18aa 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -391,6 +391,7 @@ async fn exec_command_uses_installed_environment_shell_policy_with_explicit_over &selection, EnvironmentConfig { allow_login_shell: true, + workspace_roots: selection.workspace_roots.clone(), permission_profile: PermissionProfileSnapshot::legacy(PermissionProfile::Disabled), shell_environment_policy: ShellEnvironmentPolicy { inherit: ShellEnvironmentPolicyInherit::None, diff --git a/codex-rs/core/tests/suite/workspace_roots.rs b/codex-rs/core/tests/suite/workspace_roots.rs index c3fdae0fb6..a3ed21581b 100644 --- a/codex-rs/core/tests/suite/workspace_roots.rs +++ b/codex-rs/core/tests/suite/workspace_roots.rs @@ -1,8 +1,12 @@ use anyhow::Context; use anyhow::Result; +use codex_core::EnvironmentConfig; +use codex_core::windows_sandbox::WindowsSandboxLevelExt; use codex_exec_server::CreateDirectoryOptions; use codex_exec_server::RemoveOptions; +use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::PermissionProfile; +use codex_protocol::models::PermissionProfileSnapshot; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; @@ -23,6 +27,7 @@ use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; use core_test_support::test_target_os; use serde_json::json; +use test_case::test_case; use wiremock::MockServer; const PATCH_CALL_ID: &str = "workspace-root-patch"; @@ -194,9 +199,14 @@ async fn workspace_roots_allow_file_and_command_writes() -> Result<()> { remove_files(&test, &[&patch_path, &command_path]).await } +#[test_case(false; "thread-owned secondary root")] +#[test_case(true; "owner-resolved secondary root")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn workspace_roots_allow_apply_patch_in_secondary_root() -> Result<()> { +async fn workspace_roots_allow_file_and_command_writes_in_secondary_root( + owner_resolved_roots: bool, +) -> Result<()> { const SECONDARY_ROOT_NAME: &str = "secondary-workspace-root"; + const COMMAND_CONTENTS: &str = "secondary root command"; skip_if_wine_exec!( Ok(()), @@ -205,7 +215,7 @@ async fn workspace_roots_allow_apply_patch_in_secondary_root() -> Result<()> { let server = start_mock_server().await; let mut builder = test_codex() - .with_config(|config| { + .with_config(move |config| { #[cfg(windows)] { config.cwd = dunce::canonicalize(config.cwd.as_path()) @@ -220,7 +230,10 @@ async fn workspace_roots_allow_apply_patch_in_secondary_root() -> Result<()> { &config.cwd, SECONDARY_ROOT_NAME, )); - config.workspace_roots = vec![config.cwd.clone(), secondary_root]; + config.workspace_roots = vec![config.cwd.clone()]; + if !owner_resolved_roots { + config.workspace_roots.push(secondary_root); + } config.set_windows_sandbox_enabled(/*value*/ true); }) .with_workspace_setup(|cwd, fs| async move { @@ -245,31 +258,54 @@ async fn workspace_roots_allow_apply_patch_in_secondary_root() -> Result<()> { .parent() .context("workspace should have a parent")? .join(&secondary_root_name)?; + if owner_resolved_roots { + let selection = test + .codex + .environment_selections() + .await + .into_iter() + .next() + .context("thread should select its executor environment")?; + test.codex + .environment_ready( + &selection, + EnvironmentConfig { + allow_login_shell: test.config.permissions.allow_login_shell, + workspace_roots: vec![selection.cwd.clone(), secondary_root.clone()], + permission_profile: PermissionProfileSnapshot::legacy( + workspace_roots_profile(), + ), + shell_environment_policy: Default::default(), + windows_sandbox_level: WindowsSandboxLevel::from_config(&test.config), + windows_sandbox_private_desktop: test + .config + .permissions + .windows_sandbox_private_desktop, + use_legacy_landlock: test.config.features.use_legacy_landlock(), + exec_policy: None, + mcp_policy: None, + network_policy: None, + selected_capability_roots: Vec::new(), + }, + ) + .await?; + } let patch = format!( "*** Begin Patch\n*** Add File: ../{secondary_root_name}/secondary-root-patch.txt\n+secondary root\n*** End Patch\n" ); - let response_mock = mount_sse_sequence( + let response_mock = mount_patch_and_command_calls( &server, - vec![ - sse(vec![ - ev_response_created("resp-1"), - ev_apply_patch_custom_tool_call(PATCH_CALL_ID, &patch), - ev_completed("resp-1"), - ]), - sse(vec![ - ev_response_created("resp-2"), - ev_assistant_message("msg-1", "done"), - ev_completed("resp-2"), - ]), - ], + &patch, + &format!("../{secondary_root_name}/secondary-root-command.txt"), + COMMAND_CONTENTS, ) - .await; + .await?; submit_workspace_turn(&test, "write in the secondary workspace root").await?; let request = response_mock .last_request() - .context("model should receive the apply_patch result")?; + .context("model should receive the apply_patch and command results")?; let (patch_output, patch_success) = request .custom_tool_call_output_content_and_success(PATCH_CALL_ID) .context("patch result should be present")?; @@ -282,6 +318,15 @@ async fn workspace_roots_allow_apply_patch_in_secondary_root() -> Result<()> { ); let patched_file = secondary_root.join("secondary-root-patch.txt")?; assert_eq!(read_file(&test, &patched_file).await?, "secondary root\n"); + let (_, command_success) = request + .function_call_output_content_and_success(COMMAND_CALL_ID) + .context("command result should be present")?; + assert_ne!(command_success, Some(false)); + let command_file = secondary_root.join("secondary-root-command.txt")?; + assert_eq!( + read_file(&test, &command_file).await?.trim_end(), + COMMAND_CONTENTS + ); test.fs() .remove( diff --git a/codex-rs/ext/guardian-v2/src/sync_reviewer/reviewer_config_tests.rs b/codex-rs/ext/guardian-v2/src/sync_reviewer/reviewer_config_tests.rs index 2aed90d1fc..3ad14810c5 100644 --- a/codex-rs/ext/guardian-v2/src/sync_reviewer/reviewer_config_tests.rs +++ b/codex-rs/ext/guardian-v2/src/sync_reviewer/reviewer_config_tests.rs @@ -153,6 +153,7 @@ async fn read_only_permissions_preserve_parent_environments_and_denied_reads() - .expect("parent should have an execution environment"); environment.config = EnvironmentConfigState::Ready(EnvironmentConfig { allow_login_shell: true, + workspace_roots: environment.workspace_roots.clone(), permission_profile: PermissionProfileSnapshot::legacy(parent_profile.clone()), shell_environment_policy: Default::default(), windows_sandbox_level: WindowsSandboxLevel::from_config(&parent_config), diff --git a/codex-rs/protocol/src/environment.rs b/codex-rs/protocol/src/environment.rs index 17a1ba3e6a..ccf1686140 100644 --- a/codex-rs/protocol/src/environment.rs +++ b/codex-rs/protocol/src/environment.rs @@ -5,6 +5,7 @@ use crate::mcp_policy::EnvironmentMcpPolicy; use crate::models::PermissionProfileSnapshot; use codex_execpolicy::RequirementsExecPolicy; use codex_network_proxy::EnvironmentNetworkPolicy; +use codex_utils_path_uri::PathUri; /// Configuration supplied for a thread's selected environment. #[allow(clippy::large_enum_variant)] @@ -25,6 +26,8 @@ pub enum EnvironmentConfigState { pub struct EnvironmentConfig { /// Whether shell tools may start login shells in this environment. pub allow_login_shell: bool, + /// Effective workspace roots resolved for this environment attachment. + pub workspace_roots: Vec, /// Resolved permissions for this thread's environment attachment. pub permission_profile: PermissionProfileSnapshot, /// Controls which environment variables shell commands may inherit. @@ -50,6 +53,7 @@ impl std::fmt::Debug for EnvironmentConfig { formatter .debug_struct("EnvironmentConfig") .field("allow_login_shell", &self.allow_login_shell) + .field("workspace_roots", &self.workspace_roots) .field("permission_profile", &self.permission_profile) .field("shell_environment_policy", &"") .field("windows_sandbox_level", &self.windows_sandbox_level)