Honor environment-resolved workspace roots (#40912)

## What changed

- Add effective workspace roots to `EnvironmentConfig` and use them when
  materializing permission profiles and filesystem context.
- Preserve selection roots for thread-owned configurations while allowing a
  ready environment attachment to supply its resolved roots.
- Propagate resolved roots to child environments and validate both command and
  `apply_patch` writes in owner-provided secondary workspaces.

GitOrigin-RevId: 1832c168ce1fc37fa67efc2a472998ab4f448c70
This commit is contained in:
sayan-oai
2026-08-26 16:28:35 +00:00
committed by copyberry
parent 5b92c2d2f5
commit 7625bd5665
29 changed files with 191 additions and 67 deletions
+6 -6
View File
@@ -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::<Result<Vec<_>, _>>()
+1
View File
@@ -368,6 +368,7 @@ fn resolved_local_environments<const N: usize>(
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,
@@ -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::<Vec<_>>();
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)
+23 -2
View File
@@ -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,
+1 -1
View File
@@ -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),
];
+1
View File
@@ -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]
+1
View File
@@ -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
+18 -11
View File
@@ -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<ActivePermissionProfile> {
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
+1
View File
@@ -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] =
+2 -2
View File
@@ -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())
@@ -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),
@@ -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();
@@ -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,
@@ -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,
+2 -1
View File
@@ -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
@@ -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),
+3
View File
@@ -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,
),
@@ -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(),
),
@@ -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(),
),
@@ -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),
@@ -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(),
),
@@ -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("<workspace_roots><root>C:\\workspace</root>"),
"foreign workspace root should remain visible to the model: {environment_context}"
);
assert!(
environment_context.contains("<entry access=\"write\"><path>C:\\workspace</path>"),
"foreign workspace root should retain its permissions: {environment_context}"
);
Ok(())
}
@@ -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()
+25 -11
View File
@@ -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("<ready_capability_roots>")),
Some("<ready_capability_roots>waiting-root</ready_capability_roots>".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(),
+7 -9
View File
@@ -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(),
),
@@ -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,
+63 -18
View File
@@ -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(
@@ -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),
+4
View File
@@ -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<PathUri>,
/// 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", &"<redacted>")
.field("windows_sandbox_level", &self.windows_sandbox_level)