From 7bd45bab3de1d512bd728068786e4b0e63e27e88 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 9 Jul 2026 15:00:10 -0700 Subject: [PATCH 01/29] exec-server: preserve empty workspace roots --- codex-rs/exec-server/src/fs_sandbox.rs | 6 +- codex-rs/exec-server/src/process_sandbox.rs | 6 +- codex-rs/exec-server/tests/exec_process.rs | 61 ++++++++++++++++--- .../exec-server/tests/file_system_unix.rs | 29 +++++++++ codex-rs/file-system/src/lib.rs | 3 +- 5 files changed, 86 insertions(+), 19 deletions(-) diff --git a/codex-rs/exec-server/src/fs_sandbox.rs b/codex-rs/exec-server/src/fs_sandbox.rs index 9dfbeba976ff..ab122f415d04 100644 --- a/codex-rs/exec-server/src/fs_sandbox.rs +++ b/codex-rs/exec-server/src/fs_sandbox.rs @@ -72,11 +72,7 @@ impl FileSystemSandboxRunner { .iter() .map(native_workspace_root) .collect::, _>>()?; - let workspace_roots = if native_workspace_roots.is_empty() { - std::slice::from_ref(&cwd.native) - } else { - native_workspace_roots.as_slice() - }; + let workspace_roots = native_workspace_roots.as_slice(); let native_permissions: PermissionProfile = sandbox.permissions.clone().try_into().map_err(|err| { invalid_request(format!("invalid sandbox permission path URI: {err}")) diff --git a/codex-rs/exec-server/src/process_sandbox.rs b/codex-rs/exec-server/src/process_sandbox.rs index 050e151e6e62..8354196d0623 100644 --- a/codex-rs/exec-server/src/process_sandbox.rs +++ b/codex-rs/exec-server/src/process_sandbox.rs @@ -56,11 +56,7 @@ pub(crate) fn prepare_exec_request( .iter() .map(|root| native_path(root, "sandbox workspace root")) .collect::, _>>()?; - let workspace_roots = if native_workspace_roots.is_empty() { - std::slice::from_ref(&native_sandbox_policy_cwd) - } else { - native_workspace_roots.as_slice() - }; + let workspace_roots = native_workspace_roots.as_slice(); let permissions = permissions.materialize_project_roots_with_workspace_roots(workspace_roots); let managed_mitm_ca_trust_bundle_path = params.managed_network.as_ref().and_then(|_| { CUSTOM_CA_ENV_KEYS.iter().find_map(|key| { diff --git a/codex-rs/exec-server/tests/exec_process.rs b/codex-rs/exec-server/tests/exec_process.rs index 5e5188277216..4a283ad3829f 100644 --- a/codex-rs/exec-server/tests/exec_process.rs +++ b/codex-rs/exec-server/tests/exec_process.rs @@ -13,26 +13,26 @@ use codex_exec_server::ExecOutputStream; use codex_exec_server::ExecParams; use codex_exec_server::ExecProcess; use codex_exec_server::ExecProcessEvent; -#[cfg(target_os = "linux")] +#[cfg(unix)] use codex_exec_server::FileSystemSandboxContext; use codex_exec_server::ProcessId; use codex_exec_server::ProcessSignal; use codex_exec_server::ReadResponse; use codex_exec_server::StartedExecProcess; use codex_exec_server::WriteStatus; -#[cfg(target_os = "linux")] +#[cfg(unix)] use codex_protocol::models::PermissionProfile; -#[cfg(target_os = "linux")] +#[cfg(unix)] use codex_protocol::permissions::FileSystemAccessMode; -#[cfg(target_os = "linux")] +#[cfg(unix)] use codex_protocol::permissions::FileSystemPath; -#[cfg(target_os = "linux")] +#[cfg(unix)] use codex_protocol::permissions::FileSystemSandboxEntry; -#[cfg(target_os = "linux")] +#[cfg(unix)] use codex_protocol::permissions::FileSystemSandboxPolicy; -#[cfg(target_os = "linux")] +#[cfg(unix)] use codex_protocol::permissions::FileSystemSpecialPath; -#[cfg(target_os = "linux")] +#[cfg(unix)] use codex_protocol::permissions::NetworkSandboxPolicy; use codex_utils_path_uri::PathUri; use pretty_assertions::assert_eq; @@ -240,6 +240,51 @@ async fn remote_tty_process_uses_configured_sandbox_helper_with_hostile_path() - Ok(()) } +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_process_preserves_empty_workspace_roots() -> Result<()> { + let context = create_process_context(/*use_remote*/ true).await?; + let tmp = TempDir::new()?; + let file = tmp.path().join("excluded.txt"); + std::fs::write(&file, b"excluded")?; + let cwd = PathUri::from_host_native_path(tmp.path())?; + let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(/*subpath*/ None), + }, + access: FileSystemAccessMode::Read, + }]); + let mut sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd( + PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted), + cwd.clone(), + ); + sandbox.workspace_roots.clear(); + + let session = context + .backend + .start(ExecParams { + process_id: ProcessId::from("proc-empty-workspace-roots"), + argv: vec!["/bin/cat".to_string(), file.to_string_lossy().into_owned()], + cwd, + env_policy: None, + env: HashMap::new(), + tty: false, + pipe_stdin: false, + arg0: None, + sandbox: Some(sandbox), + enforce_managed_network: false, + managed_network: None, + }) + .await?; + let (stdout, _stderr, exit_code, closed) = + collect_process_output_from_events(session.process).await?; + + assert!(!stdout.contains("excluded"), "unexpected stdout: {stdout}"); + assert_ne!(exit_code, Some(0)); + assert!(closed); + Ok(()) +} + async fn read_process_until_change( session: Arc, wake_rx: &mut watch::Receiver, diff --git a/codex-rs/exec-server/tests/file_system_unix.rs b/codex-rs/exec-server/tests/file_system_unix.rs index bfcf9df601f8..878749acfa11 100644 --- a/codex-rs/exec-server/tests/file_system_unix.rs +++ b/codex-rs/exec-server/tests/file_system_unix.rs @@ -263,6 +263,35 @@ async fn remote_read_file_materializes_environment_workspace_roots() -> Result<( Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_read_file_preserves_empty_workspace_roots() -> Result<()> { + let context = create_file_system_context(FileSystemImplementation::Remote).await?; + let file_system = context.file_system; + let tmp = TempDir::new()?; + let file = tmp.path().join("excluded.txt"); + std::fs::write(&file, b"excluded")?; + + let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(/*subpath*/ None), + }, + access: FileSystemAccessMode::Read, + }]); + let mut sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd( + PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted), + PathUri::from_host_native_path(tmp.path())?, + ); + sandbox.workspace_roots.clear(); + + let error = file_system + .read_file(&PathUri::from_host_native_path(&file)?, Some(&sandbox)) + .await + .expect_err("empty workspace roots should not grant cwd access"); + assert_sandbox_denied(&error); + + Ok(()) +} + #[test_case(FileSystemImplementation::Local ; "local")] #[test_case(FileSystemImplementation::Remote ; "remote")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/codex-rs/file-system/src/lib.rs b/codex-rs/file-system/src/lib.rs index 2ef3bcf7107a..e402ef3382c2 100644 --- a/codex-rs/file-system/src/lib.rs +++ b/codex-rs/file-system/src/lib.rs @@ -171,10 +171,11 @@ impl FileSystemSandboxContext { permissions: PermissionProfile, cwd: Option, ) -> Self { + let workspace_roots = cwd.iter().cloned().collect(); Self { permissions: permissions.into(), cwd, - workspace_roots: Vec::new(), + workspace_roots, windows_sandbox_level: WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, use_legacy_landlock: false, From 5092b60de8b62792e7de4f5c48a67f2e7ad2e28f Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 9 Jul 2026 15:25:29 -0700 Subject: [PATCH 02/29] core: forward workspace roots to remote commands --- codex-rs/core/src/tools/sandboxing.rs | 6 +- codex-rs/core/src/tools/sandboxing_tests.rs | 2 +- codex-rs/core/tests/suite/mod.rs | 1 + codex-rs/core/tests/suite/workspace_roots.rs | 257 +++++++++++++++++++ 4 files changed, 264 insertions(+), 2 deletions(-) create mode 100644 codex-rs/core/tests/suite/workspace_roots.rs diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 571c5ce6dfc2..a23bd5a00089 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -511,7 +511,11 @@ impl<'a> SandboxAttempt<'a> { exec_request.exec_server_sandbox = Some(FileSystemSandboxContext { permissions: exec_server_permissions.into(), cwd: Some(exec_request.windows_sandbox_policy_cwd.clone()), - workspace_roots: Vec::new(), + workspace_roots: self + .workspace_roots + .iter() + .map(PathUri::from_abs_path) + .collect(), windows_sandbox_level: self.windows_sandbox_level, windows_sandbox_private_desktop: self.windows_sandbox_private_desktop, use_legacy_landlock: self.use_legacy_landlock, diff --git a/codex-rs/core/src/tools/sandboxing_tests.rs b/codex-rs/core/src/tools/sandboxing_tests.rs index e52186f7721a..ee2530a3863d 100644 --- a/codex-rs/core/src/tools/sandboxing_tests.rs +++ b/codex-rs/core/src/tools/sandboxing_tests.rs @@ -264,7 +264,7 @@ fn exec_server_env_keeps_command_native_and_carries_sandbox_context() { Some(codex_exec_server::FileSystemSandboxContext { permissions: exec_server_permissions.clone().into(), cwd: Some(cwd_uri.clone()), - workspace_roots: Vec::new(), + workspace_roots: vec![cwd_uri.clone()], windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, use_legacy_landlock: false, diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index f1fae54c3bdc..a916bf20f417 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -142,3 +142,4 @@ mod websocket_fallback; mod window_headers; #[cfg(target_os = "windows")] mod windows_sandbox; +mod workspace_roots; diff --git a/codex-rs/core/tests/suite/workspace_roots.rs b/codex-rs/core/tests/suite/workspace_roots.rs new file mode 100644 index 000000000000..0238b74077ad --- /dev/null +++ b/codex-rs/core/tests/suite/workspace_roots.rs @@ -0,0 +1,257 @@ +use anyhow::Context; +use anyhow::Result; +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use codex_exec_server::RemoveOptions; +use codex_features::Feature; +use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::FileSystemSpecialPath; +use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::protocol::EventMsg; +use codex_utils_path_uri::PathUri; +use core_test_support::TestTargetOs; +use core_test_support::responses::ResponseMock; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_sequence; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::skip_if_no_remote_env; +use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::test_codex; +use core_test_support::test_target_os; +use core_test_support::wait_for_event; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; +use wiremock::MockServer; + +const IMAGE_CALL_ID: &str = "workspace-root-image"; +const COMMAND_CALL_ID: &str = "workspace-root-command"; +const PNG_BASE64: &str = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; + +fn workspace_roots_read_profile() -> PermissionProfile { + PermissionProfile::from_runtime_permissions( + &FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Minimal, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(/*subpath*/ None), + }, + access: FileSystemAccessMode::Read, + }, + ]), + NetworkSandboxPolicy::Restricted, + ) +} + +async fn workspace_roots_test(server: &MockServer) -> Result { + let mut builder = test_codex().with_config(|config| { + config.use_experimental_unified_exec_tool = true; + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); + config.workspace_roots = vec![config.cwd.clone()]; + }); + builder.build_with_auto_env(server).await +} + +fn outside_workspace_path(test: &TestCodex, file_name: &str) -> Result { + let file_name = format!("codex-workspace-roots-{}-{file_name}", std::process::id()); + PathUri::from_abs_path(&test.config.cwd) + .parent() + .context("test workspace should have a parent")? + .join(&file_name) + .map_err(Into::into) +} + +fn command_arguments(path: &str) -> Result { + let (shell, command) = match test_target_os() { + TestTargetOs::Linux => ("bash", format!("cat '{path}'")), + TestTargetOs::Windows => ("powershell", format!("Get-Content -Raw '{path}'")), + TestTargetOs::MacOs => unreachable!("remote test targets do not run macOS"), + }; + Ok(serde_json::to_string(&json!({ + "cmd": command, + "shell": shell, + "login": false, + "yield_time_ms": 10_000, + }))?) +} + +async fn mount_file_and_command_calls( + server: &MockServer, + image_path: &str, + command_path: &str, +) -> Result { + let command_arguments = command_arguments(command_path)?; + Ok(mount_sse_sequence( + server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + IMAGE_CALL_ID, + "view_image", + &json!({ "path": image_path }).to_string(), + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_function_call(COMMAND_CALL_ID, "exec_command", &command_arguments), + ev_completed("resp-2"), + ]), + sse(vec![ + ev_response_created("resp-3"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-3"), + ]), + ], + ) + .await) +} + +async fn submit_workspace_turn(test: &TestCodex, prompt: &str) -> Result<()> { + test.submit_turn_with_permission_profile(prompt, workspace_roots_read_profile()) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + Ok(()) +} + +async fn remove_files(test: &TestCodex, paths: &[&PathUri]) -> Result<()> { + for path in paths { + test.fs() + .remove( + path, + RemoveOptions { + recursive: false, + force: true, + }, + /*sandbox*/ None, + ) + .await?; + } + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_workspace_roots_allow_file_read_and_command_run() -> Result<()> { + const COMMAND_CONTENTS: &str = "workspace root command access"; + + skip_if_no_remote_env!(Ok(())); + + let server = start_mock_server().await; + let test = workspace_roots_test(&server).await?; + let cwd = PathUri::from_abs_path(&test.config.cwd); + let image_path = cwd.join("workspace-root.png")?; + let text_path = cwd.join("workspace-root.txt")?; + test.fs() + .write_file( + &image_path, + BASE64_STANDARD.decode(PNG_BASE64)?, + /*sandbox*/ None, + ) + .await?; + test.fs() + .write_file( + &text_path, + COMMAND_CONTENTS.as_bytes().to_vec(), + /*sandbox*/ None, + ) + .await?; + + let response_mock = + mount_file_and_command_calls(&server, "workspace-root.png", "workspace-root.txt").await?; + submit_workspace_turn(&test, "read files inside the workspace roots").await?; + + let request = response_mock + .last_request() + .context("model should receive both workspace-root tool results")?; + let image_output = request.function_call_output(IMAGE_CALL_ID); + let image_url = image_output + .get("output") + .and_then(Value::as_array) + .and_then(|items| items.first()) + .and_then(|item| item.get("image_url")) + .and_then(Value::as_str) + .context("remote filesystem read should return an image")?; + assert!(image_url.starts_with("data:image/png;base64,")); + + let (command_output, success) = request + .function_call_output_content_and_success(COMMAND_CALL_ID) + .context("remote command result should be present")?; + assert_ne!(success, Some(false)); + assert!(command_output.is_some_and(|output| output.contains(COMMAND_CONTENTS))); + + remove_files(&test, &[&image_path, &text_path]).await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_workspace_roots_deny_file_and_command_reads_outside_roots() -> Result<()> { + const OUTSIDE_CONTENTS: &str = "outside workspace root"; + + skip_if_no_remote_env!(Ok(())); + + let server = start_mock_server().await; + let test = workspace_roots_test(&server).await?; + let image_path = outside_workspace_path(&test, "outside.png")?; + let text_path = outside_workspace_path(&test, "outside.txt")?; + test.fs() + .write_file( + &image_path, + BASE64_STANDARD.decode(PNG_BASE64)?, + /*sandbox*/ None, + ) + .await?; + test.fs() + .write_file( + &text_path, + OUTSIDE_CONTENTS.as_bytes().to_vec(), + /*sandbox*/ None, + ) + .await?; + let image_path_display = image_path.inferred_native_path_string(); + let text_path_display = text_path.inferred_native_path_string(); + + let response_mock = + mount_file_and_command_calls(&server, &image_path_display, &text_path_display).await?; + submit_workspace_turn(&test, "try to read files outside the workspace roots").await?; + + let request = response_mock + .last_request() + .context("model should receive both denied tool results")?; + let (file_output, file_success) = request + .function_call_output_content_and_success(IMAGE_CALL_ID) + .context("denied remote file-read result should be present")?; + assert_eq!(file_success, Some(false)); + assert!(file_output.is_some_and(|output| { + output.starts_with(&format!( + "unable to locate image at `{image_path_display}`:" + )) || output.starts_with(&format!("unable to read image at `{image_path_display}`:")) + })); + + let (command_output, command_success) = request + .function_call_output_content_and_success(COMMAND_CALL_ID) + .context("denied remote command result should be present")?; + assert_eq!(command_success, Some(false)); + assert!(command_output.is_none_or(|output| !output.contains(OUTSIDE_CONTENTS))); + + remove_files(&test, &[&image_path, &text_path]).await +} From 8c24dcb1deec9e0edad05ce83810d49fa79140ef Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 9 Jul 2026 15:34:56 -0700 Subject: [PATCH 03/29] core: allow time for remote workspace root test --- codex-rs/core/tests/suite/workspace_roots.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/codex-rs/core/tests/suite/workspace_roots.rs b/codex-rs/core/tests/suite/workspace_roots.rs index 0238b74077ad..ee95b5843448 100644 --- a/codex-rs/core/tests/suite/workspace_roots.rs +++ b/codex-rs/core/tests/suite/workspace_roots.rs @@ -26,14 +26,16 @@ use core_test_support::skip_if_no_remote_env; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; use core_test_support::test_target_os; -use core_test_support::wait_for_event; +use core_test_support::wait_for_event_with_timeout; use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; +use tokio::time::Duration; use wiremock::MockServer; const IMAGE_CALL_ID: &str = "workspace-root-image"; const COMMAND_CALL_ID: &str = "workspace-root-command"; +const TURN_COMPLETE_TIMEOUT: Duration = Duration::from_secs(30); const PNG_BASE64: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; @@ -128,9 +130,11 @@ async fn mount_file_and_command_calls( async fn submit_workspace_turn(test: &TestCodex, prompt: &str) -> Result<()> { test.submit_turn_with_permission_profile(prompt, workspace_roots_read_profile()) .await?; - wait_for_event(&test.codex, |event| { - matches!(event, EventMsg::TurnComplete(_)) - }) + wait_for_event_with_timeout( + &test.codex, + |event| matches!(event, EventMsg::TurnComplete(_)), + TURN_COMPLETE_TIMEOUT, + ) .await; Ok(()) } From ec7065b003155ee073ad1fcbc1b638a3939cff80 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 9 Jul 2026 15:35:31 -0700 Subject: [PATCH 04/29] core: restore workspace root test timeout --- codex-rs/core/tests/suite/workspace_roots.rs | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/codex-rs/core/tests/suite/workspace_roots.rs b/codex-rs/core/tests/suite/workspace_roots.rs index ee95b5843448..0238b74077ad 100644 --- a/codex-rs/core/tests/suite/workspace_roots.rs +++ b/codex-rs/core/tests/suite/workspace_roots.rs @@ -26,16 +26,14 @@ use core_test_support::skip_if_no_remote_env; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; use core_test_support::test_target_os; -use core_test_support::wait_for_event_with_timeout; +use core_test_support::wait_for_event; use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; -use tokio::time::Duration; use wiremock::MockServer; const IMAGE_CALL_ID: &str = "workspace-root-image"; const COMMAND_CALL_ID: &str = "workspace-root-command"; -const TURN_COMPLETE_TIMEOUT: Duration = Duration::from_secs(30); const PNG_BASE64: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; @@ -130,11 +128,9 @@ async fn mount_file_and_command_calls( async fn submit_workspace_turn(test: &TestCodex, prompt: &str) -> Result<()> { test.submit_turn_with_permission_profile(prompt, workspace_roots_read_profile()) .await?; - wait_for_event_with_timeout( - &test.codex, - |event| matches!(event, EventMsg::TurnComplete(_)), - TURN_COMPLETE_TIMEOUT, - ) + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) .await; Ok(()) } From 2c98e1fedfc0fc7c9764813271935b433eb2aeda Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 9 Jul 2026 15:36:24 -0700 Subject: [PATCH 05/29] core: avoid waiting twice for workspace test turn --- codex-rs/core/tests/suite/workspace_roots.rs | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/codex-rs/core/tests/suite/workspace_roots.rs b/codex-rs/core/tests/suite/workspace_roots.rs index 0238b74077ad..21116f559c4e 100644 --- a/codex-rs/core/tests/suite/workspace_roots.rs +++ b/codex-rs/core/tests/suite/workspace_roots.rs @@ -11,7 +11,6 @@ use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; -use codex_protocol::protocol::EventMsg; use codex_utils_path_uri::PathUri; use core_test_support::TestTargetOs; use core_test_support::responses::ResponseMock; @@ -26,7 +25,6 @@ use core_test_support::skip_if_no_remote_env; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; use core_test_support::test_target_os; -use core_test_support::wait_for_event; use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; @@ -127,12 +125,7 @@ async fn mount_file_and_command_calls( async fn submit_workspace_turn(test: &TestCodex, prompt: &str) -> Result<()> { test.submit_turn_with_permission_profile(prompt, workspace_roots_read_profile()) - .await?; - wait_for_event(&test.codex, |event| { - matches!(event, EventMsg::TurnComplete(_)) - }) - .await; - Ok(()) + .await } async fn remove_files(test: &TestCodex, paths: &[&PathUri]) -> Result<()> { From 0e31de2d2f45c3e59338a8e530453d549e175dca Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 9 Jul 2026 15:45:31 -0700 Subject: [PATCH 06/29] core: run workspace root tests locally and remotely --- codex-rs/core/tests/suite/workspace_roots.rs | 43 ++++++++++++-------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/codex-rs/core/tests/suite/workspace_roots.rs b/codex-rs/core/tests/suite/workspace_roots.rs index 21116f559c4e..aabe8b052e92 100644 --- a/codex-rs/core/tests/suite/workspace_roots.rs +++ b/codex-rs/core/tests/suite/workspace_roots.rs @@ -21,11 +21,10 @@ use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; -use core_test_support::skip_if_no_remote_env; +use core_test_support::skip_if_wine_exec; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; use core_test_support::test_target_os; -use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; use wiremock::MockServer; @@ -78,9 +77,8 @@ fn outside_workspace_path(test: &TestCodex, file_name: &str) -> Result fn command_arguments(path: &str) -> Result { let (shell, command) = match test_target_os() { - TestTargetOs::Linux => ("bash", format!("cat '{path}'")), + TestTargetOs::Linux | TestTargetOs::MacOs => ("bash", format!("cat '{path}'")), TestTargetOs::Windows => ("powershell", format!("Get-Content -Raw '{path}'")), - TestTargetOs::MacOs => unreachable!("remote test targets do not run macOS"), }; Ok(serde_json::to_string(&json!({ "cmd": command, @@ -145,10 +143,13 @@ async fn remove_files(test: &TestCodex, paths: &[&PathUri]) -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn remote_workspace_roots_allow_file_read_and_command_run() -> Result<()> { +async fn workspace_roots_allow_file_read_and_command_run() -> Result<()> { const COMMAND_CONTENTS: &str = "workspace root command access"; - skip_if_no_remote_env!(Ok(())); + skip_if_wine_exec!( + Ok(()), + "remote Windows sandboxed process launches are not supported" + ); let server = start_mock_server().await; let test = workspace_roots_test(&server).await?; @@ -184,12 +185,12 @@ async fn remote_workspace_roots_allow_file_read_and_command_run() -> Result<()> .and_then(|items| items.first()) .and_then(|item| item.get("image_url")) .and_then(Value::as_str) - .context("remote filesystem read should return an image")?; + .context("filesystem read should return an image")?; assert!(image_url.starts_with("data:image/png;base64,")); let (command_output, success) = request .function_call_output_content_and_success(COMMAND_CALL_ID) - .context("remote command result should be present")?; + .context("command result should be present")?; assert_ne!(success, Some(false)); assert!(command_output.is_some_and(|output| output.contains(COMMAND_CONTENTS))); @@ -197,10 +198,13 @@ async fn remote_workspace_roots_allow_file_read_and_command_run() -> Result<()> } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn remote_workspace_roots_deny_file_and_command_reads_outside_roots() -> Result<()> { +async fn workspace_roots_deny_file_and_command_reads_outside_roots() -> Result<()> { const OUTSIDE_CONTENTS: &str = "outside workspace root"; - skip_if_no_remote_env!(Ok(())); + skip_if_wine_exec!( + Ok(()), + "remote Windows sandboxed process launches are not supported" + ); let server = start_mock_server().await; let test = workspace_roots_test(&server).await?; @@ -230,21 +234,26 @@ async fn remote_workspace_roots_deny_file_and_command_reads_outside_roots() -> R let request = response_mock .last_request() .context("model should receive both denied tool results")?; - let (file_output, file_success) = request + let (file_output, _) = request .function_call_output_content_and_success(IMAGE_CALL_ID) - .context("denied remote file-read result should be present")?; - assert_eq!(file_success, Some(false)); + .context("denied file-read result should be present")?; assert!(file_output.is_some_and(|output| { output.starts_with(&format!( "unable to locate image at `{image_path_display}`:" )) || output.starts_with(&format!("unable to read image at `{image_path_display}`:")) })); - let (command_output, command_success) = request + let (command_output, _) = request .function_call_output_content_and_success(COMMAND_CALL_ID) - .context("denied remote command result should be present")?; - assert_eq!(command_success, Some(false)); - assert!(command_output.is_none_or(|output| !output.contains(OUTSIDE_CONTENTS))); + .context("denied command result should be present")?; + let command_output = command_output.context("denied command output should be present")?; + assert!(command_output.contains(&text_path_display)); + assert!( + command_output.contains("Permission denied") + || command_output.contains("Operation not permitted") + || command_output.contains("No such file or directory") + ); + assert!(!command_output.contains(OUTSIDE_CONTENTS)); remove_files(&test, &[&image_path, &text_path]).await } From aa7457939e31451071302b9bd533f56e81d22112 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 9 Jul 2026 15:55:18 -0700 Subject: [PATCH 07/29] core: expose local sandbox helper to workspace tests --- codex-rs/core/tests/suite/workspace_roots.rs | 47 +++++++++++++------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/codex-rs/core/tests/suite/workspace_roots.rs b/codex-rs/core/tests/suite/workspace_roots.rs index aabe8b052e92..6c81551f4851 100644 --- a/codex-rs/core/tests/suite/workspace_roots.rs +++ b/codex-rs/core/tests/suite/workspace_roots.rs @@ -34,24 +34,39 @@ const COMMAND_CALL_ID: &str = "workspace-root-command"; const PNG_BASE64: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; -fn workspace_roots_read_profile() -> PermissionProfile { - PermissionProfile::from_runtime_permissions( - &FileSystemSandboxPolicy::restricted(vec![ - FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Minimal, - }, - access: FileSystemAccessMode::Read, +fn workspace_roots_read_profile() -> Result { + let mut entries = vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Minimal, }, - FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::project_roots(/*subpath*/ None), - }, - access: FileSystemAccessMode::Read, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(/*subpath*/ None), + }, + access: FileSystemAccessMode::Read, + }, + ]; + #[cfg(target_os = "linux")] + if !core_test_support::is_remote_test_environment() { + // Bubblewrap re-execs the test binary after applying the filesystem policy. + // Bazel places that binary outside the platform paths covered by `:minimal`. + entries.push(FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path( + std::env::current_exe()?, + )?, }, - ]), + access: FileSystemAccessMode::Read, + }); + } + + Ok(PermissionProfile::from_runtime_permissions( + &FileSystemSandboxPolicy::restricted(entries), NetworkSandboxPolicy::Restricted, - ) + )) } async fn workspace_roots_test(server: &MockServer) -> Result { @@ -122,7 +137,7 @@ async fn mount_file_and_command_calls( } async fn submit_workspace_turn(test: &TestCodex, prompt: &str) -> Result<()> { - test.submit_turn_with_permission_profile(prompt, workspace_roots_read_profile()) + test.submit_turn_with_permission_profile(prompt, workspace_roots_read_profile()?) .await } From 9f75bf7a17566d8a26b70469c8ead69a2a192b10 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 9 Jul 2026 16:00:37 -0700 Subject: [PATCH 08/29] core: improve workspace root test diagnostics --- codex-rs/core/tests/suite/workspace_roots.rs | 40 ++++++++++++-------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/codex-rs/core/tests/suite/workspace_roots.rs b/codex-rs/core/tests/suite/workspace_roots.rs index 6c81551f4851..1e48d99b0a8a 100644 --- a/codex-rs/core/tests/suite/workspace_roots.rs +++ b/codex-rs/core/tests/suite/workspace_roots.rs @@ -35,7 +35,7 @@ const PNG_BASE64: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; fn workspace_roots_read_profile() -> Result { - let mut entries = vec![ + let entries = vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::Minimal, @@ -50,18 +50,22 @@ fn workspace_roots_read_profile() -> Result { }, ]; #[cfg(target_os = "linux")] - if !core_test_support::is_remote_test_environment() { - // Bubblewrap re-execs the test binary after applying the filesystem policy. - // Bazel places that binary outside the platform paths covered by `:minimal`. - entries.push(FileSystemSandboxEntry { - path: FileSystemPath::Path { - path: codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path( - std::env::current_exe()?, - )?, - }, - access: FileSystemAccessMode::Read, - }); - } + let entries = { + let mut entries = entries; + if !core_test_support::is_remote_test_environment() { + // Bubblewrap re-execs the test binary after applying the filesystem policy. + // Bazel places that binary outside the platform paths covered by `:minimal`. + entries.push(FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path( + std::env::current_exe()?, + )?, + }, + access: FileSystemAccessMode::Read, + }); + } + entries + }; Ok(PermissionProfile::from_runtime_permissions( &FileSystemSandboxPolicy::restricted(entries), @@ -207,7 +211,10 @@ async fn workspace_roots_allow_file_read_and_command_run() -> Result<()> { .function_call_output_content_and_success(COMMAND_CALL_ID) .context("command result should be present")?; assert_ne!(success, Some(false)); - assert!(command_output.is_some_and(|output| output.contains(COMMAND_CONTENTS))); + assert!( + command_output.is_some_and(|output| output.contains(COMMAND_CONTENTS)), + "command should read the workspace-root file, got {command_output:?}" + ); remove_files(&test, &[&image_path, &text_path]).await } @@ -262,7 +269,10 @@ async fn workspace_roots_deny_file_and_command_reads_outside_roots() -> Result<( .function_call_output_content_and_success(COMMAND_CALL_ID) .context("denied command result should be present")?; let command_output = command_output.context("denied command output should be present")?; - assert!(command_output.contains(&text_path_display)); + assert!( + command_output.contains(&text_path_display), + "denied command output should identify {text_path_display}, got {command_output:?}" + ); assert!( command_output.contains("Permission denied") || command_output.contains("Operation not permitted") From 500e176de4c561c97d053e2377fe55a9b7e7ca4e Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 9 Jul 2026 16:03:59 -0700 Subject: [PATCH 09/29] core: retain workspace command diagnostics --- codex-rs/core/tests/suite/workspace_roots.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/tests/suite/workspace_roots.rs b/codex-rs/core/tests/suite/workspace_roots.rs index 1e48d99b0a8a..eae2ec492927 100644 --- a/codex-rs/core/tests/suite/workspace_roots.rs +++ b/codex-rs/core/tests/suite/workspace_roots.rs @@ -212,7 +212,9 @@ async fn workspace_roots_allow_file_read_and_command_run() -> Result<()> { .context("command result should be present")?; assert_ne!(success, Some(false)); assert!( - command_output.is_some_and(|output| output.contains(COMMAND_CONTENTS)), + command_output + .as_deref() + .is_some_and(|output| output.contains(COMMAND_CONTENTS)), "command should read the workspace-root file, got {command_output:?}" ); From 27f21a788827d5991e48b8516534a891b95c1bc3 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 9 Jul 2026 16:37:06 -0700 Subject: [PATCH 10/29] core: rely on sandbox helper visibility --- codex-rs/core/tests/suite/workspace_roots.rs | 49 ++++++-------------- 1 file changed, 15 insertions(+), 34 deletions(-) diff --git a/codex-rs/core/tests/suite/workspace_roots.rs b/codex-rs/core/tests/suite/workspace_roots.rs index eae2ec492927..15df4c32a51b 100644 --- a/codex-rs/core/tests/suite/workspace_roots.rs +++ b/codex-rs/core/tests/suite/workspace_roots.rs @@ -34,43 +34,24 @@ const COMMAND_CALL_ID: &str = "workspace-root-command"; const PNG_BASE64: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; -fn workspace_roots_read_profile() -> Result { - let entries = vec![ - FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Minimal, - }, - access: FileSystemAccessMode::Read, - }, - FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::project_roots(/*subpath*/ None), +fn workspace_roots_read_profile() -> PermissionProfile { + PermissionProfile::from_runtime_permissions( + &FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Minimal, + }, + access: FileSystemAccessMode::Read, }, - access: FileSystemAccessMode::Read, - }, - ]; - #[cfg(target_os = "linux")] - let entries = { - let mut entries = entries; - if !core_test_support::is_remote_test_environment() { - // Bubblewrap re-execs the test binary after applying the filesystem policy. - // Bazel places that binary outside the platform paths covered by `:minimal`. - entries.push(FileSystemSandboxEntry { - path: FileSystemPath::Path { - path: codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path( - std::env::current_exe()?, - )?, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(/*subpath*/ None), }, access: FileSystemAccessMode::Read, - }); - } - entries - }; - - Ok(PermissionProfile::from_runtime_permissions( - &FileSystemSandboxPolicy::restricted(entries), + }, + ]), NetworkSandboxPolicy::Restricted, - )) + ) } async fn workspace_roots_test(server: &MockServer) -> Result { @@ -141,7 +122,7 @@ async fn mount_file_and_command_calls( } async fn submit_workspace_turn(test: &TestCodex, prompt: &str) -> Result<()> { - test.submit_turn_with_permission_profile(prompt, workspace_roots_read_profile()?) + test.submit_turn_with_permission_profile(prompt, workspace_roots_read_profile()) .await } From dd51887ce7745750551688dc540a633faca2ede8 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 9 Jul 2026 17:18:53 -0700 Subject: [PATCH 11/29] core: test workspace roots with write permissions --- codex-rs/core/tests/suite/workspace_roots.rs | 213 ++++++++----------- 1 file changed, 94 insertions(+), 119 deletions(-) diff --git a/codex-rs/core/tests/suite/workspace_roots.rs b/codex-rs/core/tests/suite/workspace_roots.rs index 15df4c32a51b..bd031127b788 100644 --- a/codex-rs/core/tests/suite/workspace_roots.rs +++ b/codex-rs/core/tests/suite/workspace_roots.rs @@ -1,19 +1,13 @@ use anyhow::Context; use anyhow::Result; -use base64::Engine; -use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_exec_server::RemoveOptions; use codex_features::Feature; use codex_protocol::models::PermissionProfile; -use codex_protocol::permissions::FileSystemAccessMode; -use codex_protocol::permissions::FileSystemPath; -use codex_protocol::permissions::FileSystemSandboxEntry; -use codex_protocol::permissions::FileSystemSandboxPolicy; -use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_utils_path_uri::PathUri; use core_test_support::TestTargetOs; use core_test_support::responses::ResponseMock; +use core_test_support::responses::ev_apply_patch_custom_tool_call; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call; @@ -21,36 +15,22 @@ use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; -use core_test_support::skip_if_wine_exec; +use core_test_support::skip_if_target_windows; 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::Value; use serde_json::json; use wiremock::MockServer; -const IMAGE_CALL_ID: &str = "workspace-root-image"; +const PATCH_CALL_ID: &str = "workspace-root-patch"; const COMMAND_CALL_ID: &str = "workspace-root-command"; -const PNG_BASE64: &str = - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; -fn workspace_roots_read_profile() -> PermissionProfile { - PermissionProfile::from_runtime_permissions( - &FileSystemSandboxPolicy::restricted(vec![ - FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Minimal, - }, - access: FileSystemAccessMode::Read, - }, - FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::project_roots(/*subpath*/ None), - }, - access: FileSystemAccessMode::Read, - }, - ]), +fn workspace_roots_profile() -> PermissionProfile { + PermissionProfile::workspace_write_with( + &[], NetworkSandboxPolicy::Restricted, + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ true, ) } @@ -75,35 +55,36 @@ fn outside_workspace_path(test: &TestCodex, file_name: &str) -> Result .map_err(Into::into) } -fn command_arguments(path: &str) -> Result { +fn command_arguments(path: &str, contents: &str) -> Result { let (shell, command) = match test_target_os() { - TestTargetOs::Linux | TestTargetOs::MacOs => ("bash", format!("cat '{path}'")), - TestTargetOs::Windows => ("powershell", format!("Get-Content -Raw '{path}'")), + TestTargetOs::Linux | TestTargetOs::MacOs => { + ("bash", format!("printf %s '{contents}' > '{path}'")) + } + TestTargetOs::Windows => ( + "powershell", + format!("Set-Content -NoNewline -Path '{path}' -Value '{contents}'"), + ), }; Ok(serde_json::to_string(&json!({ "cmd": command, "shell": shell, "login": false, - "yield_time_ms": 10_000, }))?) } -async fn mount_file_and_command_calls( +async fn mount_patch_and_command_calls( server: &MockServer, - image_path: &str, + patch: &str, command_path: &str, + command_contents: &str, ) -> Result { - let command_arguments = command_arguments(command_path)?; + let command_arguments = command_arguments(command_path, command_contents)?; Ok(mount_sse_sequence( server, vec![ sse(vec![ ev_response_created("resp-1"), - ev_function_call( - IMAGE_CALL_ID, - "view_image", - &json!({ "path": image_path }).to_string(), - ), + ev_apply_patch_custom_tool_call(PATCH_CALL_ID, patch), ev_completed("resp-1"), ]), sse(vec![ @@ -122,10 +103,16 @@ async fn mount_file_and_command_calls( } async fn submit_workspace_turn(test: &TestCodex, prompt: &str) -> Result<()> { - test.submit_turn_with_permission_profile(prompt, workspace_roots_read_profile()) + test.submit_turn_with_permission_profile(prompt, workspace_roots_profile()) .await } +async fn read_file(test: &TestCodex, path: &PathUri) -> Result { + Ok(String::from_utf8( + test.fs().read_file(path, /*sandbox*/ None).await?, + )?) +} + async fn remove_files(test: &TestCodex, paths: &[&PathUri]) -> Result<()> { for path in paths { test.fs() @@ -143,125 +130,113 @@ async fn remove_files(test: &TestCodex, paths: &[&PathUri]) -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn workspace_roots_allow_file_read_and_command_run() -> Result<()> { +async fn workspace_roots_allow_file_and_command_writes() -> Result<()> { + const PATCH_CONTENTS: &str = "workspace root patch access"; const COMMAND_CONTENTS: &str = "workspace root command access"; - skip_if_wine_exec!( + skip_if_target_windows!( Ok(()), - "remote Windows sandboxed process launches are not supported" + "sandboxed process launch is not supported by the exec-server Windows backend" ); let server = start_mock_server().await; let test = workspace_roots_test(&server).await?; let cwd = PathUri::from_abs_path(&test.config.cwd); - let image_path = cwd.join("workspace-root.png")?; - let text_path = cwd.join("workspace-root.txt")?; - test.fs() - .write_file( - &image_path, - BASE64_STANDARD.decode(PNG_BASE64)?, - /*sandbox*/ None, - ) - .await?; - test.fs() - .write_file( - &text_path, - COMMAND_CONTENTS.as_bytes().to_vec(), - /*sandbox*/ None, - ) - .await?; + let patch_path = cwd.join("workspace-root-patch.txt")?; + let command_path = cwd.join("workspace-root-command.txt")?; + let patch = format!( + "*** Begin Patch\n*** Add File: workspace-root-patch.txt\n+{PATCH_CONTENTS}\n*** End Patch\n" + ); - let response_mock = - mount_file_and_command_calls(&server, "workspace-root.png", "workspace-root.txt").await?; - submit_workspace_turn(&test, "read files inside the workspace roots").await?; + let response_mock = mount_patch_and_command_calls( + &server, + &patch, + "workspace-root-command.txt", + COMMAND_CONTENTS, + ) + .await?; + submit_workspace_turn(&test, "write files inside the workspace roots").await?; let request = response_mock .last_request() .context("model should receive both workspace-root tool results")?; - let image_output = request.function_call_output(IMAGE_CALL_ID); - let image_url = image_output - .get("output") - .and_then(Value::as_array) - .and_then(|items| items.first()) - .and_then(|item| item.get("image_url")) - .and_then(Value::as_str) - .context("filesystem read should return an image")?; - assert!(image_url.starts_with("data:image/png;base64,")); + let (_, patch_success) = request + .custom_tool_call_output_content_and_success(PATCH_CALL_ID) + .context("patch result should be present")?; + assert_ne!(patch_success, Some(false)); - let (command_output, success) = request + let (_, command_success) = request .function_call_output_content_and_success(COMMAND_CALL_ID) .context("command result should be present")?; - assert_ne!(success, Some(false)); - assert!( - command_output - .as_deref() - .is_some_and(|output| output.contains(COMMAND_CONTENTS)), - "command should read the workspace-root file, got {command_output:?}" + assert_ne!(command_success, Some(false)); + assert_eq!( + read_file(&test, &patch_path).await?, + format!("{PATCH_CONTENTS}\n") ); + assert_eq!(read_file(&test, &command_path).await?, COMMAND_CONTENTS); - remove_files(&test, &[&image_path, &text_path]).await + remove_files(&test, &[&patch_path, &command_path]).await } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn workspace_roots_deny_file_and_command_reads_outside_roots() -> Result<()> { - const OUTSIDE_CONTENTS: &str = "outside workspace root"; +async fn workspace_roots_deny_file_and_command_writes_outside_roots() -> Result<()> { + const PATCH_CONTENTS: &str = "outside workspace root patch"; + const COMMAND_CONTENTS: &str = "outside workspace root command"; - skip_if_wine_exec!( + skip_if_target_windows!( Ok(()), - "remote Windows sandboxed process launches are not supported" + "sandboxed process launch is not supported by the exec-server Windows backend" ); let server = start_mock_server().await; let test = workspace_roots_test(&server).await?; - let image_path = outside_workspace_path(&test, "outside.png")?; - let text_path = outside_workspace_path(&test, "outside.txt")?; - test.fs() - .write_file( - &image_path, - BASE64_STANDARD.decode(PNG_BASE64)?, - /*sandbox*/ None, - ) - .await?; - test.fs() - .write_file( - &text_path, - OUTSIDE_CONTENTS.as_bytes().to_vec(), - /*sandbox*/ None, - ) - .await?; - let image_path_display = image_path.inferred_native_path_string(); - let text_path_display = text_path.inferred_native_path_string(); + let patch_path = outside_workspace_path(&test, "outside-patch.txt")?; + let command_path = outside_workspace_path(&test, "outside-command.txt")?; + let patch_path_display = patch_path.inferred_native_path_string(); + let command_path_display = command_path.inferred_native_path_string(); + let patch = format!( + "*** Begin Patch\n*** Add File: {patch_path_display}\n+{PATCH_CONTENTS}\n*** End Patch\n" + ); let response_mock = - mount_file_and_command_calls(&server, &image_path_display, &text_path_display).await?; - submit_workspace_turn(&test, "try to read files outside the workspace roots").await?; + mount_patch_and_command_calls(&server, &patch, &command_path_display, COMMAND_CONTENTS) + .await?; + submit_workspace_turn(&test, "try to write files outside the workspace roots").await?; let request = response_mock .last_request() .context("model should receive both denied tool results")?; - let (file_output, _) = request - .function_call_output_content_and_success(IMAGE_CALL_ID) - .context("denied file-read result should be present")?; - assert!(file_output.is_some_and(|output| { - output.starts_with(&format!( - "unable to locate image at `{image_path_display}`:" - )) || output.starts_with(&format!("unable to read image at `{image_path_display}`:")) - })); + let (patch_output, patch_success) = request + .custom_tool_call_output_content_and_success(PATCH_CALL_ID) + .context("denied patch result should be present")?; + assert_ne!(patch_success, Some(true)); + assert!( + patch_output + .as_deref() + .is_some_and(|output| output.contains("outside of the project")), + "patch should be denied outside the workspace roots, got {patch_output:?}" + ); let (command_output, _) = request .function_call_output_content_and_success(COMMAND_CALL_ID) .context("denied command result should be present")?; let command_output = command_output.context("denied command output should be present")?; assert!( - command_output.contains(&text_path_display), - "denied command output should identify {text_path_display}, got {command_output:?}" + command_output.contains(&command_path_display), + "denied command output should identify {command_path_display}, got {command_output:?}" ); assert!( - command_output.contains("Permission denied") - || command_output.contains("Operation not permitted") - || command_output.contains("No such file or directory") + test.fs() + .read_file(&patch_path, /*sandbox*/ None) + .await + .is_err() + ); + assert!( + test.fs() + .read_file(&command_path, /*sandbox*/ None) + .await + .is_err() ); - assert!(!command_output.contains(OUTSIDE_CONTENTS)); - remove_files(&test, &[&image_path, &text_path]).await + Ok(()) } From 014742db1f0a687a98ae4fc8f57264bd57186116 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Wed, 8 Jul 2026 14:48:44 -0700 Subject: [PATCH 12/29] core: move workspace roots onto environments --- .../schema/json/ClientRequest.json | 10 ++ .../codex_app_server_protocol.schemas.json | 10 ++ .../codex_app_server_protocol.v2.schemas.json | 10 ++ .../schema/json/v2/ThreadStartParams.json | 10 ++ .../schema/json/v2/TurnStartParams.json | 10 ++ .../typescript/v2/TurnEnvironmentParams.ts | 7 +- .../src/protocol/v2/tests.rs | 8 +- .../src/protocol/v2/turn.rs | 4 + codex-rs/app-server/README.md | 4 +- codex-rs/app-server/src/request_processors.rs | 27 ++- .../request_processors/thread_processor.rs | 15 +- .../src/request_processors/turn_processor.rs | 75 ++++---- .../tests/common/test_app_server.rs | 1 + .../app-server/tests/suite/v2/mcp_tool.rs | 1 + .../suite/v2/selected_capability_stack.rs | 3 + .../app-server/tests/suite/v2/thread_start.rs | 2 + .../app-server/tests/suite/v2/turn_start.rs | 30 +++- codex-rs/core/src/codex_thread.rs | 3 - codex-rs/core/src/environment_selection.rs | 31 +++- codex-rs/core/src/session/handlers.rs | 2 - codex-rs/core/src/session/mod.rs | 1 - codex-rs/core/src/session/session.rs | 42 ++--- codex-rs/core/src/session/tests.rs | 166 ++---------------- codex-rs/core/src/session/turn_context.rs | 69 +++++--- codex-rs/core/src/thread_manager.rs | 31 +++- codex-rs/core/src/thread_manager_tests.rs | 1 + .../core/src/tools/handlers/apply_patch.rs | 9 +- .../src/tools/handlers/extension_tools.rs | 2 +- .../core/src/tools/handlers/view_image.rs | 6 +- codex-rs/core/src/tools/orchestrator.rs | 28 ++- .../core/src/tools/runtimes/apply_patch.rs | 10 +- .../src/tools/runtimes/apply_patch_tests.rs | 4 +- codex-rs/core/src/tools/runtimes/mod_tests.rs | 2 +- codex-rs/core/src/tools/runtimes/shell.rs | 4 + .../core/src/tools/runtimes/unified_exec.rs | 4 + codex-rs/core/src/tools/sandboxing.rs | 28 +-- codex-rs/core/src/tools/sandboxing_tests.rs | 2 +- codex-rs/core/tests/common/test_codex.rs | 5 +- .../remote_env_windows_test.rs | 1 + codex-rs/core/tests/suite/agents_md.rs | 2 + codex-rs/core/tests/suite/apply_patch_cli.rs | 1 + codex-rs/core/tests/suite/network_approval.rs | 1 + codex-rs/core/tests/suite/remote_env.rs | 6 + codex-rs/core/tests/suite/view_image.rs | 1 + codex-rs/ext/skills/tests/skills_extension.rs | 2 + codex-rs/memories/write/src/runtime.rs | 2 +- codex-rs/protocol/src/protocol.rs | 5 +- 47 files changed, 390 insertions(+), 308 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 5da27a074cac..06b260bfb45d 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -4496,6 +4496,16 @@ }, "environmentId": { "type": "string" + }, + "runtimeWorkspaceRoots": { + "description": "Environment-native runtime workspace roots. Omitted inherits the request's top-level `runtimeWorkspaceRoots` fallback.", + "items": { + "$ref": "#/definitions/LegacyAppPathString" + }, + "type": [ + "array", + "null" + ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index a0f893b7f818..83ad817f178c 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -20170,6 +20170,16 @@ }, "environmentId": { "type": "string" + }, + "runtimeWorkspaceRoots": { + "description": "Environment-native runtime workspace roots. Omitted inherits the request's top-level `runtimeWorkspaceRoots` fallback.", + "items": { + "$ref": "#/definitions/v2/LegacyAppPathString" + }, + "type": [ + "array", + "null" + ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 0fe34feff3cb..00dda383cba2 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -17949,6 +17949,16 @@ }, "environmentId": { "type": "string" + }, + "runtimeWorkspaceRoots": { + "description": "Environment-native runtime workspace roots. Omitted inherits the request's top-level `runtimeWorkspaceRoots` fallback.", + "items": { + "$ref": "#/definitions/LegacyAppPathString" + }, + "type": [ + "array", + "null" + ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json index 3819f1f7611e..f1ad30099e9b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json @@ -281,6 +281,16 @@ }, "environmentId": { "type": "string" + }, + "runtimeWorkspaceRoots": { + "description": "Environment-native runtime workspace roots. Omitted inherits the request's top-level `runtimeWorkspaceRoots` fallback.", + "items": { + "$ref": "#/definitions/LegacyAppPathString" + }, + "type": [ + "array", + "null" + ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index cc4869e1d18c..6b55e4c8bbb4 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -362,6 +362,16 @@ }, "environmentId": { "type": "string" + }, + "runtimeWorkspaceRoots": { + "description": "Environment-native runtime workspace roots. Omitted inherits the request's top-level `runtimeWorkspaceRoots` fallback.", + "items": { + "$ref": "#/definitions/LegacyAppPathString" + }, + "type": [ + "array", + "null" + ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnEnvironmentParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnEnvironmentParams.ts index cb93ba396411..2d7131a33116 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnEnvironmentParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnEnvironmentParams.ts @@ -3,4 +3,9 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { LegacyAppPathString } from "../LegacyAppPathString"; -export type TurnEnvironmentParams = { environmentId: string, cwd: LegacyAppPathString, }; +export type TurnEnvironmentParams = { environmentId: string, cwd: LegacyAppPathString, +/** + * Environment-native runtime workspace roots. Omitted inherits the + * request's top-level `runtimeWorkspaceRoots` fallback. + */ +runtimeWorkspaceRoots?: Array | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index d35d39fe4626..a57eb1b2279b 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -4217,13 +4217,15 @@ fn turn_start_params_round_trip_environments() { let raw_cwd = r"C:\workspace"; let cwd: LegacyAppPathString = serde_json::from_value(json!(raw_cwd)).expect("API path should deserialize"); + let workspace_root = cwd.clone(); let params: TurnStartParams = serde_json::from_value(json!({ "threadId": "thread_123", "input": [], "environments": [ { "environmentId": "local", - "cwd": cwd + "cwd": cwd, + "runtimeWorkspaceRoots": [workspace_root] } ], })) @@ -4234,6 +4236,7 @@ fn turn_start_params_round_trip_environments() { Some(vec![TurnEnvironmentParams { environment_id: "local".to_string(), cwd: cwd.clone(), + runtime_workspace_roots: Some(vec![workspace_root.clone()]), }]) ); assert_eq!( @@ -4247,7 +4250,8 @@ fn turn_start_params_round_trip_environments() { Some(&json!([ { "environmentId": "local", - "cwd": cwd + "cwd": cwd, + "runtimeWorkspaceRoots": [workspace_root] } ])) ); diff --git a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs index af99b4b2e725..5c0e354e4315 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs @@ -41,6 +41,10 @@ pub enum TurnStatus { pub struct TurnEnvironmentParams { pub environment_id: String, pub cwd: LegacyAppPathString, + /// Environment-native runtime workspace roots. Omitted inherits the + /// request's top-level `runtimeWorkspaceRoots` fallback. + #[ts(optional = nullable)] + pub runtime_workspace_roots: Option>, } #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 6280cf520409..037291bf0d49 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -137,7 +137,7 @@ Example with notification opt-out: ## API Overview -- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. Experimental `allowProviderModelFallback` lets providers backed by an authoritative static model catalog replace an unavailable requested `model` with the catalog default; dynamic or cached catalogs preserve the requested model. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. For permissions, prefer experimental `permissions` profile selection by id; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Deprecated experimental `multiAgentMode` is ignored; use Ultra reasoning effort for proactive multi-agent behavior. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`. Experimental `selectedCapabilityRoots` selects environment-owned plugin or standalone-skill roots using environment-native absolute paths. Skills found below those roots are listed and read through the owning environment. Stdio MCP servers declared by selected plugins are started in that environment, and HTTP MCP connections use that environment's HTTP client. +- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. Experimental `allowProviderModelFallback` lets providers backed by an authoritative static model catalog replace an unavailable requested `model` with the catalog default; dynamic or cached catalogs preserve the requested model. Experimental `runtimeWorkspaceRoots` supplies the default runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. For permissions, prefer experimental `permissions` profile selection by id; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Deprecated experimental `multiAgentMode` is ignored; use Ultra reasoning effort for proactive multi-agent behavior. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd` and optional environment-native `runtimeWorkspaceRoots`. Omitted per-environment roots inherit the top-level fallback; an empty list explicitly selects no roots for that environment. Experimental `selectedCapabilityRoots` selects environment-owned plugin or standalone-skill roots using environment-native absolute paths. Skills found below those roots are listed and read through the owning environment. Stdio MCP servers declared by selected plugins are started in that environment, and HTTP MCP connections use that environment's HTTP client. - `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`. - `thread/fork` — fork an existing thread into a new thread id by copying the stored history; pass an optional `lastTurnId` to copy history only through that turn, inclusive, and drop later turns from the fork. An in-progress `lastTurnId` is rejected. If `lastTurnId` is null while the source thread is mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Experimental clients can pass `excludeTurns: true` when they plan to page fork history via `thread/turns/list` instead of receiving the full turn array immediately. Accepts the same permission override rules as `thread/start`. - `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. `instructionSources` lists loaded instruction files using each source environment's native absolute path syntax, including files loaded from remote environments. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. Their deprecated experimental `multiAgentMode` field, and the corresponding thread setting, always report `explicitRequestOnly`; Ultra reasoning effort is the source of proactive multi-agent behavior. @@ -168,7 +168,7 @@ Example with notification opt-out: - `thread/backgroundTerminals/list` — list running background terminals for a loaded thread (experimental; requires `capabilities.experimentalApi`); returns `data` with the running terminal ids. - `thread/backgroundTerminals/terminate` — terminate one running background terminal by app-server `processId` (experimental; requires `capabilities.experimentalApi`); returns whether a process was terminated. - `thread/rollback` — deprecated and will be removed soon. Drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success. -- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. Prefer experimental `permissions` profile selection by id for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". Deprecated experimental `multiAgentMode` is ignored; Ultra reasoning effort selects proactive behavior. +- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Experimental `runtimeWorkspaceRoots` supplies the default roots for newly resolved environment selections. Explicit `environments[].runtimeWorkspaceRoots` override that fallback with environment-native absolute paths. Prefer experimental `permissions` profile selection by id for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". Deprecated experimental `multiAgentMode` is ignored; Ultra reasoning effort selects proactive behavior. - `thread/inject_items` — append raw Responses API items to a loaded thread’s model-visible history without starting a user turn; returns `{}` on success. - `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Review and manual compaction turns reject `turn/steer`. - `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`. diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index da88d0e06716..6b30cc055bf0 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -368,7 +368,6 @@ use codex_core_plugins::remote::RemotePluginShareContext as RemoteCatalogPluginS use codex_core_plugins::remote::RemotePluginShareSummary as RemoteCatalogPluginShareSummary; use codex_core_plugins::remote::RemotePluginSummary as RemoteCatalogPluginSummary; use codex_exec_server::EnvironmentManager; -use codex_exec_server::LOCAL_ENVIRONMENT_ID; use codex_exec_server::LOCAL_FS; use codex_features::FEATURES; use codex_features::Feature; @@ -465,6 +464,7 @@ use codex_thread_store::ThreadSortKey as StoreThreadSortKey; use codex_thread_store::ThreadStore; use codex_thread_store::ThreadStoreError; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP; use std::collections::BTreeMap; use std::collections::HashMap; @@ -564,6 +564,7 @@ fn resolve_request_cwd(cwd: Option) -> Result, fn resolve_turn_environment_selections( thread_manager: &ThreadManager, environments: Option>, + fallback_workspace_roots: &[AbsolutePathBuf], ) -> Result>, JSONRPCErrorError> { let Some(environments) = environments else { return Ok(None); @@ -580,9 +581,33 @@ fn resolve_turn_environment_selections( environment.cwd )) })?; + let workspace_roots = environment + .runtime_workspace_roots + .map(|roots| { + let mut resolved_roots = Vec::new(); + for root in roots { + let root = root.to_inferred_path_uri().ok_or_else(|| { + invalid_request(format!( + "invalid runtime workspace root for environment `{environment_id}`: path `{root}` does not use absolute POSIX or Windows path syntax" + )) + })?; + if !resolved_roots.contains(&root) { + resolved_roots.push(root); + } + } + Ok::<_, JSONRPCErrorError>(resolved_roots) + }) + .transpose()? + .unwrap_or_else(|| { + fallback_workspace_roots + .iter() + .map(PathUri::from_abs_path) + .collect() + }); selections.push(TurnEnvironmentSelection { environment_id, cwd, + workspace_roots, }); } thread_manager diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index fc6a3281b4fd..cdaf64007516 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -968,8 +968,6 @@ impl ThreadRequestProcessor { "`permissions` cannot be combined with `sandbox`", )); } - let environment_selections = - resolve_turn_environment_selections(self.thread_manager.as_ref(), environments)?; let runtime_workspace_roots = runtime_workspace_roots.map(resolve_runtime_workspace_roots); let mut typesafe_overrides = self.build_thread_config_overrides( model, @@ -1017,7 +1015,7 @@ impl ThreadRequestProcessor { history_mode.map(Into::into), session_start_source, thread_source.map(Into::into), - environment_selections, + environments, service_name, allow_provider_model_fallback, experimental_raw_events, @@ -1094,7 +1092,7 @@ impl ThreadRequestProcessor { history_mode: Option, session_start_source: Option, thread_source: Option, - environments: Option>, + environments: Option>, service_name: Option, allow_provider_model_fallback: bool, experimental_raw_events: bool, @@ -1188,10 +1186,15 @@ impl ThreadRequestProcessor { } } - let environments = environments.unwrap_or_else(|| { + let environments = resolve_turn_environment_selections( + listener_task_context.thread_manager.as_ref(), + environments, + &config.workspace_roots, + )? + .unwrap_or_else(|| { listener_task_context .thread_manager - .default_environment_selections(&config.cwd) + .default_environment_selections(&config.cwd, &config.workspace_roots) }); let dynamic_tools = dynamic_tools.unwrap_or_default(); if !dynamic_tools.is_empty() { diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index 3ff64cd34e3c..547d628728b1 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -481,8 +481,18 @@ impl TurnRequestProcessor { )) })?; - let environment_selections = - resolve_turn_environment_selections(self.thread_manager.as_ref(), params.environments)?; + let runtime_workspace_roots = params + .runtime_workspace_roots + .map(resolve_runtime_workspace_roots); + let fallback_workspace_roots = match runtime_workspace_roots.as_ref() { + Some(workspace_roots) => workspace_roots.clone(), + None => thread.config_snapshot().await.workspace_roots, + }; + let environment_selections = resolve_turn_environment_selections( + self.thread_manager.as_ref(), + params.environments, + &fallback_workspace_roots, + )?; // Map v2 input items to core input items. let mapped_items: Vec = params @@ -495,7 +505,12 @@ impl TurnRequestProcessor { let turn_has_input = !mapped_items.is_empty(); let cwd = resolve_request_cwd(params.cwd)?; let environments = self - .build_environment_override(thread.as_ref(), cwd, environment_selections) + .build_environment_override( + thread.as_ref(), + cwd, + runtime_workspace_roots.clone(), + environment_selections, + ) .await; let thread_settings = self .build_thread_settings_overrides( @@ -503,7 +518,7 @@ impl TurnRequestProcessor { ThreadSettingsBuildParams { method: "turn/start", environments, - runtime_workspace_roots: params.runtime_workspace_roots, + runtime_workspace_roots, approval_policy: params.approval_policy, approvals_reviewer: params.approvals_reviewer, sandbox_policy: params.sandbox_policy, @@ -572,33 +587,25 @@ impl TurnRequestProcessor { &self, thread: &CodexThread, cwd: Option, + workspace_roots: Option>, environment_selections: Option>, ) -> Option { - match (cwd, environment_selections) { - (None, None) => None, - (Some(cwd), None) => { - let environment_selections = - self.thread_manager.default_environment_selections(&cwd); - Some(TurnEnvironmentSelections::new(cwd, environment_selections)) - } - (cwd, Some(environment_selections)) => { - let legacy_fallback_cwd = match cwd { - Some(cwd) => cwd, - None => { - let snapshot = thread.config_snapshot().await; - environment_selections - .iter() - .find(|selection| selection.environment_id == LOCAL_ENVIRONMENT_ID) - .and_then(|selection| selection.cwd.to_abs_path().ok()) - .unwrap_or_else(|| snapshot.cwd().clone()) - } - }; - Some(TurnEnvironmentSelections::new( - legacy_fallback_cwd, - environment_selections, - )) - } + if cwd.is_none() && workspace_roots.is_none() && environment_selections.is_none() { + return None; } + let snapshot = thread.config_snapshot().await; + let legacy_fallback_cwd = cwd.unwrap_or_else(|| snapshot.cwd().clone()); + let legacy_fallback_workspace_roots = workspace_roots.unwrap_or(snapshot.workspace_roots); + let environment_selections = environment_selections.unwrap_or_else(|| { + self.thread_manager.default_environment_selections( + &legacy_fallback_cwd, + &legacy_fallback_workspace_roots, + ) + }); + Some(TurnEnvironmentSelections::new( + legacy_fallback_cwd, + environment_selections, + )) } async fn build_thread_settings_overrides( @@ -654,8 +661,7 @@ impl TurnRequestProcessor { || collaboration_mode.is_some() || personality.is_some(); - let runtime_workspace_roots = - runtime_workspace_roots_request.map(resolve_runtime_workspace_roots); + let runtime_workspace_roots = runtime_workspace_roots_request; let approval_policy = approval_policy.map(codex_app_server_protocol::AskForApproval::to_core); let approvals_reviewer = @@ -715,7 +721,6 @@ impl TurnRequestProcessor { thread .preview_thread_settings_overrides(CodexThreadSettingsOverrides { environments: environments.clone(), - workspace_roots: runtime_workspace_roots.clone(), approval_policy, approvals_reviewer, sandbox_policy: sandbox_policy.clone(), @@ -738,7 +743,6 @@ impl TurnRequestProcessor { Ok(codex_protocol::protocol::ThreadSettingsOverrides { environments, - workspace_roots: runtime_workspace_roots, profile_workspace_roots, approval_policy, approvals_reviewer, @@ -763,7 +767,12 @@ impl TurnRequestProcessor { let (_, thread) = self.load_thread(¶ms.thread_id).await?; let cwd = resolve_request_cwd(params.cwd)?; let environments = self - .build_environment_override(thread.as_ref(), cwd, /*environment_selections*/ None) + .build_environment_override( + thread.as_ref(), + cwd, + /*workspace_roots*/ None, + /*environment_selections*/ None, + ) .await; let thread_settings = self .build_thread_settings_overrides( diff --git a/codex-rs/app-server/tests/common/test_app_server.rs b/codex-rs/app-server/tests/common/test_app_server.rs index 825ea46ab8a5..9352b05a1852 100644 --- a/codex-rs/app-server/tests/common/test_app_server.rs +++ b/codex-rs/app-server/tests/common/test_app_server.rs @@ -192,6 +192,7 @@ impl TestAppServer { Ok(TurnEnvironmentParams { environment_id: selection.environment_id.clone(), cwd: selection.cwd.clone().into(), + runtime_workspace_roots: None, }) } diff --git a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs index ff8d0fcacd4b..453b4c0abf48 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs @@ -381,6 +381,7 @@ url = "{mcp_server_url}/mcp" capability_root.path().to_path_buf(), )? .into(), + runtime_workspace_roots: None, }]), selected_capability_roots: Some(vec![SelectedCapabilityRoot { id: "late-plugin@1".to_string(), diff --git a/codex-rs/app-server/tests/suite/v2/selected_capability_stack.rs b/codex-rs/app-server/tests/suite/v2/selected_capability_stack.rs index 9e8983840b73..20ec96481db1 100644 --- a/codex-rs/app-server/tests/suite/v2/selected_capability_stack.rs +++ b/codex-rs/app-server/tests/suite/v2/selected_capability_stack.rs @@ -366,6 +366,7 @@ async fn selected_capabilities_become_available_between_samples_in_one_turn() -> environments: Some(vec![TurnEnvironmentParams { environment_id: LOCAL_ENVIRONMENT_ID.to_string(), cwd: fixture.environment_cwd.into(), + runtime_workspace_roots: None, }]), collaboration_mode: Some(CollaborationMode { mode: ModeKind::Plan, @@ -644,6 +645,7 @@ async fn start_thread( environments: Some(vec![TurnEnvironmentParams { environment_id: LOCAL_ENVIRONMENT_ID.to_string(), cwd: environment_cwd.into(), + runtime_workspace_roots: None, }]), selected_capability_roots: Some(vec![selected_root]), ..Default::default() @@ -674,6 +676,7 @@ async fn run_turn( environments: Some(vec![TurnEnvironmentParams { environment_id: LOCAL_ENVIRONMENT_ID.to_string(), cwd: environment_cwd.into(), + runtime_workspace_roots: None, }]), ..Default::default() }) diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 363b1c338985..259223cae6c5 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -619,6 +619,7 @@ async fn thread_start_rejects_unknown_environment_as_invalid_request() -> Result codex_home.path().to_path_buf(), )? .into(), + runtime_workspace_roots: None, }]), ..Default::default() }) @@ -655,6 +656,7 @@ async fn thread_start_rejects_relative_environment_cwd_as_invalid_request() -> R environments: Some(vec![TurnEnvironmentParams { environment_id: environment_id.clone(), cwd: serde_json::from_value(json!("relative"))?, + runtime_workspace_roots: None, }]), ..Default::default() }) diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index c748c413c23b..637224d5f922 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1378,6 +1378,7 @@ async fn turn_start_rejects_unknown_environment_before_starting_turn() -> Result codex_home.path().to_path_buf(), )? .into(), + runtime_workspace_roots: None, }]), ..Default::default() }) @@ -2711,8 +2712,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { #[cfg(unix)] #[tokio::test] -async fn turn_start_permission_profile_rebinds_runtime_workspace_roots_between_turns() -> Result<()> -{ +async fn turn_environment_workspace_roots_override_and_inherit_turn_fallback() -> Result<()> { skip_if_no_network!(Ok(())); let tmp = TempDir::new()?; @@ -2720,12 +2720,19 @@ async fn turn_start_permission_profile_rebinds_runtime_workspace_roots_between_t std::fs::create_dir(&codex_home)?; let old_root = tmp.path().join("old-root"); let new_root = tmp.path().join("new-root"); + let fallback_root = tmp.path().join("fallback-root"); std::fs::create_dir(&old_root)?; std::fs::create_dir(&new_root)?; + std::fs::create_dir(&fallback_root)?; let old_root_text = old_root.to_string_lossy().into_owned(); let new_root_text = new_root.to_string_lossy().into_owned(); + let fallback_root_text = fallback_root.to_string_lossy().into_owned(); let old_root = codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(old_root)?; let new_root = codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(new_root)?; + let fallback_root = + codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(fallback_root)?; + let environment_cwd = + codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(tmp.path())?; let server = responses::start_mock_server().await; let response_mock = responses::mount_sse_sequence( @@ -2794,7 +2801,12 @@ stream_max_retries = 0 text: "select dev profile".to_string(), text_elements: Vec::new(), }], - runtime_workspace_roots: Some(vec![old_root]), + runtime_workspace_roots: Some(vec![fallback_root]), + environments: Some(vec![TurnEnvironmentParams { + environment_id: "local".to_string(), + cwd: environment_cwd.clone().into(), + runtime_workspace_roots: Some(vec![old_root.into()]), + }]), permissions: Some("dev".to_string()), ..Default::default() }) @@ -2819,6 +2831,11 @@ stream_max_retries = 0 text_elements: Vec::new(), }], runtime_workspace_roots: Some(vec![new_root]), + environments: Some(vec![TurnEnvironmentParams { + environment_id: "local".to_string(), + cwd: environment_cwd.into(), + runtime_workspace_roots: None, + }]), ..Default::default() }) .await?; @@ -2848,15 +2865,15 @@ stream_max_retries = 0 let first_permissions = latest_permissions_instructions(&requests[0]); assert!(first_permissions.contains(&old_root_text)); assert!( - !first_permissions.contains(&new_root_text), - "first turn should materialize the initial runtime workspace root" + !first_permissions.contains(&fallback_root_text), + "environment roots should override the turn-level fallback" ); let second_permissions = latest_permissions_instructions(&requests[1]); assert!(second_permissions.contains(&new_root_text)); assert!( !second_permissions.contains(&old_root_text), - "second turn should rebind :workspace_roots to the updated runtime workspace root" + "omitted environment roots should inherit the updated turn-level fallback" ); Ok(()) @@ -3010,6 +3027,7 @@ fn environment_params(ids: Option<&[&str]>, cwd: &Path) -> Option, - pub workspace_roots: Option>, pub profile_workspace_roots: Option>, pub approval_policy: Option, pub approvals_reviewer: Option, @@ -369,7 +368,6 @@ impl CodexThread { ) -> SessionSettingsUpdate { let CodexThreadSettingsOverrides { environments, - workspace_roots, profile_workspace_roots, approval_policy, approvals_reviewer, @@ -396,7 +394,6 @@ impl CodexThread { SessionSettingsUpdate { environments, - workspace_roots, profile_workspace_roots, approval_policy, approvals_reviewer, diff --git a/codex-rs/core/src/environment_selection.rs b/codex-rs/core/src/environment_selection.rs index ef6a3ec5ea93..bed131a5cfb7 100644 --- a/codex-rs/core/src/environment_selection.rs +++ b/codex-rs/core/src/environment_selection.rs @@ -22,6 +22,7 @@ use crate::shell_snapshot::ShellSnapshot; pub(crate) fn default_thread_environment_selections( environment_manager: &EnvironmentManager, cwd: &AbsolutePathBuf, + workspace_roots: &[AbsolutePathBuf], ) -> Vec { environment_manager .default_environment_ids() @@ -29,6 +30,7 @@ pub(crate) fn default_thread_environment_selections( .map(|environment_id| TurnEnvironmentSelection { environment_id, cwd: PathUri::from_abs_path(cwd), + workspace_roots: workspace_roots.iter().map(PathUri::from_abs_path).collect(), }) .collect() } @@ -176,7 +178,8 @@ impl ThreadEnvironments { Some(local_shell) }; let mut turn_environment = - TurnEnvironment::new(selection.environment_id, environment, selection.cwd, shell); + TurnEnvironment::new(selection.environment_id, environment, selection.cwd, shell) + .with_workspace_roots(selection.workspace_roots); let task = shell_snapshot .build(turn_environment.clone()) .boxed() @@ -406,10 +409,11 @@ mod tests { .await; assert_eq!( - default_thread_environment_selections(&manager, &cwd), + default_thread_environment_selections(&manager, &cwd, std::slice::from_ref(&cwd)), vec![TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), - cwd: cwd_uri, + cwd: cwd_uri.clone(), + workspace_roots: vec![cwd_uri], }] ); } @@ -434,15 +438,17 @@ url = "ws://127.0.0.1:8765" .expect("environment manager"); assert_eq!( - default_thread_environment_selections(&manager, &cwd), + default_thread_environment_selections(&manager, &cwd, std::slice::from_ref(&cwd)), vec![ TurnEnvironmentSelection { environment_id: LOCAL_ENVIRONMENT_ID.to_string(), cwd: cwd_uri.clone(), + workspace_roots: vec![cwd_uri.clone()], }, TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), - cwd: cwd_uri, + cwd: cwd_uri.clone(), + workspace_roots: vec![cwd_uri], }, ] ); @@ -454,7 +460,7 @@ url = "ws://127.0.0.1:8765" let manager = EnvironmentManager::without_environments(); assert_eq!( - default_thread_environment_selections(&manager, &cwd), + default_thread_environment_selections(&manager, &cwd, std::slice::from_ref(&cwd)), Vec::::new() ); } @@ -476,6 +482,7 @@ url = "ws://127.0.0.1:8765" turn_environments.update_selections(&[TurnEnvironmentSelection { environment_id: LOCAL_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_abs_path(&cwd), + workspace_roots: Vec::new(), }]); let snapshot = turn_environments.snapshot().await; @@ -496,6 +503,7 @@ url = "ws://127.0.0.1:8765" let first = TurnEnvironmentSelection { environment_id: LOCAL_ENVIRONMENT_ID.to_string(), cwd: cwd_uri.clone(), + workspace_roots: Vec::new(), }; let resolved = resolve_turn_environments( @@ -505,6 +513,7 @@ url = "ws://127.0.0.1:8765" TurnEnvironmentSelection { environment_id: LOCAL_ENVIRONMENT_ID.to_string(), cwd: cwd_uri.join("other").expect("other cwd URI"), + workspace_roots: Vec::new(), }, ], ) @@ -525,6 +534,7 @@ url = "ws://127.0.0.1:8765" &[TurnEnvironmentSelection { environment_id: "local".to_string(), cwd: selected_cwd_uri, + workspace_roots: Vec::new(), }], ) .await; @@ -562,6 +572,7 @@ url = "ws://127.0.0.1:8765" let local = TurnEnvironmentSelection { environment_id: LOCAL_ENVIRONMENT_ID.to_string(), cwd: cwd_uri.clone(), + workspace_roots: Vec::new(), }; let resolved = resolve_turn_environments( @@ -570,6 +581,7 @@ url = "ws://127.0.0.1:8765" TurnEnvironmentSelection { environment_id: "missing".to_string(), cwd: cwd_uri, + workspace_roots: Vec::new(), }, local.clone(), ], @@ -597,6 +609,7 @@ url = "ws://127.0.0.1:8765" let selection = TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_abs_path(&AbsolutePathBuf::current_dir().expect("cwd")), + workspace_roots: Vec::new(), }; let environments = Arc::new(ThreadEnvironments::new( manager, @@ -644,10 +657,12 @@ url = "ws://127.0.0.1:8765" let remote = TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: cwd.clone(), + workspace_roots: Vec::new(), }; let local = TurnEnvironmentSelection { environment_id: LOCAL_ENVIRONMENT_ID.to_string(), cwd, + workspace_roots: Vec::new(), }; let turn_environments = ThreadEnvironments::new( manager, @@ -706,6 +721,7 @@ url = "ws://127.0.0.1:8765" let selection = TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_abs_path(&AbsolutePathBuf::current_dir().expect("cwd")), + workspace_roots: Vec::new(), }; let environments = ThreadEnvironments::new( Arc::clone(&manager), @@ -757,6 +773,7 @@ url = "ws://127.0.0.1:8765" let selection = TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_abs_path(&cwd), + workspace_roots: Vec::new(), }; let environments = ThreadEnvironments::new( Arc::clone(&manager), @@ -813,6 +830,7 @@ url = "ws://127.0.0.1:8765" let selection = TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_abs_path(&cwd), + workspace_roots: Vec::new(), }; let inherited_environment = Arc::new( Environment::create_for_tests(Some("ws://127.0.0.1:8765".to_string())) @@ -865,6 +883,7 @@ url = "ws://127.0.0.1:8765" &[TurnEnvironmentSelection { environment_id: LOCAL_ENVIRONMENT_ID.to_string(), cwd: cwd_uri.clone(), + workspace_roots: Vec::new(), }], ) .await; diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index cc21755524e3..552c03803df9 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -122,7 +122,6 @@ async fn thread_settings_update( ) -> SessionSettingsUpdate { let ThreadSettingsOverrides { environments, - workspace_roots, profile_workspace_roots, approval_policy, approvals_reviewer, @@ -151,7 +150,6 @@ async fn thread_settings_update( }; SessionSettingsUpdate { environments, - workspace_roots, profile_workspace_roots, approval_policy, approvals_reviewer, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 9339413b3337..d2d681d22ee5 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -658,7 +658,6 @@ impl Codex { config.cwd.clone(), environment_selections, ), - workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, original_config_do_not_use: Arc::clone(&config), diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 7c59ec22b7b2..0734f3090a2a 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -80,9 +80,6 @@ pub(crate) struct SessionConfiguration { /// Sticky thread-level environment selections plus the legacy cwd used /// when a turn does not select an environment. pub(super) environments: TurnEnvironmentSelections, - /// Thread-scoped runtime workspace roots for materializing symbolic - /// workspace permissions at session runtime. - pub(super) workspace_roots: Vec, /// Directory containing all Codex state for this session. pub(super) codex_home: AbsolutePathBuf, /// Optional user-facing name for the thread, updated during the session. @@ -119,6 +116,21 @@ impl SessionConfiguration { &self.environments.environments } + pub(super) fn workspace_roots(&self) -> Vec { + self.environments + .environments + .first() + .and_then(|environment| { + environment + .workspace_roots + .iter() + .map(PathUri::to_abs_path) + .collect::>>() + .ok() + }) + .unwrap_or_else(|| vec![self.cwd().clone()]) + } + pub(crate) fn codex_home(&self) -> &AbsolutePathBuf { &self.codex_home } @@ -131,7 +143,7 @@ impl SessionConfiguration { self.permission_profile_state .permission_profile() .clone() - .materialize_project_roots_with_workspace_roots(&self.workspace_roots) + .materialize_project_roots_with_workspace_roots(&self.workspace_roots()) } pub(super) fn active_permission_profile(&self) -> Option { @@ -186,7 +198,7 @@ impl SessionConfiguration { permission_profile: self.permission_profile(), active_permission_profile: self.active_permission_profile(), environments: self.environments.clone(), - workspace_roots: self.workspace_roots.clone(), + workspace_roots: self.workspace_roots(), profile_workspace_roots: self.profile_workspace_roots().to_vec(), ephemeral: self.original_config_do_not_use.ephemeral, reasoning_effort: self.collaboration_mode.reasoning_effort(), @@ -265,25 +277,8 @@ impl SessionConfiguration { .environments .clone() .unwrap_or_else(|| self.environments.clone()); - let cwd_changed = next_environments.legacy_fallback_cwd.as_path() != current_cwd.as_path(); + let cwd_changed = next_environments.legacy_fallback_cwd != current_cwd; next_configuration.environments = next_environments; - if let Some(workspace_roots) = updates.workspace_roots.clone() { - next_configuration.workspace_roots = workspace_roots; - } else if cwd_changed && self.workspace_roots.contains(¤t_cwd) { - let mut retargeted_workspace_roots = - Vec::with_capacity(next_configuration.workspace_roots.len()); - for root in &self.workspace_roots { - let root = if root == ¤t_cwd { - next_configuration.cwd().clone() - } else { - root.clone() - }; - if !retargeted_workspace_roots.contains(&root) { - retargeted_workspace_roots.push(root); - } - } - next_configuration.workspace_roots = retargeted_workspace_roots; - } if let Some(permission_profile) = updates.permission_profile.clone() { let active_permission_profile = @@ -416,7 +411,6 @@ impl SessionConfiguration { #[derive(Default, Clone)] pub(crate) struct SessionSettingsUpdate { pub(crate) environments: Option, - pub(crate) workspace_roots: Option>, pub(crate) profile_workspace_roots: Option>, pub(crate) approval_policy: Option, pub(crate) approvals_reviewer: Option, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 696a8adbd839..7cbea472fbfd 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -49,9 +49,7 @@ use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::TrustLevel; use codex_protocol::exec_output::ExecToolCallOutput; -use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::AgentMessageInputContent; -use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::FunctionCallOutputContentItem; @@ -181,6 +179,7 @@ use tokio::sync::Semaphore; use tokio::time::sleep; use tokio::time::timeout; use tracing_opentelemetry::OpenTelemetrySpanExt; + use uuid::Uuid; use codex_protocol::mcp::CallToolResult as McpCallToolResult; @@ -2919,52 +2918,6 @@ async fn session_configured_reports_permission_profile_for_external_sandbox() -> Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn session_permission_profile_rebinds_runtime_workspace_roots() -> anyhow::Result<()> { - let codex_home = tempfile::TempDir::new()?; - let cwd = tempfile::TempDir::new()?; - let old_root = test_path_buf("/workspace/old").abs(); - let new_root = test_path_buf("/workspace/new").abs(); - let config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .harness_overrides(crate::config::ConfigOverrides { - cwd: Some(cwd.path().to_path_buf()), - default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()), - additional_writable_roots: vec![old_root.to_path_buf()], - ..Default::default() - }) - .build() - .await?; - - let session_permission_profile_state = session_permission_profile_state_from_config(&config)?; - let stored_file_system_policy = session_permission_profile_state - .permission_profile() - .file_system_sandbox_policy(); - assert!( - !stored_file_system_policy - .can_write_path_with_cwd(old_root.as_path(), config.cwd.as_path()), - "session permission profile state should keep runtime workspace roots symbolic" - ); - - let mut session_configuration = make_session_configuration_for_tests().await; - session_configuration.environments = - TurnEnvironmentSelections::new(config.cwd.clone(), Vec::new()); - session_configuration.workspace_roots = config.workspace_roots.clone(); - session_configuration.permission_profile_state = session_permission_profile_state; - - let initial_policy = session_configuration.file_system_sandbox_policy(); - assert!(initial_policy.can_write_path_with_cwd(old_root.as_path(), config.cwd.as_path())); - - let updated = session_configuration.apply(&SessionSettingsUpdate { - workspace_roots: Some(vec![new_root.clone()]), - ..Default::default() - })?; - let updated_policy = updated.file_system_sandbox_policy(); - assert!(updated_policy.can_write_path_with_cwd(new_root.as_path(), updated.cwd().as_path())); - assert!(!updated_policy.can_write_path_with_cwd(old_root.as_path(), updated.cwd().as_path())); - Ok(()) -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result<()> { let server = start_mock_server().await; @@ -3746,7 +3699,6 @@ async fn set_rate_limits_retains_previous_credits() { permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), environments: TurnEnvironmentSelections::new(config.cwd.clone(), Vec::new()), - workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, original_config_do_not_use: Arc::clone(&config), @@ -3853,7 +3805,6 @@ async fn set_rate_limits_updates_plan_type_when_present() { permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), environments: TurnEnvironmentSelections::new(config.cwd.clone(), Vec::new()), - workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, original_config_do_not_use: Arc::clone(&config), @@ -4390,7 +4341,6 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), environments: TurnEnvironmentSelections::new(config.cwd.clone(), Vec::new()), - workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, original_config_do_not_use: Arc::clone(&config), @@ -4548,7 +4498,7 @@ async fn session_configuration_apply_preserves_profile_file_system_policy_on_cwd ) .expect("set permission profile"); let expected_file_system_sandbox_policy = file_system_sandbox_policy - .materialize_project_roots_with_workspace_roots(&session_configuration.workspace_roots); + .materialize_project_roots_with_workspace_roots(&session_configuration.workspace_roots()); let updated = session_configuration .apply(&SessionSettingsUpdate { @@ -4610,7 +4560,7 @@ async fn session_configuration_apply_permission_profile_preserves_existing_deny_ .expect("permission profile update should succeed"); let mut expected_file_system_policy = requested_file_system_policy - .materialize_project_roots_with_workspace_roots(&session_configuration.workspace_roots); + .materialize_project_roots_with_workspace_roots(&session_configuration.workspace_roots()); expected_file_system_policy.glob_scan_max_depth = Some(2); expected_file_system_policy.entries.push(deny_entry); assert_eq!( @@ -4666,94 +4616,6 @@ async fn session_configuration_apply_permission_profile_accepts_direct_write_roo ); } -#[tokio::test] -async fn session_configuration_apply_rebinds_symbolic_profile_to_updated_workspace_roots() { - let mut session_configuration = make_session_configuration_for_tests().await; - let old_root = tempfile::tempdir().expect("create old root"); - let new_root = tempfile::tempdir().expect("create new root"); - let profile_root = tempfile::tempdir().expect("create profile root"); - let old_root = old_root.path().abs(); - let new_root = new_root.path().abs(); - let profile_root = profile_root.path().abs(); - session_configuration.workspace_roots = vec![old_root.clone()]; - - let file_system_sandbox_policy = - FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::project_roots(/*subpath*/ None), - }, - access: FileSystemAccessMode::Write, - }]); - let permission_profile = PermissionProfile::from_runtime_permissions( - &file_system_sandbox_policy, - NetworkSandboxPolicy::Restricted, - ); - - let updated = session_configuration - .apply(&SessionSettingsUpdate { - workspace_roots: Some(vec![new_root.clone()]), - permission_profile: Some(permission_profile), - active_permission_profile: Some(ActivePermissionProfile::new("dev")), - profile_workspace_roots: Some(vec![profile_root.clone()]), - ..Default::default() - }) - .expect("permission profile update should succeed"); - - let updated_policy = updated.file_system_sandbox_policy(); - assert!(updated_policy.can_write_path_with_cwd(new_root.as_path(), updated.cwd().as_path())); - assert!(!updated_policy.can_write_path_with_cwd(old_root.as_path(), updated.cwd().as_path())); - assert_eq!( - updated.active_permission_profile(), - Some(ActivePermissionProfile::new("dev")) - ); - assert_eq!(updated.profile_workspace_roots(), &[profile_root]); -} - -#[tokio::test] -async fn session_configuration_apply_retargets_implicit_workspace_root_on_cwd_update() { - let mut session_configuration = make_session_configuration_for_tests().await; - let old_root = tempfile::tempdir().expect("create old root"); - let new_root = tempfile::tempdir().expect("create new root"); - let extra_root = tempfile::tempdir().expect("create extra root"); - let old_root = old_root.path().abs(); - let new_root = new_root.path().abs(); - let extra_root = extra_root.path().abs(); - session_configuration.environments = - TurnEnvironmentSelections::new(old_root.clone(), Vec::new()); - session_configuration.workspace_roots = vec![old_root.clone(), extra_root.clone()]; - - let file_system_sandbox_policy = - FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::project_roots(/*subpath*/ None), - }, - access: FileSystemAccessMode::Write, - }]); - let permission_profile = PermissionProfile::from_runtime_permissions( - &file_system_sandbox_policy, - NetworkSandboxPolicy::Restricted, - ); - session_configuration - .set_permission_profile_for_tests(permission_profile) - .expect("set permission profile"); - - let updated = session_configuration - .apply(&SessionSettingsUpdate { - environments: Some(TurnEnvironmentSelections::new(new_root.clone(), Vec::new())), - ..Default::default() - }) - .expect("cwd-only update should succeed"); - - assert_eq!( - updated.workspace_roots, - vec![new_root.clone(), extra_root.clone()] - ); - let updated_policy = updated.file_system_sandbox_policy(); - assert!(updated_policy.can_write_path_with_cwd(new_root.as_path(), updated.cwd().as_path())); - assert!(updated_policy.can_write_path_with_cwd(extra_root.as_path(), updated.cwd().as_path())); - assert!(!updated_policy.can_write_path_with_cwd(old_root.as_path(), updated.cwd().as_path())); -} - #[tokio::test] async fn active_profile_update_rebuilds_network_proxy_config() -> std::io::Result<()> { let codex_home = tempfile::tempdir().expect("create codex home"); @@ -4963,7 +4825,6 @@ async fn session_configuration_apply_retargets_legacy_workspace_root_on_cwd_upda let project_root = workspace.path().join("repo-b").abs(); session_configuration.environments = TurnEnvironmentSelections::new(original_cwd.clone(), Vec::new()); - session_configuration.workspace_roots = vec![session_configuration.cwd().clone()]; let sandbox_policy = SandboxPolicy::WorkspaceWrite { writable_roots: Vec::new(), network_access: false, @@ -4994,7 +4855,7 @@ async fn session_configuration_apply_retargets_legacy_workspace_root_on_cwd_upda }) .expect("cwd-only update should succeed"); - assert_eq!(updated.workspace_roots, vec![project_root.clone()]); + assert_eq!(updated.workspace_roots(), vec![project_root.clone()]); assert!( updated .file_system_sandbox_policy() @@ -5261,7 +5122,6 @@ async fn session_new_fails_when_zsh_fork_enabled_without_packaged_zsh() { permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), environments: TurnEnvironmentSelections::new(config.cwd.clone(), Vec::new()), - workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, original_config_do_not_use: Arc::clone(&config), @@ -5393,7 +5253,6 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), environments: TurnEnvironmentSelections::new(config.cwd.clone(), default_environments), - workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, original_config_do_not_use: Arc::clone(&config), @@ -5409,8 +5268,11 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { dynamic_tools: Vec::new(), user_shell_override: None, }; - let per_turn_config = - Session::build_per_turn_config(&session_configuration, session_configuration.cwd().clone()); + let per_turn_config = Session::build_per_turn_config( + &session_configuration, + session_configuration.cwd().clone(), + session_configuration.workspace_roots(), + ); let model_info = construct_model_info_offline_for_tests( session_configuration.collaboration_mode.model(), &per_turn_config.to_models_manager_config(), @@ -5647,7 +5509,6 @@ async fn make_session_with_config_and_rx( permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), environments: TurnEnvironmentSelections::new(config.cwd.clone(), default_environments), - workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, original_config_do_not_use: Arc::clone(&config), @@ -5755,7 +5616,6 @@ async fn make_session_with_history_source_and_agent_control_and_rx( permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), environments: TurnEnvironmentSelections::new(config.cwd.clone(), default_environments), - workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, original_config_do_not_use: Arc::clone(&config), @@ -7525,7 +7385,6 @@ where permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), environments: TurnEnvironmentSelections::new(config.cwd.clone(), default_environments), - workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, original_config_do_not_use: Arc::clone(&config), @@ -7541,8 +7400,11 @@ where dynamic_tools, user_shell_override: None, }; - let per_turn_config = - Session::build_per_turn_config(&session_configuration, session_configuration.cwd().clone()); + let per_turn_config = Session::build_per_turn_config( + &session_configuration, + session_configuration.cwd().clone(), + session_configuration.workspace_roots(), + ); let model_info = construct_model_info_offline_for_tests( session_configuration.collaboration_mode.model(), &per_turn_config.to_models_manager_config(), diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index e200413d3deb..45ad4ebf5aab 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -45,6 +45,7 @@ pub(crate) struct TurnEnvironment { pub(crate) environment_id: String, pub(crate) environment: Arc, cwd: PathUri, + workspace_roots: Vec, pub(crate) shell: Option, pub(crate) shell_snapshot: ShellSnapshotTask, } @@ -60,6 +61,7 @@ impl TurnEnvironment { environment_id, environment, cwd, + workspace_roots: Vec::new(), shell, shell_snapshot: futures::future::ready(None).boxed().shared(), } @@ -79,10 +81,20 @@ impl TurnEnvironment { &self.cwd } + pub(crate) fn workspace_roots(&self) -> &[PathUri] { + &self.workspace_roots + } + + pub(crate) fn with_workspace_roots(mut self, workspace_roots: Vec) -> Self { + self.workspace_roots = workspace_roots; + self + } + pub(crate) fn selection(&self) -> TurnEnvironmentSelection { TurnEnvironmentSelection { environment_id: self.environment_id.clone(), cwd: self.cwd.clone(), + workspace_roots: self.workspace_roots.clone(), } } } @@ -93,6 +105,7 @@ impl std::fmt::Debug for TurnEnvironment { .field("environment_id", &self.environment_id) .field("environment", &self.environment) .field("cwd", &self.cwd) + .field("workspace_roots", &self.workspace_roots) .field("shell", &self.shell) .finish_non_exhaustive() } @@ -310,10 +323,11 @@ impl TurnContext { pub(crate) fn file_system_sandbox_context( &self, additional_permissions: Option, - cwd: &PathUri, + environment: &TurnEnvironment, ) -> FileSystemSandboxContext { + let permission_profile = self.config.permissions.permission_profile(); let (base_file_system_sandbox_policy, base_network_sandbox_policy) = - self.permission_profile.to_runtime_permissions(); + permission_profile.to_runtime_permissions(); let file_system_sandbox_policy = effective_file_system_sandbox_policy( &base_file_system_sandbox_policy, additional_permissions.as_ref(), @@ -323,19 +337,14 @@ impl TurnContext { additional_permissions.as_ref(), ); let permissions = PermissionProfile::from_runtime_permissions_with_enforcement( - self.permission_profile.enforcement(), + permission_profile.enforcement(), &file_system_sandbox_policy, network_sandbox_policy, ); FileSystemSandboxContext { permissions: permissions.into(), - cwd: Some(cwd.clone()), - workspace_roots: self - .config - .effective_workspace_roots() - .iter() - .map(PathUri::from_abs_path) - .collect(), + cwd: Some(environment.cwd().clone()), + workspace_roots: environment.workspace_roots().to_vec(), windows_sandbox_level: self.windows_sandbox_level, windows_sandbox_private_desktop: self .config @@ -426,15 +435,16 @@ impl Session { pub(crate) fn build_per_turn_config( session_configuration: &SessionConfiguration, cwd: AbsolutePathBuf, + workspace_roots: Vec, ) -> Config { // todo(aibrahim): store this state somewhere else so we don't need to mut config let config = session_configuration.original_config_do_not_use.clone(); let mut per_turn_config = (*config).clone(); per_turn_config.cwd = cwd; - per_turn_config.workspace_roots = session_configuration.workspace_roots.clone(); + per_turn_config.workspace_roots = workspace_roots.clone(); per_turn_config .permissions - .set_workspace_roots(session_configuration.workspace_roots.clone()); + .set_workspace_roots(workspace_roots); per_turn_config.model_reasoning_effort = session_configuration.collaboration_mode.reasoning_effort(); per_turn_config.model_reasoning_summary = session_configuration.model_reasoning_summary; @@ -465,14 +475,16 @@ impl Session { pub(crate) fn build_effective_session_config( session_configuration: &SessionConfiguration, ) -> Config { - let mut config = - Self::build_per_turn_config(session_configuration, session_configuration.cwd().clone()); + let workspace_roots = session_configuration.workspace_roots(); + let mut config = Self::build_per_turn_config( + session_configuration, + session_configuration.cwd().clone(), + workspace_roots.clone(), + ); config.model = Some(session_configuration.collaboration_mode.model().to_string()); config.permissions.approval_policy = session_configuration.approval_policy.clone(); - config.workspace_roots = session_configuration.workspace_roots.clone(); - config - .permissions - .set_workspace_roots(session_configuration.workspace_roots.clone()); + config.workspace_roots = workspace_roots.clone(); + config.permissions.set_workspace_roots(workspace_roots); config } @@ -523,6 +535,7 @@ impl Session { per_turn_config.features.enabled(Feature::FastMode), &model_info, ); + let permission_profile = per_turn_config.permissions.effective_permission_profile(); let per_turn_config = Arc::new(per_turn_config); let turn_metadata_state = Arc::new(TurnMetadataState::new( session_id.to_string(), @@ -533,7 +546,7 @@ impl Session { session_configuration.thread_source.clone(), sub_id.clone(), cwd.clone(), - &session_configuration.permission_profile(), + &permission_profile, session_configuration.windows_sandbox_level, network.is_some(), )); @@ -566,7 +579,7 @@ impl Session { multi_agent_version, personality: session_configuration.personality, approval_policy: session_configuration.approval_policy.clone(), - permission_profile: session_configuration.permission_profile(), + permission_profile, network, windows_sandbox_level: session_configuration.windows_sandbox_level, available_models, @@ -697,7 +710,21 @@ impl Session { .as_ref() .and_then(|turn_environment| turn_environment.cwd().to_abs_path().ok()) .unwrap_or_else(|| session_configuration.cwd().clone()); - let per_turn_config = Self::build_per_turn_config(&session_configuration, cwd.clone()); + let workspace_roots = primary_turn_environment + .as_ref() + .map(TurnEnvironment::workspace_roots) + .map(|workspace_roots| { + workspace_roots + .iter() + .map(PathUri::to_abs_path) + .collect::>>() + }) + .transpose() + .ok() + .flatten() + .unwrap_or_else(|| session_configuration.workspace_roots()); + let per_turn_config = + Self::build_per_turn_config(&session_configuration, cwd.clone(), workspace_roots); { let mcp_runtime = self.services.latest_mcp_runtime(); let mcp_connection_manager = mcp_runtime.manager(); diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index de37484f8bee..508f8ffad719 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -509,8 +509,13 @@ impl ThreadManager { pub fn default_environment_selections( &self, cwd: &AbsolutePathBuf, + workspace_roots: &[AbsolutePathBuf], ) -> Vec { - default_thread_environment_selections(self.state.environment_manager.as_ref(), cwd) + default_thread_environment_selections( + self.state.environment_manager.as_ref(), + cwd, + workspace_roots, + ) } pub fn validate_environment_selections( @@ -658,6 +663,7 @@ impl ThreadManager { let environments = default_thread_environment_selections( self.state.environment_manager.as_ref(), &config.cwd, + &config.workspace_roots, ); Box::pin(self.start_thread_with_options(StartThreadOptions { config, @@ -789,6 +795,7 @@ impl ThreadManager { let environments = default_thread_environment_selections( self.state.environment_manager.as_ref(), &config.cwd, + &config.workspace_roots, ); let (session_source, thread_source) = initial_history .get_resumed_session_sources() @@ -827,6 +834,7 @@ impl ThreadManager { let environments = default_thread_environment_selections( self.state.environment_manager.as_ref(), &config.cwd, + &config.workspace_roots, ); Box::pin(self.state.spawn_thread( config, @@ -860,6 +868,7 @@ impl ThreadManager { let environments = default_thread_environment_selections( self.state.environment_manager.as_ref(), &config.cwd, + &config.workspace_roots, ); let (session_source, thread_source) = initial_history .get_resumed_session_sources() @@ -1048,6 +1057,7 @@ impl ThreadManager { let environments = default_thread_environment_selections( self.state.environment_manager.as_ref(), &config.cwd, + &config.workspace_roots, ); let agent_control = self.agent_control_for_config(&config); Box::pin(self.state.spawn_thread( @@ -1364,7 +1374,11 @@ impl ThreadManagerState { environments: Option>, ) -> CodexResult { let environments = environments.unwrap_or_else(|| { - default_thread_environment_selections(self.environment_manager.as_ref(), &config.cwd) + default_thread_environment_selections( + self.environment_manager.as_ref(), + &config.cwd, + &config.workspace_roots, + ) }); Box::pin(self.spawn_thread_with_source( config, @@ -1403,8 +1417,11 @@ impl ThreadManagerState { inherited_environments, inherited_exec_policy, } = options; - let environments = - default_thread_environment_selections(self.environment_manager.as_ref(), &config.cwd); + let environments = default_thread_environment_selections( + self.environment_manager.as_ref(), + &config.cwd, + &config.workspace_roots, + ); let thread_source = initial_history.get_resumed_thread_source(); Box::pin(self.spawn_thread_with_source( config, @@ -1446,7 +1463,11 @@ impl ThreadManagerState { thread_extension_init: ExtensionDataInit, ) -> CodexResult { let environments = environments.unwrap_or_else(|| { - default_thread_environment_selections(self.environment_manager.as_ref(), &config.cwd) + default_thread_environment_selections( + self.environment_manager.as_ref(), + &config.cwd, + &config.workspace_roots, + ) }); Box::pin(self.spawn_thread_with_source( config, diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index d29d6d04e5d1..b4842e29738f 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -829,6 +829,7 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() { let environments = vec![TurnEnvironmentSelection { environment_id: "local".to_string(), cwd: PathUri::from_abs_path(&selected_cwd), + workspace_roots: Vec::new(), }]; let default_cwd = config.cwd.clone(); let mut source_config = config.clone(); diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index 6326dbc1adf2..0950174ba444 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -383,10 +383,8 @@ impl ApplyPatchHandler { )); }; let fs = turn_environment.environment.get_filesystem(); - let sandbox = turn.file_system_sandbox_context( - /*additional_permissions*/ None, - turn_environment.cwd(), - ); + let sandbox = turn + .file_system_sandbox_context(/*additional_permissions*/ None, turn_environment); match codex_apply_patch::verify_apply_patch_args( args, turn_environment.cwd(), @@ -554,7 +552,8 @@ pub(crate) async fn intercept_apply_patch( call_id: &str, tool_name: &str, ) -> Result, FunctionCallError> { - let sandbox = turn.file_system_sandbox_context(/*additional_permissions*/ None, cwd); + let sandbox = + turn.file_system_sandbox_context(/*additional_permissions*/ None, &turn_environment); match codex_apply_patch::maybe_parse_apply_patch_verified(command, cwd, fs, Some(&sandbox)) .await { diff --git a/codex-rs/core/src/tools/handlers/extension_tools.rs b/codex-rs/core/src/tools/handlers/extension_tools.rs index 2fd998849e08..ec4eb85e8828 100644 --- a/codex-rs/core/src/tools/handlers/extension_tools.rs +++ b/codex-rs/core/src/tools/handlers/extension_tools.rs @@ -133,7 +133,7 @@ async fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall { .additional_permissions; let file_system_sandbox_context = invocation .turn - .file_system_sandbox_context(additional_permissions, environment.cwd()); + .file_system_sandbox_context(additional_permissions, environment); environments.push(ToolEnvironment { environment_id: environment.environment_id.clone(), cwd: native_cwd, diff --git a/codex-rs/core/src/tools/handlers/view_image.rs b/codex-rs/core/src/tools/handlers/view_image.rs index 625b5b871308..c3ec34ed2225 100644 --- a/codex-rs/core/src/tools/handlers/view_image.rs +++ b/codex-rs/core/src/tools/handlers/view_image.rs @@ -147,10 +147,8 @@ impl ViewImageHandler { )) })?; let model_visible_path = path_uri.inferred_native_path_string(); - let sandbox = turn.file_system_sandbox_context( - /*additional_permissions*/ None, - turn_environment.cwd(), - ); + let sandbox = turn + .file_system_sandbox_context(/*additional_permissions*/ None, turn_environment); let fs = turn_environment.environment.get_filesystem(); let metadata = fs diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index f82ad5bb7030..475bc170a058 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -13,6 +13,7 @@ use crate::guardian::review_approval_request; use crate::guardian::routes_approval_to_guardian; use crate::hook_runtime::run_permission_request_hooks; use crate::network_policy_decision::network_approval_context_from_payload; +use crate::session::turn_context::TurnEnvironment; use crate::tools::flat_tool_name; use crate::tools::network_approval::ActiveNetworkApproval; use crate::tools::network_approval::DeferredNetworkApproval; @@ -265,16 +266,33 @@ impl ToolOrchestrator { .sandbox_cwd(req) .cloned() .unwrap_or_else(|| PathUri::from_abs_path(&turn_ctx.cwd)); - let workspace_roots = turn_ctx.config.effective_workspace_roots(); + let workspace_roots = tool + .turn_environment(req) + .or_else(|| turn_ctx.environments.primary()) + .map(TurnEnvironment::workspace_roots) + .unwrap_or_default(); + let permissions = workspace_roots + .iter() + .map(PathUri::to_abs_path) + .collect::>>() + .map(|workspace_roots| { + turn_ctx + .config + .permissions + .permission_profile() + .clone() + .materialize_project_roots_with_workspace_roots(&workspace_roots) + }) + .unwrap_or_else(|_| turn_ctx.permission_profile.clone()); let initial_attempt = SandboxAttempt { sandbox: initial_sandbox, sandbox_requested, - permissions: &turn_ctx.permission_profile, + permissions: &permissions, exec_server_permissions: turn_ctx.config.permissions.permission_profile(), enforce_managed_network: managed_network_active, manager: &self.sandbox, sandbox_cwd: &sandbox_policy_cwd, - workspace_roots: workspace_roots.as_slice(), + workspace_roots, codex_linux_sandbox_exe: turn_ctx.config.codex_linux_sandbox_exe.as_ref(), use_legacy_landlock, windows_sandbox_level: turn_ctx.windows_sandbox_level, @@ -452,12 +470,12 @@ impl ToolOrchestrator { let retry_attempt = SandboxAttempt { sandbox: retry_sandbox, sandbox_requested: retry_sandbox_requested, - permissions: &turn_ctx.permission_profile, + permissions: &permissions, exec_server_permissions: turn_ctx.config.permissions.permission_profile(), enforce_managed_network: managed_network_active, manager: &self.sandbox, sandbox_cwd: &sandbox_policy_cwd, - workspace_roots: workspace_roots.as_slice(), + workspace_roots, codex_linux_sandbox_exe: retry_codex_linux_sandbox_exe, use_legacy_landlock, windows_sandbox_level: turn_ctx.windows_sandbox_level, diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index e5617e0a508f..9617f9140adf 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -105,11 +105,7 @@ impl ApplyPatchRuntime { Some(FileSystemSandboxContext { permissions: permissions.into(), cwd: Some(attempt.sandbox_cwd.clone()), - workspace_roots: attempt - .workspace_roots - .iter() - .map(PathUri::from_abs_path) - .collect(), + workspace_roots: attempt.workspace_roots.to_vec(), windows_sandbox_level: attempt.windows_sandbox_level, windows_sandbox_private_desktop: attempt.windows_sandbox_private_desktop, use_legacy_landlock: attempt.use_legacy_landlock, @@ -223,6 +219,10 @@ impl Approvable for ApplyPatchRuntime { } impl ToolRuntime for ApplyPatchRuntime { + fn turn_environment<'a>(&self, req: &'a ApplyPatchRequest) -> Option<&'a TurnEnvironment> { + Some(&req.turn_environment) + } + fn sandbox_cwd<'a>(&self, req: &'a ApplyPatchRequest) -> Option<&'a PathUri> { Some(&req.action.cwd) } 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 c70002e62f07..bcb474e9614d 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs @@ -226,7 +226,7 @@ async fn file_system_sandbox_context_uses_active_attempt() { enforce_managed_network: false, manager: &manager, sandbox_cwd: &sandbox_policy_cwd, - workspace_roots: std::slice::from_ref(&path), + workspace_roots: std::slice::from_ref(&sandbox_policy_cwd), codex_linux_sandbox_exe: None, use_legacy_landlock: true, windows_sandbox_level: WindowsSandboxLevel::RestrictedToken, @@ -295,7 +295,7 @@ async fn no_sandbox_attempt_has_no_file_system_context() { enforce_managed_network: false, manager: &manager, sandbox_cwd: &sandbox_policy_cwd, - workspace_roots: std::slice::from_ref(&path), + workspace_roots: std::slice::from_ref(&sandbox_policy_cwd), codex_linux_sandbox_exe: None, use_legacy_landlock: false, windows_sandbox_level: WindowsSandboxLevel::Disabled, diff --git a/codex-rs/core/src/tools/runtimes/mod_tests.rs b/codex-rs/core/src/tools/runtimes/mod_tests.rs index f6ec38f6c26d..34c89581476c 100644 --- a/codex-rs/core/src/tools/runtimes/mod_tests.rs +++ b/codex-rs/core/src/tools/runtimes/mod_tests.rs @@ -112,7 +112,7 @@ async fn explicit_escalation_prepares_exec_without_managed_network() -> anyhow:: enforce_managed_network: false, manager: &manager, sandbox_cwd: &sandbox_policy_cwd, - workspace_roots: std::slice::from_ref(&native_sandbox_policy_cwd), + workspace_roots: std::slice::from_ref(&sandbox_policy_cwd), codex_linux_sandbox_exe: None, use_legacy_landlock: false, windows_sandbox_level: WindowsSandboxLevel::Disabled, diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 3ff50f3f51ad..6562faa66ea9 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -208,6 +208,10 @@ impl Approvable for ShellRuntime { } impl ToolRuntime for ShellRuntime { + fn turn_environment<'a>(&self, req: &'a ShellRequest) -> Option<&'a TurnEnvironment> { + Some(&req.turn_environment) + } + fn network_approval_spec( &self, req: &ShellRequest, diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index a1b9b33c6d5c..8ebd26aa90b9 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -255,6 +255,10 @@ impl Approvable for UnifiedExecRuntime<'_> { } impl<'a> ToolRuntime for UnifiedExecRuntime<'a> { + fn turn_environment<'b>(&self, req: &'b UnifiedExecRequest) -> Option<&'b TurnEnvironment> { + Some(&req.turn_environment) + } + fn sandbox_cwd<'b>(&self, req: &'b UnifiedExecRequest) -> Option<&'b PathUri> { Some(&req.sandbox_cwd) } diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index a23bd5a00089..cd3fb16af942 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -8,6 +8,7 @@ use crate::sandboxing::ExecOptions; use crate::sandboxing::SandboxPermissions; use crate::session::session::Session; use crate::session::turn_context::TurnContext; +use crate::session::turn_context::TurnEnvironment; use crate::state::SessionServices; use crate::tools::hook_names::HookToolName; use crate::tools::network_approval::NetworkApprovalSpec; @@ -27,7 +28,6 @@ use codex_sandboxing::SandboxType; use codex_sandboxing::SandboxablePreference; use codex_sandboxing::policy_transforms::effective_permission_profile; use codex_tools::ToolName; -use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; use futures::Future; use futures::future::BoxFuture; @@ -395,6 +395,10 @@ pub(crate) enum ToolError { } pub(crate) trait ToolRuntime: Approvable + Sandboxable { + fn turn_environment<'a>(&self, _req: &'a Req) -> Option<&'a TurnEnvironment> { + None + } + fn network_approval_spec(&self, _req: &Req, _ctx: &ToolCtx) -> Option { None } @@ -421,7 +425,7 @@ pub(crate) struct SandboxAttempt<'a> { pub enforce_managed_network: bool, pub(crate) manager: &'a SandboxManager, pub(crate) sandbox_cwd: &'a PathUri, - pub(crate) workspace_roots: &'a [AbsolutePathBuf], + pub(crate) workspace_roots: &'a [PathUri], pub codex_linux_sandbox_exe: Option<&'a std::path::PathBuf>, pub use_legacy_landlock: bool, pub windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel, @@ -464,10 +468,15 @@ impl<'a> SandboxAttempt<'a> { windows_sandbox_private_desktop: self.windows_sandbox_private_desktop, }) .map_err(CodexErr::from)?; + let workspace_roots = self + .workspace_roots + .iter() + .map(PathUri::to_abs_path) + .collect::>>()?; Ok(crate::sandboxing::ExecRequest::from_sandbox_exec_request( request, options, - self.workspace_roots.to_vec(), + workspace_roots, )) } @@ -501,21 +510,14 @@ impl<'a> SandboxAttempt<'a> { windows_sandbox_private_desktop: self.windows_sandbox_private_desktop, }) .map_err(CodexErr::from)?; - let mut exec_request = crate::sandboxing::ExecRequest::from_sandbox_exec_request( - request, - options, - self.workspace_roots.to_vec(), - ); + let mut exec_request = + crate::sandboxing::ExecRequest::from_sandbox_exec_request(request, options, Vec::new()); exec_request.exec_server_managed_network = managed_network; if self.sandbox_requested { exec_request.exec_server_sandbox = Some(FileSystemSandboxContext { permissions: exec_server_permissions.into(), cwd: Some(exec_request.windows_sandbox_policy_cwd.clone()), - workspace_roots: self - .workspace_roots - .iter() - .map(PathUri::from_abs_path) - .collect(), + workspace_roots: self.workspace_roots.to_vec(), windows_sandbox_level: self.windows_sandbox_level, windows_sandbox_private_desktop: self.windows_sandbox_private_desktop, use_legacy_landlock: self.use_legacy_landlock, diff --git a/codex-rs/core/src/tools/sandboxing_tests.rs b/codex-rs/core/src/tools/sandboxing_tests.rs index ee2530a3863d..9e1d00d0e201 100644 --- a/codex-rs/core/src/tools/sandboxing_tests.rs +++ b/codex-rs/core/src/tools/sandboxing_tests.rs @@ -221,7 +221,7 @@ fn exec_server_env_keeps_command_native_and_carries_sandbox_context() { enforce_managed_network: true, manager: &manager, sandbox_cwd: &cwd_uri, - workspace_roots: std::slice::from_ref(&cwd), + workspace_roots: std::slice::from_ref(&cwd_uri), codex_linux_sandbox_exe: None, use_legacy_landlock: false, windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index b95a003c5b21..65eadaca95be 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -109,6 +109,7 @@ pub fn local(cwd: AbsolutePathBuf) -> TurnEnvironmentSelection { TurnEnvironmentSelection { environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_abs_path(&cwd), + workspace_roots: vec![PathUri::from_abs_path(&cwd)], } } @@ -203,6 +204,7 @@ pub async fn test_env() -> Result { let selection = TurnEnvironmentSelection { environment_id: codex_exec_server::REMOTE_ENVIRONMENT_ID.to_string(), cwd: cwd_uri.clone(), + workspace_roots: vec![cwd_uri.clone()], }; let cwd = if remote_env == TestEnvironment::WineExec { // TODO(anp): Convert `Config::cwd` to `LegacyAppPathString` and remove this @@ -686,7 +688,8 @@ impl TestCodexBuilder { .await? } (None, None) => { - let environments = thread_manager.default_environment_selections(&config.cwd); + let environments = thread_manager + .default_environment_selections(&config.cwd, &config.workspace_roots); Box::pin( thread_manager.start_thread_with_options(StartThreadOptions { config: config.clone(), 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 ff8031606109..9f5b4d2f35aa 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 @@ -108,6 +108,7 @@ async fn windows_exec_server_runs_with_native_shell_and_cwd() -> Result<()> { vec![TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: PathUri::parse("file:///C:/codex-home")?, + workspace_roots: Vec::new(), }], ); diff --git a/codex-rs/core/tests/suite/agents_md.rs b/codex-rs/core/tests/suite/agents_md.rs index 7c73b9060392..3d2e88d8e4c8 100644 --- a/codex-rs/core/tests/suite/agents_md.rs +++ b/codex-rs/core/tests/suite/agents_md.rs @@ -705,10 +705,12 @@ async fn multi_environment_thread_loads_every_project_and_keeps_creation_snapsho TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_abs_path(&test.config.cwd), + workspace_roots: Vec::new(), }, TurnEnvironmentSelection { environment_id: LOCAL_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_host_native_path(local_root.path())?, + workspace_roots: Vec::new(), }, ], thread_extension_init: Default::default(), diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index d8142723b495..aa134fb1eaf9 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -1653,6 +1653,7 @@ async fn apply_patch_turn_diff_tracks_local_and_remote_environment_paths() -> Re TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_abs_path(&shared_cwd), + workspace_roots: Vec::new(), }, ]; test.codex diff --git a/codex-rs/core/tests/suite/network_approval.rs b/codex-rs/core/tests/suite/network_approval.rs index 437155f32547..d3da765289bc 100644 --- a/codex-rs/core/tests/suite/network_approval.rs +++ b/codex-rs/core/tests/suite/network_approval.rs @@ -261,6 +261,7 @@ async fn approved_network_host_for_one_environment_still_prompts_in_another() -> TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_abs_path(&remote_cwd), + workspace_roots: Vec::new(), }, ]; diff --git a/codex-rs/core/tests/suite/remote_env.rs b/codex-rs/core/tests/suite/remote_env.rs index 5f8c50349693..e93ba8747870 100644 --- a/codex-rs/core/tests/suite/remote_env.rs +++ b/codex-rs/core/tests/suite/remote_env.rs @@ -302,6 +302,7 @@ async fn explicit_remote_shell_runs_in_remote_cwd() -> Result<()> { Some(vec![TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_abs_path(&test.config.cwd), + workspace_roots: Vec::new(), }]), ) .await?; @@ -1134,6 +1135,7 @@ async fn exec_command_routes_to_selected_remote_environment() -> Result<()> { let remote_selection = TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_abs_path(&remote_cwd), + workspace_roots: Vec::new(), }; let multi_env_output = exec_command_routing_output( &test, @@ -1290,6 +1292,7 @@ async fn remote_request_permissions_grant_unblocks_later_remote_exec() -> Result TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_abs_path(&remote_cwd), + workspace_roots: Vec::new(), }, ], AskForApproval::OnRequest, @@ -1431,6 +1434,7 @@ async fn apply_patch_freeform_routes_to_selected_remote_environment() -> Result< TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_abs_path(&remote_cwd), + workspace_roots: Vec::new(), }, ]), ) @@ -1514,6 +1518,7 @@ async fn apply_patch_approvals_are_remembered_per_environment() -> Result<()> { TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_abs_path(&remote_cwd), + workspace_roots: Vec::new(), }, ]; let local_patch = format!( @@ -1715,6 +1720,7 @@ async fn apply_patch_intercepted_exec_command_routes_to_selected_remote_environm TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_abs_path(&remote_cwd), + workspace_roots: Vec::new(), }, ]), ) diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 38c2a7d77175..4a7d85231d87 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -590,6 +590,7 @@ async fn view_image_routes_to_selected_remote_environment() -> anyhow::Result<() let remote_selection = TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: remote_cwd_uri, + workspace_roots: Vec::new(), }; let relative_call_id = "call-view-image-relative-multi-env"; let absolute_call_id = "call-view-image-absolute-multi-env"; diff --git a/codex-rs/ext/skills/tests/skills_extension.rs b/codex-rs/ext/skills/tests/skills_extension.rs index 76d0df7954b4..61fb9f9af750 100644 --- a/codex-rs/ext/skills/tests/skills_extension.rs +++ b/codex-rs/ext/skills/tests/skills_extension.rs @@ -199,6 +199,7 @@ async fn selected_executor_catalog_follows_step_availability_and_reuses_its_cach let turn_environment = TurnEnvironmentSelection { environment_id: "turn-env".to_string(), cwd: PathUri::parse("file:///workspace").expect("cwd URI"), + workspace_roots: Vec::new(), }; let available_sections = registry.context_contributors()[0] .contribute_world_state(WorldStateContributionInput { @@ -593,6 +594,7 @@ async fn root_qualified_locator_selects_only_the_matching_executor_skill() -> Te environments: &[TurnEnvironmentSelection { environment_id: "env-1".to_string(), cwd: PathUri::parse("file:///workspace").expect("cwd URI"), + workspace_roots: Vec::new(), }], ready_selected_capability_roots: &selected_roots, session_store: &session_store, diff --git a/codex-rs/memories/write/src/runtime.rs b/codex-rs/memories/write/src/runtime.rs index 903a1743ff30..9219edaeb50b 100644 --- a/codex-rs/memories/write/src/runtime.rs +++ b/codex-rs/memories/write/src/runtime.rs @@ -324,7 +324,7 @@ impl MemoryStartupContext { ) -> anyhow::Result { let environments = self .thread_manager - .default_environment_selections(&config.cwd); + .default_environment_selections(&config.cwd, &config.workspace_roots); let NewThread { thread_id, thread, .. } = self diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 57a36c3312c9..5af7fccc1073 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -134,6 +134,7 @@ pub fn strip_user_message_prefix(text: &str) -> &str { pub struct TurnEnvironmentSelection { pub environment_id: String, pub cwd: PathUri, + pub workspace_roots: Vec, } #[derive(Debug, Clone, PartialEq)] @@ -453,10 +454,6 @@ pub struct ThreadSettingsOverrides { /// Updated fallback `cwd` and environments supplied together as a complete pair. pub environments: Option, - /// Updated runtime workspace roots used to materialize symbolic - /// `:workspace_roots` filesystem permissions. - pub workspace_roots: Option>, - /// Updated profile-defined workspace roots for status summaries and /// per-turn config reconstruction. pub profile_workspace_roots: Option>, From b008d04de3ebcdd5e289f377ffd844f5ca05ae39 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 9 Jul 2026 11:42:30 -0700 Subject: [PATCH 13/29] core: require roots when constructing turn environments --- codex-rs/core/src/agents_md_tests.rs | 1 + codex-rs/core/src/environment_selection.rs | 13 ++++++++++--- codex-rs/core/src/mcp_openai_file.rs | 1 + codex-rs/core/src/mcp_tool_call_tests.rs | 1 + codex-rs/core/src/session/tests.rs | 5 +++++ codex-rs/core/src/session/turn_context.rs | 8 ++------ codex-rs/core/src/tools/handlers/shell_tests.rs | 1 + codex-rs/core/src/tools/handlers/view_image.rs | 1 + .../core/src/tools/runtimes/apply_patch_tests.rs | 1 + codex-rs/core/src/tools/runtimes/shell_tests.rs | 1 + codex-rs/core/src/tools/runtimes/unified_exec.rs | 1 + codex-rs/core/src/tools/spec_plan_tests.rs | 1 + 12 files changed, 26 insertions(+), 9 deletions(-) diff --git a/codex-rs/core/src/agents_md_tests.rs b/codex-rs/core/src/agents_md_tests.rs index d93e4634dbb6..6bdfcf842ef6 100644 --- a/codex-rs/core/src/agents_md_tests.rs +++ b/codex-rs/core/src/agents_md_tests.rs @@ -303,6 +303,7 @@ fn resolved_local_environments( .expect("local environment"), ), PathUri::from_abs_path(&cwd), + Vec::new(), /*shell*/ None, ) }) diff --git a/codex-rs/core/src/environment_selection.rs b/codex-rs/core/src/environment_selection.rs index bed131a5cfb7..53857e596e77 100644 --- a/codex-rs/core/src/environment_selection.rs +++ b/codex-rs/core/src/environment_selection.rs @@ -177,9 +177,13 @@ impl ThreadEnvironments { } else { Some(local_shell) }; - let mut turn_environment = - TurnEnvironment::new(selection.environment_id, environment, selection.cwd, shell) - .with_workspace_roots(selection.workspace_roots); + let mut turn_environment = TurnEnvironment::new( + selection.environment_id, + environment, + selection.cwd, + selection.workspace_roots, + shell, + ); let task = shell_snapshot .build(turn_environment.clone()) .boxed() @@ -840,6 +844,7 @@ url = "ws://127.0.0.1:8765" selection.environment_id.clone(), Arc::clone(&inherited_environment), selection.cwd.clone(), + Vec::new(), /*shell*/ None, ); let manager = Arc::new(EnvironmentManager::without_environments()); @@ -897,6 +902,7 @@ url = "ws://127.0.0.1:8765" REMOTE_ENVIRONMENT_ID.to_string(), remote_environment.clone(), cwd_uri.clone(), + Vec::new(), /*shell*/ None, )], starting: Vec::new(), @@ -908,6 +914,7 @@ url = "ws://127.0.0.1:8765" REMOTE_ENVIRONMENT_ID.to_string(), remote_environment, cwd_uri, + Vec::new(), /*shell*/ None, ), ], diff --git a/codex-rs/core/src/mcp_openai_file.rs b/codex-rs/core/src/mcp_openai_file.rs index 0df11b2f1231..02c1587e04ea 100644 --- a/codex-rs/core/src/mcp_openai_file.rs +++ b/codex-rs/core/src/mcp_openai_file.rs @@ -229,6 +229,7 @@ mod tests { primary.environment_id.clone(), Arc::clone(&primary.environment), PathUri::from_abs_path(&cwd), + Vec::new(), primary.shell.clone(), ); } diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 8c76c603348f..6d1c68676dc5 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -1169,6 +1169,7 @@ async fn mcp_sandbox_cwd_uses_matching_server_environment_uri() -> anyhow::Resul "remote".to_string(), environment, secondary_cwd.clone(), + Vec::new(), /*shell*/ None, )); diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 7cbea472fbfd..83c55fdf63f3 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -6065,6 +6065,7 @@ async fn request_permissions_tool_resolves_relative_paths_against_selected_envir "remote".to_string(), current_environment.environment, PathUri::from_abs_path(&environment_cwd), + Vec::new(), current_environment.shell, ); @@ -6697,6 +6698,7 @@ async fn primary_environment_uses_first_turn_environment() { "second".to_string(), Arc::clone(&first_environment.environment), second_cwd_uri.clone(), + Vec::new(), /*shell*/ None, )); @@ -7919,6 +7921,7 @@ async fn record_context_updates_emits_environment_item_for_cwd_changes() { environment.environment_id, environment.environment, PathUri::from_abs_path(&cwd), + Vec::new(), environment.shell, ); @@ -7978,6 +7981,7 @@ async fn record_context_updates_omits_environment_item_when_disabled() { environment.environment_id, environment.environment, PathUri::from_abs_path(&test_path_buf("/new-repo").abs()), + Vec::new(), environment.shell, ); @@ -8706,6 +8710,7 @@ async fn turn_context_item_stores_local_cwd() { "remote".to_string(), environment.environment, cwd, + Vec::new(), environment.shell, ); diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 45ad4ebf5aab..bf50a5b20692 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -55,13 +55,14 @@ impl TurnEnvironment { environment_id: String, environment: Arc, cwd: PathUri, + workspace_roots: Vec, shell: Option, ) -> Self { Self { environment_id, environment, cwd, - workspace_roots: Vec::new(), + workspace_roots, shell, shell_snapshot: futures::future::ready(None).boxed().shared(), } @@ -85,11 +86,6 @@ impl TurnEnvironment { &self.workspace_roots } - pub(crate) fn with_workspace_roots(mut self, workspace_roots: Vec) -> Self { - self.workspace_roots = workspace_roots; - self - } - pub(crate) fn selection(&self) -> TurnEnvironmentSelection { TurnEnvironmentSelection { environment_id: self.environment_id.clone(), diff --git a/codex-rs/core/src/tools/handlers/shell_tests.rs b/codex-rs/core/src/tools/handlers/shell_tests.rs index ebfbf9127c36..90fd27d87815 100644 --- a/codex-rs/core/src/tools/handlers/shell_tests.rs +++ b/codex-rs/core/src/tools/handlers/shell_tests.rs @@ -109,6 +109,7 @@ async fn shell_command_handler_to_exec_params_uses_selected_environment() { .environment, ), PathUri::from_abs_path(&selected_cwd), + Vec::new(), Some(selected_shell), ); let mut expected_env = create_env( diff --git a/codex-rs/core/src/tools/handlers/view_image.rs b/codex-rs/core/src/tools/handlers/view_image.rs index c3ec34ed2225..92b7d0023b78 100644 --- a/codex-rs/core/src/tools/handlers/view_image.rs +++ b/codex-rs/core/src/tools/handlers/view_image.rs @@ -270,6 +270,7 @@ mod tests { current.environment_id, current.environment, PathUri::from_abs_path(&cwd), + Vec::new(), current.shell, ); } 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 bcb474e9614d..6e3aa76c1674 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs @@ -20,6 +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()), PathUri::from_abs_path(&std::env::temp_dir().abs()), + Vec::new(), /*shell*/ None, ) } diff --git a/codex-rs/core/src/tools/runtimes/shell_tests.rs b/codex-rs/core/src/tools/runtimes/shell_tests.rs index eaa7adf48cba..9f070490adfc 100644 --- a/codex-rs/core/src/tools/runtimes/shell_tests.rs +++ b/codex-rs/core/src/tools/runtimes/shell_tests.rs @@ -13,6 +13,7 @@ async fn approval_key_includes_environment_id() { "remote".to_string(), Arc::new(Environment::default_for_tests()), PathUri::from_abs_path(&cwd), + Vec::new(), /*shell*/ None, ), shell_type: None, diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 8ebd26aa90b9..b5af707d6718 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -508,6 +508,7 @@ mod tests { LOCAL_ENVIRONMENT_ID.to_string(), Arc::new(Environment::default_for_tests()), cwd, + Vec::new(), /*shell*/ None, ) } diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index bd33336bcea7..6096fab575ef 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -592,6 +592,7 @@ async fn zsh_fork_unified_exec_keeps_shell_parameter_when_remote_environment_ava .expect("remote test environment"), ), remote_cwd, + Vec::new(), /*shell*/ None, ), ); From e1cb296253dba39008c1f12fe6aa3521b808e16c Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 9 Jul 2026 11:49:36 -0700 Subject: [PATCH 14/29] core: expose workspace roots to tool sandboxing --- codex-rs/core/src/tools/orchestrator.rs | 11 +++++++---- codex-rs/core/src/tools/runtimes/apply_patch.rs | 4 ++-- codex-rs/core/src/tools/runtimes/shell.rs | 5 +++-- codex-rs/core/src/tools/runtimes/unified_exec.rs | 4 ++-- codex-rs/core/src/tools/sandboxing.rs | 3 +-- 5 files changed, 15 insertions(+), 12 deletions(-) diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index 475bc170a058..06b6095d4897 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -13,7 +13,6 @@ use crate::guardian::review_approval_request; use crate::guardian::routes_approval_to_guardian; use crate::hook_runtime::run_permission_request_hooks; use crate::network_policy_decision::network_approval_context_from_payload; -use crate::session::turn_context::TurnEnvironment; use crate::tools::flat_tool_name; use crate::tools::network_approval::ActiveNetworkApproval; use crate::tools::network_approval::DeferredNetworkApproval; @@ -267,9 +266,13 @@ impl ToolOrchestrator { .cloned() .unwrap_or_else(|| PathUri::from_abs_path(&turn_ctx.cwd)); let workspace_roots = tool - .turn_environment(req) - .or_else(|| turn_ctx.environments.primary()) - .map(TurnEnvironment::workspace_roots) + .workspace_roots(req) + .or_else(|| { + turn_ctx + .environments + .primary() + .map(|environment| environment.workspace_roots()) + }) .unwrap_or_default(); let permissions = workspace_roots .iter() diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index 9617f9140adf..f5023bd6e00f 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -219,8 +219,8 @@ impl Approvable for ApplyPatchRuntime { } impl ToolRuntime for ApplyPatchRuntime { - fn turn_environment<'a>(&self, req: &'a ApplyPatchRequest) -> Option<&'a TurnEnvironment> { - Some(&req.turn_environment) + fn workspace_roots<'a>(&self, req: &'a ApplyPatchRequest) -> Option<&'a [PathUri]> { + Some(req.turn_environment.workspace_roots()) } fn sandbox_cwd<'a>(&self, req: &'a ApplyPatchRequest) -> Option<&'a PathUri> { diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 6562faa66ea9..a7b085bf8e9e 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -46,6 +46,7 @@ use codex_protocol::protocol::ReviewDecision; use codex_sandboxing::SandboxablePreference; use codex_shell_command::powershell::prefix_powershell_script_with_utf8; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; use futures::future::BoxFuture; use std::collections::HashMap; use tokio_util::sync::CancellationToken; @@ -208,8 +209,8 @@ impl Approvable for ShellRuntime { } impl ToolRuntime for ShellRuntime { - fn turn_environment<'a>(&self, req: &'a ShellRequest) -> Option<&'a TurnEnvironment> { - Some(&req.turn_environment) + fn workspace_roots<'a>(&self, req: &'a ShellRequest) -> Option<&'a [PathUri]> { + Some(req.turn_environment.workspace_roots()) } fn network_approval_spec( diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index b5af707d6718..ee5c0deddcc7 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -255,8 +255,8 @@ impl Approvable for UnifiedExecRuntime<'_> { } impl<'a> ToolRuntime for UnifiedExecRuntime<'a> { - fn turn_environment<'b>(&self, req: &'b UnifiedExecRequest) -> Option<&'b TurnEnvironment> { - Some(&req.turn_environment) + fn workspace_roots<'b>(&self, req: &'b UnifiedExecRequest) -> Option<&'b [PathUri]> { + Some(req.turn_environment.workspace_roots()) } fn sandbox_cwd<'b>(&self, req: &'b UnifiedExecRequest) -> Option<&'b PathUri> { diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index cd3fb16af942..91a33d0029e0 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -8,7 +8,6 @@ use crate::sandboxing::ExecOptions; use crate::sandboxing::SandboxPermissions; use crate::session::session::Session; use crate::session::turn_context::TurnContext; -use crate::session::turn_context::TurnEnvironment; use crate::state::SessionServices; use crate::tools::hook_names::HookToolName; use crate::tools::network_approval::NetworkApprovalSpec; @@ -395,7 +394,7 @@ pub(crate) enum ToolError { } pub(crate) trait ToolRuntime: Approvable + Sandboxable { - fn turn_environment<'a>(&self, _req: &'a Req) -> Option<&'a TurnEnvironment> { + fn workspace_roots<'a>(&self, _req: &'a Req) -> Option<&'a [PathUri]> { None } From 2bb2cd25c63b642711b0058979b48ce849962619 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 9 Jul 2026 11:50:56 -0700 Subject: [PATCH 15/29] core: require tool runtimes to provide workspace roots --- codex-rs/core/src/session/tests.rs | 4 ++++ codex-rs/core/src/tools/orchestrator.rs | 10 +--------- codex-rs/core/src/tools/runtimes/apply_patch.rs | 4 ++-- codex-rs/core/src/tools/runtimes/shell.rs | 4 ++-- codex-rs/core/src/tools/runtimes/unified_exec.rs | 4 ++-- codex-rs/core/src/tools/sandboxing.rs | 4 +--- 6 files changed, 12 insertions(+), 18 deletions(-) diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 83c55fdf63f3..9791813ddeb0 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1076,6 +1076,10 @@ async fn danger_full_access_tool_attempts_do_not_enforce_managed_network() -> an } impl crate::tools::sandboxing::ToolRuntime<(), ()> for ProbeToolRuntime { + fn workspace_roots<'a>(&self, _req: &'a ()) -> &'a [PathUri] { + &[] + } + async fn run( &mut self, _req: &(), diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index 06b6095d4897..100634fd5d19 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -265,15 +265,7 @@ impl ToolOrchestrator { .sandbox_cwd(req) .cloned() .unwrap_or_else(|| PathUri::from_abs_path(&turn_ctx.cwd)); - let workspace_roots = tool - .workspace_roots(req) - .or_else(|| { - turn_ctx - .environments - .primary() - .map(|environment| environment.workspace_roots()) - }) - .unwrap_or_default(); + let workspace_roots = tool.workspace_roots(req); let permissions = workspace_roots .iter() .map(PathUri::to_abs_path) diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index f5023bd6e00f..6b6a8b86a761 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -219,8 +219,8 @@ impl Approvable for ApplyPatchRuntime { } impl ToolRuntime for ApplyPatchRuntime { - fn workspace_roots<'a>(&self, req: &'a ApplyPatchRequest) -> Option<&'a [PathUri]> { - Some(req.turn_environment.workspace_roots()) + fn workspace_roots<'a>(&self, req: &'a ApplyPatchRequest) -> &'a [PathUri] { + req.turn_environment.workspace_roots() } fn sandbox_cwd<'a>(&self, req: &'a ApplyPatchRequest) -> Option<&'a PathUri> { diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index a7b085bf8e9e..f943e8597bb9 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -209,8 +209,8 @@ impl Approvable for ShellRuntime { } impl ToolRuntime for ShellRuntime { - fn workspace_roots<'a>(&self, req: &'a ShellRequest) -> Option<&'a [PathUri]> { - Some(req.turn_environment.workspace_roots()) + fn workspace_roots<'a>(&self, req: &'a ShellRequest) -> &'a [PathUri] { + req.turn_environment.workspace_roots() } fn network_approval_spec( diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index ee5c0deddcc7..8a97cde20fc2 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -255,8 +255,8 @@ impl Approvable for UnifiedExecRuntime<'_> { } impl<'a> ToolRuntime for UnifiedExecRuntime<'a> { - fn workspace_roots<'b>(&self, req: &'b UnifiedExecRequest) -> Option<&'b [PathUri]> { - Some(req.turn_environment.workspace_roots()) + fn workspace_roots<'b>(&self, req: &'b UnifiedExecRequest) -> &'b [PathUri] { + req.turn_environment.workspace_roots() } fn sandbox_cwd<'b>(&self, req: &'b UnifiedExecRequest) -> Option<&'b PathUri> { diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 91a33d0029e0..7b6f6e30d857 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -394,9 +394,7 @@ pub(crate) enum ToolError { } pub(crate) trait ToolRuntime: Approvable + Sandboxable { - fn workspace_roots<'a>(&self, _req: &'a Req) -> Option<&'a [PathUri]> { - None - } + fn workspace_roots<'a>(&self, req: &'a Req) -> &'a [PathUri]; fn network_approval_spec(&self, _req: &Req, _ctx: &ToolCtx) -> Option { None From 6e417ed7800b2f35e0df51ae7f0dd219c0e7121d Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 9 Jul 2026 12:03:51 -0700 Subject: [PATCH 16/29] app-server: keep workspace roots environment-local --- .../schema/json/ClientRequest.json | 2 +- .../codex_app_server_protocol.schemas.json | 2 +- .../codex_app_server_protocol.v2.schemas.json | 2 +- .../schema/json/v2/ThreadStartParams.json | 2 +- .../schema/json/v2/TurnStartParams.json | 2 +- .../typescript/v2/TurnEnvironmentParams.ts | 3 +- .../src/protocol/v2/turn.rs | 3 +- codex-rs/app-server/README.md | 2 +- codex-rs/app-server/src/request_processors.rs | 9 +--- .../request_processors/thread_processor.rs | 1 - .../src/request_processors/turn_processor.rs | 44 +++++++++++-------- .../app-server/tests/suite/v2/turn_start.rs | 14 +++--- 12 files changed, 43 insertions(+), 43 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 06b260bfb45d..77cd682f0fcd 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -4498,7 +4498,7 @@ "type": "string" }, "runtimeWorkspaceRoots": { - "description": "Environment-native runtime workspace roots. Omitted inherits the request's top-level `runtimeWorkspaceRoots` fallback.", + "description": "Environment-native runtime workspace roots. Omitted defaults to `cwd`.", "items": { "$ref": "#/definitions/LegacyAppPathString" }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 83ad817f178c..5aec67ee4d05 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -20172,7 +20172,7 @@ "type": "string" }, "runtimeWorkspaceRoots": { - "description": "Environment-native runtime workspace roots. Omitted inherits the request's top-level `runtimeWorkspaceRoots` fallback.", + "description": "Environment-native runtime workspace roots. Omitted defaults to `cwd`.", "items": { "$ref": "#/definitions/v2/LegacyAppPathString" }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 00dda383cba2..c85ce3bf53bf 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -17951,7 +17951,7 @@ "type": "string" }, "runtimeWorkspaceRoots": { - "description": "Environment-native runtime workspace roots. Omitted inherits the request's top-level `runtimeWorkspaceRoots` fallback.", + "description": "Environment-native runtime workspace roots. Omitted defaults to `cwd`.", "items": { "$ref": "#/definitions/LegacyAppPathString" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json index f1ad30099e9b..8bf5ae8bef72 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json @@ -283,7 +283,7 @@ "type": "string" }, "runtimeWorkspaceRoots": { - "description": "Environment-native runtime workspace roots. Omitted inherits the request's top-level `runtimeWorkspaceRoots` fallback.", + "description": "Environment-native runtime workspace roots. Omitted defaults to `cwd`.", "items": { "$ref": "#/definitions/LegacyAppPathString" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index 6b55e4c8bbb4..b38792e448bd 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -364,7 +364,7 @@ "type": "string" }, "runtimeWorkspaceRoots": { - "description": "Environment-native runtime workspace roots. Omitted inherits the request's top-level `runtimeWorkspaceRoots` fallback.", + "description": "Environment-native runtime workspace roots. Omitted defaults to `cwd`.", "items": { "$ref": "#/definitions/LegacyAppPathString" }, diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnEnvironmentParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnEnvironmentParams.ts index 2d7131a33116..f51fcf33ee52 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnEnvironmentParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnEnvironmentParams.ts @@ -5,7 +5,6 @@ import type { LegacyAppPathString } from "../LegacyAppPathString"; export type TurnEnvironmentParams = { environmentId: string, cwd: LegacyAppPathString, /** - * Environment-native runtime workspace roots. Omitted inherits the - * request's top-level `runtimeWorkspaceRoots` fallback. + * Environment-native runtime workspace roots. Omitted defaults to `cwd`. */ runtimeWorkspaceRoots?: Array | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs index 5c0e354e4315..62a289cd2541 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs @@ -41,8 +41,7 @@ pub enum TurnStatus { pub struct TurnEnvironmentParams { pub environment_id: String, pub cwd: LegacyAppPathString, - /// Environment-native runtime workspace roots. Omitted inherits the - /// request's top-level `runtimeWorkspaceRoots` fallback. + /// Environment-native runtime workspace roots. Omitted defaults to `cwd`. #[ts(optional = nullable)] pub runtime_workspace_roots: Option>, } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 037291bf0d49..a95a003842c6 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -137,7 +137,7 @@ Example with notification opt-out: ## API Overview -- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. Experimental `allowProviderModelFallback` lets providers backed by an authoritative static model catalog replace an unavailable requested `model` with the catalog default; dynamic or cached catalogs preserve the requested model. Experimental `runtimeWorkspaceRoots` supplies the default runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. For permissions, prefer experimental `permissions` profile selection by id; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Deprecated experimental `multiAgentMode` is ignored; use Ultra reasoning effort for proactive multi-agent behavior. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd` and optional environment-native `runtimeWorkspaceRoots`. Omitted per-environment roots inherit the top-level fallback; an empty list explicitly selects no roots for that environment. Experimental `selectedCapabilityRoots` selects environment-owned plugin or standalone-skill roots using environment-native absolute paths. Skills found below those roots are listed and read through the owning environment. Stdio MCP servers declared by selected plugins are started in that environment, and HTTP MCP connections use that environment's HTTP client. +- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. Experimental `allowProviderModelFallback` lets providers backed by an authoritative static model catalog replace an unavailable requested `model` with the catalog default; dynamic or cached catalogs preserve the requested model. Experimental `runtimeWorkspaceRoots` supplies the runtime workspace roots used when app-server creates default environment selections; paths must be absolute. For permissions, prefer experimental `permissions` profile selection by id; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Deprecated experimental `multiAgentMode` is ignored; use Ultra reasoning effort for proactive multi-agent behavior. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd` and optional environment-native `runtimeWorkspaceRoots`. Explicit environments ignore the top-level roots; omitted per-environment roots default to that environment's `cwd`, while an empty list explicitly selects no roots. Experimental `selectedCapabilityRoots` selects environment-owned plugin or standalone-skill roots using environment-native absolute paths. Skills found below those roots are listed and read through the owning environment. Stdio MCP servers declared by selected plugins are started in that environment, and HTTP MCP connections use that environment's HTTP client. - `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`. - `thread/fork` — fork an existing thread into a new thread id by copying the stored history; pass an optional `lastTurnId` to copy history only through that turn, inclusive, and drop later turns from the fork. An in-progress `lastTurnId` is rejected. If `lastTurnId` is null while the source thread is mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Experimental clients can pass `excludeTurns: true` when they plan to page fork history via `thread/turns/list` instead of receiving the full turn array immediately. Accepts the same permission override rules as `thread/start`. - `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. `instructionSources` lists loaded instruction files using each source environment's native absolute path syntax, including files loaded from remote environments. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. Their deprecated experimental `multiAgentMode` field, and the corresponding thread setting, always report `explicitRequestOnly`; Ultra reasoning effort is the source of proactive multi-agent behavior. diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 6b30cc055bf0..989742e19c39 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -464,7 +464,6 @@ use codex_thread_store::ThreadSortKey as StoreThreadSortKey; use codex_thread_store::ThreadStore; use codex_thread_store::ThreadStoreError; use codex_utils_absolute_path::AbsolutePathBuf; -use codex_utils_path_uri::PathUri; use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP; use std::collections::BTreeMap; use std::collections::HashMap; @@ -564,7 +563,6 @@ fn resolve_request_cwd(cwd: Option) -> Result, fn resolve_turn_environment_selections( thread_manager: &ThreadManager, environments: Option>, - fallback_workspace_roots: &[AbsolutePathBuf], ) -> Result>, JSONRPCErrorError> { let Some(environments) = environments else { return Ok(None); @@ -598,12 +596,7 @@ fn resolve_turn_environment_selections( Ok::<_, JSONRPCErrorError>(resolved_roots) }) .transpose()? - .unwrap_or_else(|| { - fallback_workspace_roots - .iter() - .map(PathUri::from_abs_path) - .collect() - }); + .unwrap_or_else(|| vec![cwd.clone()]); selections.push(TurnEnvironmentSelection { environment_id, cwd, diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index cdaf64007516..93e467b07961 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -1189,7 +1189,6 @@ impl ThreadRequestProcessor { let environments = resolve_turn_environment_selections( listener_task_context.thread_manager.as_ref(), environments, - &config.workspace_roots, )? .unwrap_or_else(|| { listener_task_context diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index 547d628728b1..f56c05bf8440 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -6,6 +6,7 @@ use codex_protocol::protocol::AdditionalContextKind as CoreAdditionalContextKind use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_utils_path_uri::PathUri; use crate::image_url::REMOTE_IMAGE_URL_ERROR; use crate::image_url::is_remote_image_url; @@ -484,15 +485,8 @@ impl TurnRequestProcessor { let runtime_workspace_roots = params .runtime_workspace_roots .map(resolve_runtime_workspace_roots); - let fallback_workspace_roots = match runtime_workspace_roots.as_ref() { - Some(workspace_roots) => workspace_roots.clone(), - None => thread.config_snapshot().await.workspace_roots, - }; - let environment_selections = resolve_turn_environment_selections( - self.thread_manager.as_ref(), - params.environments, - &fallback_workspace_roots, - )?; + let environment_selections = + resolve_turn_environment_selections(self.thread_manager.as_ref(), params.environments)?; // Map v2 input items to core input items. let mapped_items: Vec = params @@ -595,12 +589,10 @@ impl TurnRequestProcessor { } let snapshot = thread.config_snapshot().await; let legacy_fallback_cwd = cwd.unwrap_or_else(|| snapshot.cwd().clone()); - let legacy_fallback_workspace_roots = workspace_roots.unwrap_or(snapshot.workspace_roots); let environment_selections = environment_selections.unwrap_or_else(|| { - self.thread_manager.default_environment_selections( - &legacy_fallback_cwd, - &legacy_fallback_workspace_roots, - ) + let workspace_roots = workspace_roots.unwrap_or(snapshot.workspace_roots); + self.thread_manager + .default_environment_selections(&legacy_fallback_cwd, &workspace_roots) }); Some(TurnEnvironmentSelections::new( legacy_fallback_cwd, @@ -674,15 +666,29 @@ impl TurnRequestProcessor { "{method} permission selection missing thread snapshot" ))); }; + let workspace_roots = environments + .as_ref() + .map(|environments| { + environments + .environments + .first() + .map(|environment| { + environment + .workspace_roots + .iter() + .map(PathUri::to_abs_path) + .collect::>>() + .unwrap_or_default() + }) + .unwrap_or_default() + }) + .or_else(|| runtime_workspace_roots.clone()) + .unwrap_or_else(|| snapshot.workspace_roots.clone()); let overrides = ConfigOverrides { cwd: environments .as_ref() .map(|environments| environments.legacy_fallback_cwd.to_path_buf()), - workspace_roots: Some( - runtime_workspace_roots - .clone() - .unwrap_or_else(|| snapshot.workspace_roots.clone()), - ), + workspace_roots: Some(workspace_roots), default_permissions: Some(permissions), codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 637224d5f922..5b90bae43187 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -2712,7 +2712,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { #[cfg(unix)] #[tokio::test] -async fn turn_environment_workspace_roots_override_and_inherit_turn_fallback() -> Result<()> { +async fn turn_environment_workspace_roots_override_and_default_to_cwd() -> Result<()> { skip_if_no_network!(Ok(())); let tmp = TempDir::new()?; @@ -2801,7 +2801,7 @@ stream_max_retries = 0 text: "select dev profile".to_string(), text_elements: Vec::new(), }], - runtime_workspace_roots: Some(vec![fallback_root]), + runtime_workspace_roots: Some(vec![fallback_root.clone()]), environments: Some(vec![TurnEnvironmentParams { environment_id: "local".to_string(), cwd: environment_cwd.clone().into(), @@ -2833,7 +2833,7 @@ stream_max_retries = 0 runtime_workspace_roots: Some(vec![new_root]), environments: Some(vec![TurnEnvironmentParams { environment_id: "local".to_string(), - cwd: environment_cwd.into(), + cwd: fallback_root.into(), runtime_workspace_roots: None, }]), ..Default::default() @@ -2870,10 +2870,14 @@ stream_max_retries = 0 ); let second_permissions = latest_permissions_instructions(&requests[1]); - assert!(second_permissions.contains(&new_root_text)); + assert!(second_permissions.contains(&fallback_root_text)); assert!( !second_permissions.contains(&old_root_text), - "omitted environment roots should inherit the updated turn-level fallback" + "omitted environment roots should not retain the previous environment roots" + ); + assert!( + !second_permissions.contains(&new_root_text), + "explicit environments should ignore top-level roots" ); Ok(()) From 7e95377bc5a1b9e34e29b3bde77c5bc5daa6c701 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 9 Jul 2026 12:30:43 -0700 Subject: [PATCH 17/29] core: keep session permission profiles symbolic --- codex-rs/core/src/session/session.rs | 25 +++++---- codex-rs/core/src/session/tests.rs | 65 +++-------------------- codex-rs/core/src/session/turn_context.rs | 4 +- 3 files changed, 24 insertions(+), 70 deletions(-) diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 0734f3090a2a..7247d267c4db 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -116,19 +116,19 @@ impl SessionConfiguration { &self.environments.environments } - pub(super) fn workspace_roots(&self) -> Vec { + pub(super) fn primary_workspace_roots(&self) -> Vec { self.environments .environments .first() - .and_then(|environment| { + .map(|environment| { environment .workspace_roots .iter() .map(PathUri::to_abs_path) .collect::>>() - .ok() + .unwrap_or_default() }) - .unwrap_or_else(|| vec![self.cwd().clone()]) + .unwrap_or_default() } pub(crate) fn codex_home(&self) -> &AbsolutePathBuf { @@ -140,10 +140,12 @@ impl SessionConfiguration { } pub(super) fn permission_profile(&self) -> PermissionProfile { - self.permission_profile_state - .permission_profile() - .clone() - .materialize_project_roots_with_workspace_roots(&self.workspace_roots()) + self.permission_profile_state.permission_profile().clone() + } + + fn materialized_permission_profile(&self) -> PermissionProfile { + self.permission_profile() + .materialize_project_roots_with_workspace_roots(&self.primary_workspace_roots()) } pub(super) fn active_permission_profile(&self) -> Option { @@ -171,7 +173,7 @@ impl SessionConfiguration { } pub(super) fn sandbox_policy(&self) -> SandboxPolicy { - let permission_profile = self.permission_profile(); + let permission_profile = self.materialized_permission_profile(); codex_sandboxing::compatibility_sandbox_policy_for_permission_profile( &permission_profile, self.cwd(), @@ -179,7 +181,8 @@ impl SessionConfiguration { } pub(super) fn file_system_sandbox_policy(&self) -> FileSystemSandboxPolicy { - self.permission_profile().file_system_sandbox_policy() + self.materialized_permission_profile() + .file_system_sandbox_policy() } pub(super) fn network_sandbox_policy(&self) -> NetworkSandboxPolicy { @@ -198,7 +201,7 @@ impl SessionConfiguration { permission_profile: self.permission_profile(), active_permission_profile: self.active_permission_profile(), environments: self.environments.clone(), - workspace_roots: self.workspace_roots(), + workspace_roots: self.primary_workspace_roots(), profile_workspace_roots: self.profile_workspace_roots().to_vec(), ephemeral: self.original_config_do_not_use.ephemeral, reasoning_effort: self.collaboration_mode.reasoning_effort(), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 9791813ddeb0..041970b0125a 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -4502,7 +4502,9 @@ async fn session_configuration_apply_preserves_profile_file_system_policy_on_cwd ) .expect("set permission profile"); let expected_file_system_sandbox_policy = file_system_sandbox_policy - .materialize_project_roots_with_workspace_roots(&session_configuration.workspace_roots()); + .materialize_project_roots_with_workspace_roots( + &session_configuration.primary_workspace_roots(), + ); let updated = session_configuration .apply(&SessionSettingsUpdate { @@ -4564,7 +4566,9 @@ async fn session_configuration_apply_permission_profile_preserves_existing_deny_ .expect("permission profile update should succeed"); let mut expected_file_system_policy = requested_file_system_policy - .materialize_project_roots_with_workspace_roots(&session_configuration.workspace_roots()); + .materialize_project_roots_with_workspace_roots( + &session_configuration.primary_workspace_roots(), + ); expected_file_system_policy.glob_scan_max_depth = Some(2); expected_file_system_policy.entries.push(deny_entry); assert_eq!( @@ -4821,59 +4825,6 @@ enabled = false ); } -#[tokio::test] -async fn session_configuration_apply_retargets_legacy_workspace_root_on_cwd_update() { - let mut session_configuration = make_session_configuration_for_tests().await; - let workspace = tempfile::tempdir().expect("create temp dir"); - let original_cwd = workspace.path().join("repo-a").abs(); - let project_root = workspace.path().join("repo-b").abs(); - session_configuration.environments = - TurnEnvironmentSelections::new(original_cwd.clone(), Vec::new()); - let sandbox_policy = SandboxPolicy::WorkspaceWrite { - writable_roots: Vec::new(), - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }; - let file_system_sandbox_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( - &sandbox_policy, - session_configuration.cwd(), - ); - session_configuration - .set_permission_profile_for_tests( - PermissionProfile::from_runtime_permissions_with_enforcement( - SandboxEnforcement::from_legacy_sandbox_policy(&sandbox_policy), - &file_system_sandbox_policy, - NetworkSandboxPolicy::from(&sandbox_policy), - ), - ) - .expect("set permission profile"); - - let updated = session_configuration - .apply(&SessionSettingsUpdate { - environments: Some(TurnEnvironmentSelections::new( - project_root.clone(), - Vec::new(), - )), - ..Default::default() - }) - .expect("cwd-only update should succeed"); - - assert_eq!(updated.workspace_roots(), vec![project_root.clone()]); - assert!( - updated - .file_system_sandbox_policy() - .can_write_path_with_cwd(project_root.as_path(), updated.cwd().as_path()), - "cwd-only update should keep the new cwd writable" - ); - assert!( - !updated - .file_system_sandbox_policy() - .can_write_path_with_cwd(original_cwd.as_path(), updated.cwd().as_path()), - "cwd-only update should not keep the old implicit cwd writable" - ); -} - #[tokio::test] async fn session_configuration_apply_preserves_absolute_cwd_write_root_on_cwd_update() { let mut session_configuration = make_session_configuration_for_tests().await; @@ -5275,7 +5226,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { let per_turn_config = Session::build_per_turn_config( &session_configuration, session_configuration.cwd().clone(), - session_configuration.workspace_roots(), + session_configuration.primary_workspace_roots(), ); let model_info = construct_model_info_offline_for_tests( session_configuration.collaboration_mode.model(), @@ -7409,7 +7360,7 @@ where let per_turn_config = Session::build_per_turn_config( &session_configuration, session_configuration.cwd().clone(), - session_configuration.workspace_roots(), + session_configuration.primary_workspace_roots(), ); let model_info = construct_model_info_offline_for_tests( session_configuration.collaboration_mode.model(), diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index bf50a5b20692..65157cde83e9 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -471,7 +471,7 @@ impl Session { pub(crate) fn build_effective_session_config( session_configuration: &SessionConfiguration, ) -> Config { - let workspace_roots = session_configuration.workspace_roots(); + let workspace_roots = session_configuration.primary_workspace_roots(); let mut config = Self::build_per_turn_config( session_configuration, session_configuration.cwd().clone(), @@ -718,7 +718,7 @@ impl Session { .transpose() .ok() .flatten() - .unwrap_or_else(|| session_configuration.workspace_roots()); + .unwrap_or_default(); let per_turn_config = Self::build_per_turn_config(&session_configuration, cwd.clone(), workspace_roots); { From 884c235b7787b1f14f39c0edc34eb31b91f24c7e Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 9 Jul 2026 12:43:32 -0700 Subject: [PATCH 18/29] app-server: preserve legacy workspace roots coverage --- .../app-server/tests/suite/v2/thread_start.rs | 24 ++++++++++++- .../app-server/tests/suite/v2/turn_start.rs | 34 ++++--------------- 2 files changed, 30 insertions(+), 28 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 259223cae6c5..0ee560ec5f20 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -530,7 +530,7 @@ async fn thread_start_accepts_absolute_runtime_workspace_roots() -> Result<()> { timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp - .send_thread_start_request_with_auto_env(ThreadStartParams { + .send_thread_start_request(ThreadStartParams { cwd: Some(cwd.to_string_lossy().to_string()), runtime_workspace_roots: Some(vec![extra_root.abs()]), ..Default::default() @@ -551,6 +551,28 @@ async fn thread_start_accepts_absolute_runtime_workspace_roots() -> Result<()> { assert_eq!(response_cwd, cwd.abs()); assert_eq!(runtime_workspace_roots, vec![extra_root.abs()]); + let environment_root = cwd.join("environment-root"); + std::fs::create_dir_all(&environment_root)?; + let mut environment = mcp.auto_env_params()?; + environment.runtime_workspace_roots = Some(vec![environment_root.abs().into()]); + let req_id = mcp + .send_thread_start_request(ThreadStartParams { + runtime_workspace_roots: Some(vec![extra_root.abs()]), + environments: Some(vec![environment]), + ..Default::default() + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(req_id)), + ) + .await??; + let ThreadStartResponse { + runtime_workspace_roots, + .. + } = to_response::(resp)?; + assert_eq!(runtime_workspace_roots, vec![environment_root.abs()]); + Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 5b90bae43187..517074793ec8 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -2712,7 +2712,8 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { #[cfg(unix)] #[tokio::test] -async fn turn_environment_workspace_roots_override_and_default_to_cwd() -> Result<()> { +async fn turn_start_permission_profile_rebinds_runtime_workspace_roots_between_turns() -> Result<()> +{ skip_if_no_network!(Ok(())); let tmp = TempDir::new()?; @@ -2720,19 +2721,12 @@ async fn turn_environment_workspace_roots_override_and_default_to_cwd() -> Resul std::fs::create_dir(&codex_home)?; let old_root = tmp.path().join("old-root"); let new_root = tmp.path().join("new-root"); - let fallback_root = tmp.path().join("fallback-root"); std::fs::create_dir(&old_root)?; std::fs::create_dir(&new_root)?; - std::fs::create_dir(&fallback_root)?; let old_root_text = old_root.to_string_lossy().into_owned(); let new_root_text = new_root.to_string_lossy().into_owned(); - let fallback_root_text = fallback_root.to_string_lossy().into_owned(); let old_root = codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(old_root)?; let new_root = codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(new_root)?; - let fallback_root = - codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(fallback_root)?; - let environment_cwd = - codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(tmp.path())?; let server = responses::start_mock_server().await; let response_mock = responses::mount_sse_sequence( @@ -2801,12 +2795,7 @@ stream_max_retries = 0 text: "select dev profile".to_string(), text_elements: Vec::new(), }], - runtime_workspace_roots: Some(vec![fallback_root.clone()]), - environments: Some(vec![TurnEnvironmentParams { - environment_id: "local".to_string(), - cwd: environment_cwd.clone().into(), - runtime_workspace_roots: Some(vec![old_root.into()]), - }]), + runtime_workspace_roots: Some(vec![old_root]), permissions: Some("dev".to_string()), ..Default::default() }) @@ -2831,11 +2820,6 @@ stream_max_retries = 0 text_elements: Vec::new(), }], runtime_workspace_roots: Some(vec![new_root]), - environments: Some(vec![TurnEnvironmentParams { - environment_id: "local".to_string(), - cwd: fallback_root.into(), - runtime_workspace_roots: None, - }]), ..Default::default() }) .await?; @@ -2865,19 +2849,15 @@ stream_max_retries = 0 let first_permissions = latest_permissions_instructions(&requests[0]); assert!(first_permissions.contains(&old_root_text)); assert!( - !first_permissions.contains(&fallback_root_text), - "environment roots should override the turn-level fallback" + !first_permissions.contains(&new_root_text), + "first turn should materialize the initial runtime workspace root" ); let second_permissions = latest_permissions_instructions(&requests[1]); - assert!(second_permissions.contains(&fallback_root_text)); + assert!(second_permissions.contains(&new_root_text)); assert!( !second_permissions.contains(&old_root_text), - "omitted environment roots should not retain the previous environment roots" - ); - assert!( - !second_permissions.contains(&new_root_text), - "explicit environments should ignore top-level roots" + "second turn should rebind :workspace_roots to the updated runtime workspace root" ); Ok(()) From 8a2621d165b4002f5d0cdd9abbe23a074cb570f9 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 9 Jul 2026 14:24:04 -0700 Subject: [PATCH 19/29] app-server: keep permission profile roots symbolic --- .../src/request_processors/turn_processor.rs | 21 ------------------- codex-rs/core/tests/common/test_codex.rs | 1 + 2 files changed, 1 insertion(+), 21 deletions(-) diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index f56c05bf8440..9416d501321c 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -6,7 +6,6 @@ use codex_protocol::protocol::AdditionalContextKind as CoreAdditionalContextKind use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; -use codex_utils_path_uri::PathUri; use crate::image_url::REMOTE_IMAGE_URL_ERROR; use crate::image_url::is_remote_image_url; @@ -653,7 +652,6 @@ impl TurnRequestProcessor { || collaboration_mode.is_some() || personality.is_some(); - let runtime_workspace_roots = runtime_workspace_roots_request; let approval_policy = approval_policy.map(codex_app_server_protocol::AskForApproval::to_core); let approvals_reviewer = @@ -666,29 +664,10 @@ impl TurnRequestProcessor { "{method} permission selection missing thread snapshot" ))); }; - let workspace_roots = environments - .as_ref() - .map(|environments| { - environments - .environments - .first() - .map(|environment| { - environment - .workspace_roots - .iter() - .map(PathUri::to_abs_path) - .collect::>>() - .unwrap_or_default() - }) - .unwrap_or_default() - }) - .or_else(|| runtime_workspace_roots.clone()) - .unwrap_or_else(|| snapshot.workspace_roots.clone()); let overrides = ConfigOverrides { cwd: environments .as_ref() .map(|environments| environments.legacy_fallback_cwd.to_path_buf()), - workspace_roots: Some(workspace_roots), default_permissions: Some(permissions), codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 65eadaca95be..71938f9572e0 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -141,6 +141,7 @@ impl TestEnv { Some(_) => TurnEnvironmentSelection { environment_id: codex_exec_server::REMOTE_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_abs_path(&cwd), + workspace_roots: vec![PathUri::from_abs_path(&cwd)], }, None => local(cwd.clone()), }; From fc49471ee225a285ec8d33188769db7aaddcfd91 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 9 Jul 2026 16:30:46 -0700 Subject: [PATCH 20/29] app-server: preserve environment root compatibility --- .../request_processors/thread_lifecycle.rs | 3 +- .../request_processors/thread_processor.rs | 25 +++++---------- .../src/request_processors/thread_summary.rs | 22 ++++--------- .../app-server/tests/suite/v2/thread_start.rs | 31 ++++++++++++++++--- codex-rs/core/src/session/session.rs | 2 +- 5 files changed, 43 insertions(+), 40 deletions(-) diff --git a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs index 0fc919421082..25346de7ee1b 100644 --- a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs +++ b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs @@ -619,6 +619,7 @@ pub(super) async fn handle_pending_thread_resume_request( } let config_snapshot = pending.config_snapshot; + let sandbox = config_snapshot.sandbox_policy().into(); let cwd = config_snapshot.cwd().clone(); let ThreadConfigSnapshot { model, @@ -626,7 +627,6 @@ pub(super) async fn handle_pending_thread_resume_request( service_tier, approval_policy, approvals_reviewer, - permission_profile, active_permission_profile, workspace_roots, reasoning_effort, @@ -634,7 +634,6 @@ pub(super) async fn handle_pending_thread_resume_request( .. } = config_snapshot; let instruction_sources = pending.instruction_sources; - let sandbox = thread_response_sandbox_policy(&permission_profile, cwd.as_path()); let active_permission_profile = thread_response_active_permission_profile(active_permission_profile); let session_id = conversation.session_configured().session_id.to_string(); diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 93e467b07961..f361dd608780 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -1105,6 +1105,10 @@ impl ThreadRequestProcessor { .load_with_overrides(config_overrides.clone(), typesafe_overrides.clone()) .await .map_err(|err| config_load_error(&err))?; + let environment_selections = resolve_turn_environment_selections( + listener_task_context.thread_manager.as_ref(), + environments, + )?; // The user may have requested WorkspaceWrite or DangerFullAccess via // the command line, though in the process of deriving the Config, it @@ -1186,11 +1190,7 @@ impl ThreadRequestProcessor { } } - let environments = resolve_turn_environment_selections( - listener_task_context.thread_manager.as_ref(), - environments, - )? - .unwrap_or_else(|| { + let environments = environment_selections.unwrap_or_else(|| { listener_task_context .thread_manager .default_environment_selections(&config.cwd, &config.workspace_roots) @@ -1318,10 +1318,7 @@ impl ThreadRequestProcessor { /*has_in_progress_turn*/ false, ); - let sandbox = thread_response_sandbox_policy( - &config_snapshot.permission_profile, - config_snapshot.cwd().as_path(), - ); + let sandbox = config_snapshot.sandbox_policy().into(); let cwd = config_snapshot.cwd().clone(); let active_permission_profile = thread_response_active_permission_profile(config_snapshot.active_permission_profile); @@ -2895,10 +2892,7 @@ impl ThreadRequestProcessor { /*has_live_in_progress_turn*/ false, ); let config_snapshot = codex_thread.config_snapshot().await; - let sandbox = thread_response_sandbox_policy( - &config_snapshot.permission_profile, - config_snapshot.cwd().as_path(), - ); + let sandbox = config_snapshot.sandbox_policy().into(); let active_permission_profile = thread_response_active_permission_profile( config_snapshot.active_permission_profile, ); @@ -3657,10 +3651,7 @@ impl ThreadRequestProcessor { /*has_in_progress_turn*/ false, ); let config_snapshot = forked_thread.config_snapshot().await; - let sandbox = thread_response_sandbox_policy( - &config_snapshot.permission_profile, - config_snapshot.cwd().as_path(), - ); + let sandbox = config_snapshot.sandbox_policy().into(); let active_permission_profile = thread_response_active_permission_profile(config_snapshot.active_permission_profile); let thread_originator = config_snapshot.originator.clone(); diff --git a/codex-rs/app-server/src/request_processors/thread_summary.rs b/codex-rs/app-server/src/request_processors/thread_summary.rs index 139da67bcacb..caf662d6426a 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary.rs @@ -172,17 +172,6 @@ pub(crate) fn thread_response_active_permission_profile( active_permission_profile.map(Into::into) } -pub(crate) fn thread_response_sandbox_policy( - permission_profile: &codex_protocol::models::PermissionProfile, - cwd: &Path, -) -> codex_app_server_protocol::SandboxPolicy { - let sandbox_policy = codex_sandboxing::compatibility_sandbox_policy_for_permission_profile( - permission_profile, - cwd, - ); - sandbox_policy.into() -} - pub(crate) fn thread_settings_from_config_snapshot( config_snapshot: &ThreadConfigSnapshot, ) -> ThreadSettings { @@ -190,10 +179,7 @@ pub(crate) fn thread_settings_from_config_snapshot( cwd: config_snapshot.cwd().clone(), approval_policy: config_snapshot.approval_policy.into(), approvals_reviewer: config_snapshot.approvals_reviewer.into(), - sandbox_policy: thread_response_sandbox_policy( - &config_snapshot.permission_profile, - config_snapshot.cwd().as_path(), - ), + sandbox_policy: config_snapshot.sandbox_policy().into(), active_permission_profile: thread_response_active_permission_profile( config_snapshot.active_permission_profile.clone(), ), @@ -225,7 +211,11 @@ pub(crate) fn thread_settings_from_core_snapshot( personality, collaboration_mode, } = snapshot; - let sandbox_policy = thread_response_sandbox_policy(&permission_profile, cwd.as_path()); + let sandbox_policy = codex_sandboxing::compatibility_sandbox_policy_for_permission_profile( + &permission_profile, + cwd.as_path(), + ) + .into(); ThreadSettings { sandbox_policy, cwd, diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 0ee560ec5f20..95407f8a683e 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -16,6 +16,7 @@ use codex_app_server_protocol::McpServerStartupState; use codex_app_server_protocol::McpServerStatusUpdatedNotification; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxMode; +use codex_app_server_protocol::SandboxPolicy; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::TextPosition; use codex_app_server_protocol::TextRange; @@ -533,6 +534,7 @@ async fn thread_start_accepts_absolute_runtime_workspace_roots() -> Result<()> { .send_thread_start_request(ThreadStartParams { cwd: Some(cwd.to_string_lossy().to_string()), runtime_workspace_roots: Some(vec![extra_root.abs()]), + sandbox: Some(SandboxMode::WorkspaceWrite), ..Default::default() }) .await?; @@ -545,11 +547,19 @@ async fn thread_start_accepts_absolute_runtime_workspace_roots() -> Result<()> { let ThreadStartResponse { cwd: response_cwd, runtime_workspace_roots, + sandbox, .. } = to_response::(resp)?; assert_eq!(response_cwd, cwd.abs()); assert_eq!(runtime_workspace_roots, vec![extra_root.abs()]); + let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = sandbox else { + panic!("expected workspace-write sandbox"); + }; + assert!( + writable_roots.contains(&extra_root.abs().canonicalize()?), + "legacy sandbox projection should include the runtime workspace root" + ); let environment_root = cwd.join("environment-root"); std::fs::create_dir_all(&environment_root)?; @@ -559,6 +569,7 @@ async fn thread_start_accepts_absolute_runtime_workspace_roots() -> Result<()> { .send_thread_start_request(ThreadStartParams { runtime_workspace_roots: Some(vec![extra_root.abs()]), environments: Some(vec![environment]), + sandbox: Some(SandboxMode::WorkspaceWrite), ..Default::default() }) .await?; @@ -569,9 +580,17 @@ async fn thread_start_accepts_absolute_runtime_workspace_roots() -> Result<()> { .await??; let ThreadStartResponse { runtime_workspace_roots, + sandbox, .. } = to_response::(resp)?; assert_eq!(runtime_workspace_roots, vec![environment_root.abs()]); + let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = sandbox else { + panic!("expected workspace-write sandbox"); + }; + assert!( + writable_roots.contains(&environment_root.abs().canonicalize()?), + "legacy sandbox projection should include the environment workspace root" + ); Ok(()) } @@ -626,6 +645,10 @@ async fn thread_start_rejects_unknown_environment_as_invalid_request() -> Result let codex_home = TempDir::new()?; create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; + let config_path = codex_home.path().join("config.toml"); + let config_before = std::fs::read_to_string(&config_path)?; + let workspace = TempDir::new()?; + let workspace = workspace.path().to_path_buf().abs(); let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) @@ -635,12 +658,11 @@ async fn thread_start_rejects_unknown_environment_as_invalid_request() -> Result let request_id = mcp .send_thread_start_request(ThreadStartParams { + cwd: Some(workspace.to_string_lossy().into_owned()), + sandbox: Some(SandboxMode::WorkspaceWrite), environments: Some(vec![TurnEnvironmentParams { environment_id: "missing".to_string(), - cwd: codex_utils_absolute_path::AbsolutePathBuf::try_from( - codex_home.path().to_path_buf(), - )? - .into(), + cwd: workspace.into(), runtime_workspace_roots: None, }]), ..Default::default() @@ -656,6 +678,7 @@ async fn thread_start_rejects_unknown_environment_as_invalid_request() -> Result assert_eq!(error.id, RequestId::Integer(request_id)); assert_eq!(error.error.code, INVALID_REQUEST_ERROR_CODE); assert_eq!(error.error.message, "unknown turn environment id `missing`"); + assert_eq!(std::fs::read_to_string(config_path)?, config_before); Ok(()) } diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 7247d267c4db..4453e908cdf2 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -198,7 +198,7 @@ impl SessionConfiguration { service_tier: self.service_tier.clone(), approval_policy: self.approval_policy.value(), approvals_reviewer: self.approvals_reviewer, - permission_profile: self.permission_profile(), + permission_profile: self.materialized_permission_profile(), active_permission_profile: self.active_permission_profile(), environments: self.environments.clone(), workspace_roots: self.primary_workspace_roots(), From 872e6881839dad310cf56d1ed5548fb0c0e6fcb4 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 9 Jul 2026 18:41:42 -0700 Subject: [PATCH 21/29] app-server: preserve legacy cwd root updates --- .../src/request_processors/turn_processor.rs | 21 +++++++++- .../tests/suite/v2/selected_environment.rs | 16 ++++---- .../tests/suite/v2/thread_settings_update.rs | 22 +++++++++- .../app-server/tests/suite/v2/thread_start.rs | 41 ++++++++++++------- .../core/src/tools/handlers/view_image.rs | 5 ++- .../remote_env_windows_test.rs | 11 +++-- codex-rs/core/tests/suite/agents_md.rs | 4 +- codex-rs/core/tests/suite/apply_patch_cli.rs | 2 +- codex-rs/core/tests/suite/network_approval.rs | 2 +- codex-rs/core/tests/suite/remote_env.rs | 12 +++--- codex-rs/core/tests/suite/view_image.rs | 4 +- 11 files changed, 99 insertions(+), 41 deletions(-) diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index 9416d501321c..08c211bbeb6c 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -587,9 +587,28 @@ impl TurnRequestProcessor { return None; } let snapshot = thread.config_snapshot().await; + let current_cwd = snapshot.cwd().clone(); let legacy_fallback_cwd = cwd.unwrap_or_else(|| snapshot.cwd().clone()); let environment_selections = environment_selections.unwrap_or_else(|| { - let workspace_roots = workspace_roots.unwrap_or(snapshot.workspace_roots); + let workspace_roots = workspace_roots.unwrap_or_else(|| { + let workspace_roots = snapshot.workspace_roots; + if legacy_fallback_cwd != current_cwd && workspace_roots.contains(¤t_cwd) { + let mut retargeted_workspace_roots = Vec::with_capacity(workspace_roots.len()); + for root in workspace_roots { + let root = if root == current_cwd { + legacy_fallback_cwd.clone() + } else { + root + }; + if !retargeted_workspace_roots.contains(&root) { + retargeted_workspace_roots.push(root); + } + } + retargeted_workspace_roots + } else { + workspace_roots + } + }); self.thread_manager .default_environment_selections(&legacy_fallback_cwd, &workspace_roots) }); diff --git a/codex-rs/app-server/tests/suite/v2/selected_environment.rs b/codex-rs/app-server/tests/suite/v2/selected_environment.rs index 6dd90eb51a62..e30f7f996fef 100644 --- a/codex-rs/app-server/tests/suite/v2/selected_environment.rs +++ b/codex-rs/app-server/tests/suite/v2/selected_environment.rs @@ -50,6 +50,13 @@ async fn thread_start_reports_selected_environment_metadata() -> Result<()> { .build() .await?; timeout(DEFAULT_READ_TIMEOUT, app_server.initialize()).await??; + let selected_workspace_roots = app_server + .auto_env()? + .selection() + .workspace_roots + .iter() + .filter_map(|root| root.to_abs_path().ok()) + .collect::>(); let request_id = app_server .send_thread_start_request_with_auto_env(ThreadStartParams::default()) @@ -67,17 +74,12 @@ async fn thread_start_reports_selected_environment_metadata() -> Result<()> { } = to_response(response)?; let host_cwd = codex_home.path().to_path_buf().abs().canonicalize()?; let cwd = cwd.canonicalize()?; - let runtime_workspace_roots = runtime_workspace_roots - .into_iter() - .map(|root| root.canonicalize()) - .collect::>>()?; assert_eq!( (cwd, runtime_workspace_roots, active_permission_profile), ( // TODO(anp): Return the selected environment's native cwd from thread/start. - host_cwd.clone(), - // TODO(anp): Derive runtime workspace roots from the selected remote environment. - vec![host_cwd], + host_cwd, + selected_workspace_roots, // TODO(anp): Report the implicit built-in permission profile instead of None. None, ) diff --git a/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs b/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs index bf3d6261b82a..ed6196d7aa27 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs @@ -107,6 +107,7 @@ async fn thread_settings_update_cwd_retargets_default_environment() -> Result<() ]); let response_mock = responses::mount_sse_once(&server, body).await; let codex_home = TempDir::new()?; + let initial_workspace = TempDir::new()?; let workspace = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; @@ -115,7 +116,19 @@ async fn thread_settings_update_cwd_retargets_default_environment() -> Result<() .build() .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; - let thread = start_thread(&mut mcp).await?.thread; + let request_id = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(initial_workspace.path().to_string_lossy().into_owned()), + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let thread = to_response::(response)?.thread; send_thread_settings_update( &mut mcp, @@ -149,6 +162,13 @@ async fn thread_settings_update_cwd_retargets_default_environment() -> Result<() )), "default environment should use the updated cwd: {environment_context}" ); + assert!( + environment_context.contains(&format!( + "{}", + workspace.path().to_string_lossy() + )), + "default workspace root should use the updated cwd: {environment_context}" + ); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 95407f8a683e..6affebdd294f 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -16,6 +16,7 @@ use codex_app_server_protocol::McpServerStartupState; use codex_app_server_protocol::McpServerStatusUpdatedNotification; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxMode; +#[cfg(not(windows))] use codex_app_server_protocol::SandboxPolicy; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::TextPosition; @@ -553,13 +554,18 @@ async fn thread_start_accepts_absolute_runtime_workspace_roots() -> Result<()> { assert_eq!(response_cwd, cwd.abs()); assert_eq!(runtime_workspace_roots, vec![extra_root.abs()]); - let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = sandbox else { - panic!("expected workspace-write sandbox"); - }; - assert!( - writable_roots.contains(&extra_root.abs().canonicalize()?), - "legacy sandbox projection should include the runtime workspace root" - ); + #[cfg(windows)] + let _ = sandbox; + #[cfg(not(windows))] + { + let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = sandbox else { + panic!("expected workspace-write sandbox"); + }; + assert!( + writable_roots.contains(&extra_root.abs().canonicalize()?), + "legacy sandbox projection should include the runtime workspace root" + ); + } let environment_root = cwd.join("environment-root"); std::fs::create_dir_all(&environment_root)?; @@ -584,13 +590,18 @@ async fn thread_start_accepts_absolute_runtime_workspace_roots() -> Result<()> { .. } = to_response::(resp)?; assert_eq!(runtime_workspace_roots, vec![environment_root.abs()]); - let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = sandbox else { - panic!("expected workspace-write sandbox"); - }; - assert!( - writable_roots.contains(&environment_root.abs().canonicalize()?), - "legacy sandbox projection should include the environment workspace root" - ); + #[cfg(windows)] + let _ = sandbox; + #[cfg(not(windows))] + { + let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = sandbox else { + panic!("expected workspace-write sandbox"); + }; + assert!( + writable_roots.contains(&environment_root.abs().canonicalize()?), + "legacy sandbox projection should include the environment workspace root" + ); + } Ok(()) } @@ -615,7 +626,7 @@ async fn thread_start_excludes_profile_workspace_roots_from_runtime_workspace_ro timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp - .send_thread_start_request_with_auto_env(ThreadStartParams { + .send_thread_start_request(ThreadStartParams { cwd: Some(cwd.path().to_string_lossy().to_string()), ..Default::default() }) diff --git a/codex-rs/core/src/tools/handlers/view_image.rs b/codex-rs/core/src/tools/handlers/view_image.rs index 92b7d0023b78..7ee6f53ec5ca 100644 --- a/codex-rs/core/src/tools/handlers/view_image.rs +++ b/codex-rs/core/src/tools/handlers/view_image.rs @@ -381,7 +381,10 @@ mod tests { replace_primary_environment_cwd(&mut turn, image_cwd.clone()); let image_path = image_cwd.join("image.png"); std::fs::write(image_path.as_path(), b"not a real image").expect("write test image"); - turn.permission_profile = PermissionProfile::Disabled; + Arc::make_mut(&mut turn.config) + .permissions + .set_permission_profile(PermissionProfile::Disabled) + .expect("set permission profile"); let turn = Arc::new(turn); let result = ViewImageHandler::default() 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 9f5b4d2f35aa..995d1513efdb 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 @@ -105,10 +105,13 @@ async fn windows_exec_server_runs_with_native_shell_and_cwd() -> Result<()> { turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path()); let environments = TurnEnvironmentSelections::new( test.config.cwd.clone(), - vec![TurnEnvironmentSelection { - environment_id: REMOTE_ENVIRONMENT_ID.to_string(), - cwd: PathUri::parse("file:///C:/codex-home")?, - workspace_roots: Vec::new(), + vec![{ + let cwd = PathUri::parse("file:///C:/codex-home")?; + TurnEnvironmentSelection { + environment_id: REMOTE_ENVIRONMENT_ID.to_string(), + cwd: cwd.clone(), + workspace_roots: vec![cwd], + } }], ); diff --git a/codex-rs/core/tests/suite/agents_md.rs b/codex-rs/core/tests/suite/agents_md.rs index 3d2e88d8e4c8..65c377e32134 100644 --- a/codex-rs/core/tests/suite/agents_md.rs +++ b/codex-rs/core/tests/suite/agents_md.rs @@ -705,12 +705,12 @@ async fn multi_environment_thread_loads_every_project_and_keeps_creation_snapsho TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_abs_path(&test.config.cwd), - workspace_roots: Vec::new(), + workspace_roots: vec![PathUri::from_abs_path(&test.config.cwd)], }, TurnEnvironmentSelection { environment_id: LOCAL_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_host_native_path(local_root.path())?, - workspace_roots: Vec::new(), + workspace_roots: vec![PathUri::from_host_native_path(local_root.path())?], }, ], thread_extension_init: Default::default(), diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index aa134fb1eaf9..51ea0421ea87 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -1653,7 +1653,7 @@ async fn apply_patch_turn_diff_tracks_local_and_remote_environment_paths() -> Re TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_abs_path(&shared_cwd), - workspace_roots: Vec::new(), + workspace_roots: vec![PathUri::from_abs_path(&shared_cwd)], }, ]; test.codex diff --git a/codex-rs/core/tests/suite/network_approval.rs b/codex-rs/core/tests/suite/network_approval.rs index d3da765289bc..0466225b732d 100644 --- a/codex-rs/core/tests/suite/network_approval.rs +++ b/codex-rs/core/tests/suite/network_approval.rs @@ -261,7 +261,7 @@ async fn approved_network_host_for_one_environment_still_prompts_in_another() -> TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_abs_path(&remote_cwd), - workspace_roots: Vec::new(), + workspace_roots: vec![PathUri::from_abs_path(&remote_cwd)], }, ]; diff --git a/codex-rs/core/tests/suite/remote_env.rs b/codex-rs/core/tests/suite/remote_env.rs index e93ba8747870..4c98f5d563fa 100644 --- a/codex-rs/core/tests/suite/remote_env.rs +++ b/codex-rs/core/tests/suite/remote_env.rs @@ -302,7 +302,7 @@ async fn explicit_remote_shell_runs_in_remote_cwd() -> Result<()> { Some(vec![TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_abs_path(&test.config.cwd), - workspace_roots: Vec::new(), + workspace_roots: vec![PathUri::from_abs_path(&test.config.cwd)], }]), ) .await?; @@ -1135,7 +1135,7 @@ async fn exec_command_routes_to_selected_remote_environment() -> Result<()> { let remote_selection = TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_abs_path(&remote_cwd), - workspace_roots: Vec::new(), + workspace_roots: vec![PathUri::from_abs_path(&remote_cwd)], }; let multi_env_output = exec_command_routing_output( &test, @@ -1292,7 +1292,7 @@ async fn remote_request_permissions_grant_unblocks_later_remote_exec() -> Result TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_abs_path(&remote_cwd), - workspace_roots: Vec::new(), + workspace_roots: vec![PathUri::from_abs_path(&remote_cwd)], }, ], AskForApproval::OnRequest, @@ -1434,7 +1434,7 @@ async fn apply_patch_freeform_routes_to_selected_remote_environment() -> Result< TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_abs_path(&remote_cwd), - workspace_roots: Vec::new(), + workspace_roots: vec![PathUri::from_abs_path(&remote_cwd)], }, ]), ) @@ -1518,7 +1518,7 @@ async fn apply_patch_approvals_are_remembered_per_environment() -> Result<()> { TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_abs_path(&remote_cwd), - workspace_roots: Vec::new(), + workspace_roots: vec![PathUri::from_abs_path(&remote_cwd)], }, ]; let local_patch = format!( @@ -1720,7 +1720,7 @@ async fn apply_patch_intercepted_exec_command_routes_to_selected_remote_environm TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_abs_path(&remote_cwd), - workspace_roots: Vec::new(), + workspace_roots: vec![PathUri::from_abs_path(&remote_cwd)], }, ]), ) diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 4a7d85231d87..04f8c8b1fdb6 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -589,8 +589,8 @@ async fn view_image_routes_to_selected_remote_environment() -> anyhow::Result<() let absolute_image_path = image_path_uri.inferred_native_path_string(); let remote_selection = TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), - cwd: remote_cwd_uri, - workspace_roots: Vec::new(), + cwd: remote_cwd_uri.clone(), + workspace_roots: vec![remote_cwd_uri], }; let relative_call_id = "call-view-image-relative-multi-env"; let absolute_call_id = "call-view-image-absolute-multi-env"; From 4bc7a1042cd3a11826b95c5d5205d15bd36ebbcd Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 9 Jul 2026 19:29:06 -0700 Subject: [PATCH 22/29] app-server: simplify legacy workspace root updates --- .../src/request_processors/turn_processor.rs | 56 ++++++++++++------- 1 file changed, 35 insertions(+), 21 deletions(-) diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index 08c211bbeb6c..0ee424ef87e6 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -586,32 +586,46 @@ impl TurnRequestProcessor { if cwd.is_none() && workspace_roots.is_none() && environment_selections.is_none() { return None; } + + // Explicit environment selections own their roots and pass through unchanged. Top-level + // `runtimeWorkspaceRoots` is only a compatibility input for default environments. + if let Some(environment_selections) = environment_selections { + let legacy_fallback_cwd = match cwd { + Some(cwd) => cwd, + None => thread.config_snapshot().await.cwd().clone(), + }; + return Some(TurnEnvironmentSelections::new( + legacy_fallback_cwd, + environment_selections, + )); + } + let snapshot = thread.config_snapshot().await; let current_cwd = snapshot.cwd().clone(); let legacy_fallback_cwd = cwd.unwrap_or_else(|| snapshot.cwd().clone()); - let environment_selections = environment_selections.unwrap_or_else(|| { - let workspace_roots = workspace_roots.unwrap_or_else(|| { - let workspace_roots = snapshot.workspace_roots; - if legacy_fallback_cwd != current_cwd && workspace_roots.contains(¤t_cwd) { - let mut retargeted_workspace_roots = Vec::with_capacity(workspace_roots.len()); - for root in workspace_roots { - let root = if root == current_cwd { - legacy_fallback_cwd.clone() - } else { - root - }; - if !retargeted_workspace_roots.contains(&root) { - retargeted_workspace_roots.push(root); - } + let workspace_roots = match workspace_roots { + Some(workspace_roots) => workspace_roots, + None => { + // Match the pre-environment partial-update behavior: a cwd-only update retargets + // the old cwd root while preserving any additional roots. Deduplicate because the + // new cwd may already be present as an additional root. + let mut retargeted_workspace_roots = Vec::new(); + for root in snapshot.workspace_roots { + let root = if root == current_cwd { + legacy_fallback_cwd.clone() + } else { + root + }; + if !retargeted_workspace_roots.contains(&root) { + retargeted_workspace_roots.push(root); } - retargeted_workspace_roots - } else { - workspace_roots } - }); - self.thread_manager - .default_environment_selections(&legacy_fallback_cwd, &workspace_roots) - }); + retargeted_workspace_roots + } + }; + let environment_selections = self + .thread_manager + .default_environment_selections(&legacy_fallback_cwd, &workspace_roots); Some(TurnEnvironmentSelections::new( legacy_fallback_cwd, environment_selections, From 2020f5755e1fa75b1a3e40712a4725cbf1ab470b Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 9 Jul 2026 20:08:27 -0700 Subject: [PATCH 23/29] app-server: trim environment root plumbing --- .../src/request_processors/thread_processor.rs | 9 +++------ .../src/request_processors/turn_processor.rs | 10 ++-------- codex-rs/core/src/session/turn_context.rs | 17 ++++++----------- 3 files changed, 11 insertions(+), 25 deletions(-) diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index f361dd608780..29c1a9062df1 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -969,6 +969,8 @@ impl ThreadRequestProcessor { )); } let runtime_workspace_roots = runtime_workspace_roots.map(resolve_runtime_workspace_roots); + let environments = + resolve_turn_environment_selections(self.thread_manager.as_ref(), environments)?; let mut typesafe_overrides = self.build_thread_config_overrides( model, model_provider, @@ -1092,7 +1094,7 @@ impl ThreadRequestProcessor { history_mode: Option, session_start_source: Option, thread_source: Option, - environments: Option>, + environment_selections: Option>, service_name: Option, allow_provider_model_fallback: bool, experimental_raw_events: bool, @@ -1105,11 +1107,6 @@ impl ThreadRequestProcessor { .load_with_overrides(config_overrides.clone(), typesafe_overrides.clone()) .await .map_err(|err| config_load_error(&err))?; - let environment_selections = resolve_turn_environment_selections( - listener_task_context.thread_manager.as_ref(), - environments, - )?; - // The user may have requested WorkspaceWrite or DangerFullAccess via // the command line, though in the process of deriving the Config, it // could be downgraded to ReadOnly (perhaps there is no sandbox diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index 0ee424ef87e6..66e819aa3876 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -107,7 +107,6 @@ fn map_additional_context( struct ThreadSettingsBuildParams { method: &'static str, environments: Option, - runtime_workspace_roots: Option>, approval_policy: Option, approvals_reviewer: Option, sandbox_policy: Option, @@ -501,7 +500,7 @@ impl TurnRequestProcessor { .build_environment_override( thread.as_ref(), cwd, - runtime_workspace_roots.clone(), + runtime_workspace_roots, environment_selections, ) .await; @@ -511,7 +510,6 @@ impl TurnRequestProcessor { ThreadSettingsBuildParams { method: "turn/start", environments, - runtime_workspace_roots, approval_policy: params.approval_policy, approvals_reviewer: params.approvals_reviewer, sandbox_policy: params.sandbox_policy, @@ -602,7 +600,7 @@ impl TurnRequestProcessor { let snapshot = thread.config_snapshot().await; let current_cwd = snapshot.cwd().clone(); - let legacy_fallback_cwd = cwd.unwrap_or_else(|| snapshot.cwd().clone()); + let legacy_fallback_cwd = cwd.unwrap_or_else(|| current_cwd.clone()); let workspace_roots = match workspace_roots { Some(workspace_roots) => workspace_roots, None => { @@ -640,7 +638,6 @@ impl TurnRequestProcessor { let ThreadSettingsBuildParams { method, environments, - runtime_workspace_roots, approval_policy, approvals_reviewer, sandbox_policy, @@ -661,7 +658,6 @@ impl TurnRequestProcessor { let collaboration_mode = collaboration_mode.map(|mode| self.normalize_collaboration_mode(mode)); - let runtime_workspace_roots_request = runtime_workspace_roots; let has_environment_override = environments.is_some(); // `thread/settings/update` only acknowledges that the update was queued. // Clients that send dependent partial updates should wait for @@ -673,7 +669,6 @@ impl TurnRequestProcessor { }; let has_any_overrides = has_environment_override - || runtime_workspace_roots_request.is_some() || approval_policy.is_some() || approvals_reviewer.is_some() || sandbox_policy.is_some() @@ -798,7 +793,6 @@ impl TurnRequestProcessor { ThreadSettingsBuildParams { method: "thread/settings/update", environments, - runtime_workspace_roots: None, approval_policy: params.approval_policy, approvals_reviewer: params.approvals_reviewer, sandbox_policy: params.sandbox_policy, diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 65157cde83e9..fd1d2ea2fd7d 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -475,12 +475,10 @@ impl Session { let mut config = Self::build_per_turn_config( session_configuration, session_configuration.cwd().clone(), - workspace_roots.clone(), + workspace_roots, ); config.model = Some(session_configuration.collaboration_mode.model().to_string()); config.permissions.approval_policy = session_configuration.approval_policy.clone(); - config.workspace_roots = workspace_roots.clone(); - config.permissions.set_workspace_roots(workspace_roots); config } @@ -699,7 +697,7 @@ impl Session { multi_agent_runtime: TurnMultiAgentRuntime, ) -> Arc { let turn_environments = self.services.turn_environments.snapshot().await; - let primary_turn_environment = turn_environments.primary().cloned(); + let primary_turn_environment = turn_environments.primary(); // TODO(anp): Migrate per-turn config and legacy TurnContext cwd consumers to PathUri so // a foreign primary environment does not fall back to the session's host cwd. let cwd = primary_turn_environment @@ -707,17 +705,14 @@ impl Session { .and_then(|turn_environment| turn_environment.cwd().to_abs_path().ok()) .unwrap_or_else(|| session_configuration.cwd().clone()); let workspace_roots = primary_turn_environment - .as_ref() - .map(TurnEnvironment::workspace_roots) - .map(|workspace_roots| { - workspace_roots + .and_then(|turn_environment| { + turn_environment + .workspace_roots() .iter() .map(PathUri::to_abs_path) .collect::>>() + .ok() }) - .transpose() - .ok() - .flatten() .unwrap_or_default(); let per_turn_config = Self::build_per_turn_config(&session_configuration, cwd.clone(), workspace_roots); From 8fbc6719a7820691fe5e2c99bab14ec92fea4352 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 9 Jul 2026 20:37:11 -0700 Subject: [PATCH 24/29] test: assert selected environment roots directly --- .../tests/suite/v2/selected_environment.rs | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/selected_environment.rs b/codex-rs/app-server/tests/suite/v2/selected_environment.rs index e30f7f996fef..d739935e3724 100644 --- a/codex-rs/app-server/tests/suite/v2/selected_environment.rs +++ b/codex-rs/app-server/tests/suite/v2/selected_environment.rs @@ -193,10 +193,11 @@ async fn turn_model_context_uses_selected_environment() -> Result<()> { .build() .await?; timeout(DEFAULT_READ_TIMEOUT, app_server.initialize()).await??; - let (environment_cwd, environment_shell) = { + let (environment_cwd, environment_workspace_roots, environment_shell) = { let auto_env = app_server.auto_env()?; ( auto_env.selection().cwd.clone(), + auto_env.selection().workspace_roots.clone(), auto_env.environment().info().await?.shell.name, ) }; @@ -209,11 +210,7 @@ async fn turn_model_context_uses_selected_environment() -> Result<()> { app_server.read_stream_until_response_message(RequestId::Integer(request_id)), ) .await??; - let ThreadStartResponse { - thread, - runtime_workspace_roots, - .. - } = to_response(response)?; + let ThreadStartResponse { thread, .. } = to_response(response)?; timeout( DEFAULT_READ_TIMEOUT, app_server.start_turn_and_wait_for_completion(text_turn_params( @@ -248,15 +245,13 @@ async fn turn_model_context_uses_selected_environment() -> Result<()> { )), ) ); - let [runtime_workspace_root] = runtime_workspace_roots.as_slice() else { + let [environment_workspace_root] = environment_workspace_roots.as_slice() else { anyhow::bail!("expected one runtime workspace root"); }; let expected_workspace_roots = format!( "{}", - runtime_workspace_root.as_path().display() + environment_workspace_root.inferred_native_path_string() ); - // TODO(anp): Derive model-visible workspace roots from the selected remote environment and - // render them using its native path convention. assert!(environment_context.contains(&expected_workspace_roots)); Ok(()) From 31fbc7a399de0278b74b9928e43ea43c4be7dd6b Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 9 Jul 2026 21:25:19 -0700 Subject: [PATCH 25/29] core: render workspace roots for foreign environments --- .../core/src/context/environment_context.rs | 12 ++++++++---- .../src/context/world_state/environment.rs | 5 ++++- .../world_state/environment_render_tests.rs | 19 ++++++++++++------- 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/codex-rs/core/src/context/environment_context.rs b/codex-rs/core/src/context/environment_context.rs index 2f795a8ea317..50efc8035d7e 100644 --- a/codex-rs/core/src/context/environment_context.rs +++ b/codex-rs/core/src/context/environment_context.rs @@ -4,7 +4,7 @@ use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSpecialPath; -use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; use std::collections::HashSet; use std::path::PathBuf; @@ -33,14 +33,18 @@ enum ManagedFileSystemContext { impl FileSystemContext { pub(super) fn from_permission_profile( permission_profile: &PermissionProfile, - workspace_roots: &[AbsolutePathBuf], + workspace_roots: &[PathUri], ) -> Self { + let native_workspace_roots = workspace_roots + .iter() + .filter_map(|root| root.to_abs_path().ok()) + .collect::>(); let permission_profile = permission_profile .clone() - .materialize_project_roots_with_workspace_roots(workspace_roots); + .materialize_project_roots_with_workspace_roots(&native_workspace_roots); let workspace_roots = workspace_roots .iter() - .map(|root| root.to_string_lossy().into_owned()) + .map(PathUri::inferred_native_path_string) .collect(); let permission_profile = match permission_profile { PermissionProfile::Managed { file_system, .. } => { diff --git a/codex-rs/core/src/context/world_state/environment.rs b/codex-rs/core/src/context/world_state/environment.rs index d34124ecf5c0..097a3e93ef49 100644 --- a/codex-rs/core/src/context/world_state/environment.rs +++ b/codex-rs/core/src/context/world_state/environment.rs @@ -34,7 +34,10 @@ impl EnvironmentsState { network: network_from_turn_context(turn_context), filesystem: Some(FileSystemContext::from_permission_profile( &turn_context.permission_profile, - &turn_context.config.effective_workspace_roots(), + environments + .primary() + .map(|environment| environment.workspace_roots()) + .unwrap_or_default(), )), subagents: None, } diff --git a/codex-rs/core/src/context/world_state/environment_render_tests.rs b/codex-rs/core/src/context/world_state/environment_render_tests.rs index 70122586ed2a..24ee9308b0ff 100644 --- a/codex-rs/core/src/context/world_state/environment_render_tests.rs +++ b/codex-rs/core/src/context/world_state/environment_render_tests.rs @@ -86,23 +86,25 @@ fn serialize_workspace_write_environment_context() { #[test] fn serialize_environment_context_with_foreign_windows_cwd() { - let context = environment_state( - [environment( - "remote", - PathUri::parse("file:///C:/windows").expect("Windows cwd URI"), - "powershell", - )], + let windows_root = PathUri::parse("file:///C:/windows").expect("Windows workspace root URI"); + let mut context = environment_state( + [environment("remote", windows_root.clone(), "powershell")], /*current_date*/ None, /*timezone*/ None, /*network*/ None, /*subagents*/ None, ); + context.filesystem = Some(FileSystemContext::from_permission_profile( + &PermissionProfile::Disabled, + &[windows_root], + )); assert_eq!( context.render(), r#" C:\windows powershell + C:\windows "# ); } @@ -188,7 +190,10 @@ fn serialize_environment_context_with_full_filesystem_profile() { ); context.filesystem = Some(FileSystemContext::from_permission_profile( &workspace_write_permission_profile_with_private_denials(), - &[repo.clone(), other_repo.clone()], + &[ + PathUri::from_abs_path(&repo), + PathUri::from_abs_path(&other_repo), + ], )); let expected = format!( From a6c4d49799a6e6435d9c1dcaa5054de93f831d78 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 9 Jul 2026 21:39:40 -0700 Subject: [PATCH 26/29] Revert "core: render workspace roots for foreign environments" This reverts commit 31fbc7a399de0278b74b9928e43ea43c4be7dd6b. --- .../core/src/context/environment_context.rs | 12 ++++-------- .../src/context/world_state/environment.rs | 5 +---- .../world_state/environment_render_tests.rs | 19 +++++++------------ 3 files changed, 12 insertions(+), 24 deletions(-) diff --git a/codex-rs/core/src/context/environment_context.rs b/codex-rs/core/src/context/environment_context.rs index 50efc8035d7e..2f795a8ea317 100644 --- a/codex-rs/core/src/context/environment_context.rs +++ b/codex-rs/core/src/context/environment_context.rs @@ -4,7 +4,7 @@ use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSpecialPath; -use codex_utils_path_uri::PathUri; +use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::HashSet; use std::path::PathBuf; @@ -33,18 +33,14 @@ enum ManagedFileSystemContext { impl FileSystemContext { pub(super) fn from_permission_profile( permission_profile: &PermissionProfile, - workspace_roots: &[PathUri], + workspace_roots: &[AbsolutePathBuf], ) -> Self { - let native_workspace_roots = workspace_roots - .iter() - .filter_map(|root| root.to_abs_path().ok()) - .collect::>(); let permission_profile = permission_profile .clone() - .materialize_project_roots_with_workspace_roots(&native_workspace_roots); + .materialize_project_roots_with_workspace_roots(workspace_roots); let workspace_roots = workspace_roots .iter() - .map(PathUri::inferred_native_path_string) + .map(|root| root.to_string_lossy().into_owned()) .collect(); let permission_profile = match permission_profile { PermissionProfile::Managed { file_system, .. } => { diff --git a/codex-rs/core/src/context/world_state/environment.rs b/codex-rs/core/src/context/world_state/environment.rs index 097a3e93ef49..d34124ecf5c0 100644 --- a/codex-rs/core/src/context/world_state/environment.rs +++ b/codex-rs/core/src/context/world_state/environment.rs @@ -34,10 +34,7 @@ impl EnvironmentsState { network: network_from_turn_context(turn_context), filesystem: Some(FileSystemContext::from_permission_profile( &turn_context.permission_profile, - environments - .primary() - .map(|environment| environment.workspace_roots()) - .unwrap_or_default(), + &turn_context.config.effective_workspace_roots(), )), subagents: None, } diff --git a/codex-rs/core/src/context/world_state/environment_render_tests.rs b/codex-rs/core/src/context/world_state/environment_render_tests.rs index 24ee9308b0ff..70122586ed2a 100644 --- a/codex-rs/core/src/context/world_state/environment_render_tests.rs +++ b/codex-rs/core/src/context/world_state/environment_render_tests.rs @@ -86,25 +86,23 @@ fn serialize_workspace_write_environment_context() { #[test] fn serialize_environment_context_with_foreign_windows_cwd() { - let windows_root = PathUri::parse("file:///C:/windows").expect("Windows workspace root URI"); - let mut context = environment_state( - [environment("remote", windows_root.clone(), "powershell")], + let context = environment_state( + [environment( + "remote", + PathUri::parse("file:///C:/windows").expect("Windows cwd URI"), + "powershell", + )], /*current_date*/ None, /*timezone*/ None, /*network*/ None, /*subagents*/ None, ); - context.filesystem = Some(FileSystemContext::from_permission_profile( - &PermissionProfile::Disabled, - &[windows_root], - )); assert_eq!( context.render(), r#" C:\windows powershell - C:\windows "# ); } @@ -190,10 +188,7 @@ fn serialize_environment_context_with_full_filesystem_profile() { ); context.filesystem = Some(FileSystemContext::from_permission_profile( &workspace_write_permission_profile_with_private_denials(), - &[ - PathUri::from_abs_path(&repo), - PathUri::from_abs_path(&other_repo), - ], + &[repo.clone(), other_repo.clone()], )); let expected = format!( From c69a8a83742fa62a0e4c784c65d9e8eb0e9e1431 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 9 Jul 2026 21:46:51 -0700 Subject: [PATCH 27/29] test: drop legacy workspace root assertion --- .../tests/suite/v2/selected_environment.rs | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/selected_environment.rs b/codex-rs/app-server/tests/suite/v2/selected_environment.rs index d739935e3724..6dfeea20f195 100644 --- a/codex-rs/app-server/tests/suite/v2/selected_environment.rs +++ b/codex-rs/app-server/tests/suite/v2/selected_environment.rs @@ -193,11 +193,10 @@ async fn turn_model_context_uses_selected_environment() -> Result<()> { .build() .await?; timeout(DEFAULT_READ_TIMEOUT, app_server.initialize()).await??; - let (environment_cwd, environment_workspace_roots, environment_shell) = { + let (environment_cwd, environment_shell) = { let auto_env = app_server.auto_env()?; ( auto_env.selection().cwd.clone(), - auto_env.selection().workspace_roots.clone(), auto_env.environment().info().await?.shell.name, ) }; @@ -245,14 +244,5 @@ async fn turn_model_context_uses_selected_environment() -> Result<()> { )), ) ); - let [environment_workspace_root] = environment_workspace_roots.as_slice() else { - anyhow::bail!("expected one runtime workspace root"); - }; - let expected_workspace_roots = format!( - "{}", - environment_workspace_root.inferred_native_path_string() - ); - assert!(environment_context.contains(&expected_workspace_roots)); - Ok(()) } From 6507c02a259be071d80bf1a52a32104359d73ae6 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 9 Jul 2026 22:07:01 -0700 Subject: [PATCH 28/29] core: preserve effective session permissions event --- codex-rs/core/src/session/session.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 4453e908cdf2..df3eacb54385 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -1175,7 +1175,7 @@ impl Session { service_tier: session_configuration.service_tier.clone(), approval_policy: session_configuration.approval_policy.value(), approvals_reviewer: session_configuration.approvals_reviewer, - permission_profile: session_configuration.permission_profile(), + permission_profile: session_configuration.materialized_permission_profile(), active_permission_profile: session_configuration.active_permission_profile(), cwd: session_configuration.cwd().clone(), reasoning_effort: session_configuration.collaboration_mode.reasoning_effort(), From c5b8161a9c843f33bd687bc8aa8fd5d244fde22d Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Fri, 10 Jul 2026 00:50:29 -0700 Subject: [PATCH 29/29] app-server: preserve local environment cwd fallback --- codex-rs/app-server/src/request_processors.rs | 1 + .../src/request_processors/turn_processor.rs | 9 +++++- .../app-server/tests/suite/v2/turn_start.rs | 28 +++++++++++++++---- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 989742e19c39..202e57875017 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -368,6 +368,7 @@ use codex_core_plugins::remote::RemotePluginShareContext as RemoteCatalogPluginS use codex_core_plugins::remote::RemotePluginShareSummary as RemoteCatalogPluginShareSummary; use codex_core_plugins::remote::RemotePluginSummary as RemoteCatalogPluginSummary; use codex_exec_server::EnvironmentManager; +use codex_exec_server::LOCAL_ENVIRONMENT_ID; use codex_exec_server::LOCAL_FS; use codex_features::FEATURES; use codex_features::Feature; diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index 66e819aa3876..8e0c8f13c295 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -590,7 +590,14 @@ impl TurnRequestProcessor { if let Some(environment_selections) = environment_selections { let legacy_fallback_cwd = match cwd { Some(cwd) => cwd, - None => thread.config_snapshot().await.cwd().clone(), + None => match environment_selections + .iter() + .find(|selection| selection.environment_id == LOCAL_ENVIRONMENT_ID) + .and_then(|selection| selection.cwd.to_abs_path().ok()) + { + Some(cwd) => cwd, + None => thread.config_snapshot().await.cwd().clone(), + }, }; return Some(TurnEnvironmentSelections::new( legacy_fallback_cwd, diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 517074793ec8..4560cfa09351 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -48,6 +48,7 @@ use codex_app_server_protocol::ThreadDeletedNotification; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadLoadedListParams; use codex_app_server_protocol::ThreadLoadedListResponse; +use codex_app_server_protocol::ThreadSettingsUpdatedNotification; use codex_app_server_protocol::ThreadSource; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; @@ -64,6 +65,7 @@ use codex_app_server_protocol::WarningNotification; use codex_config::config_toml::ConfigToml; use codex_core::personality_migration::PERSONALITY_MIGRATION_FILENAME; use codex_core::test_support::all_model_presets; +use codex_exec_server::LOCAL_ENVIRONMENT_ID; use codex_features::FEATURES; use codex_features::Feature; use codex_protocol::config_types::CollaborationMode; @@ -2532,7 +2534,7 @@ async fn turn_start_exec_approval_decline_v2() -> Result<()> { } #[tokio::test] -async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { +async fn turn_start_explicit_local_environment_updates_legacy_cwd_between_turns() -> Result<()> { // TODO(anp): Materialize cwd and shell-display fixtures in the selected remote environment. skip_if_remote!(Ok(()), "cwd fixtures are only materialized on the host"); skip_if_no_network!(Ok(())); @@ -2636,10 +2638,15 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { .await??; mcp.clear_message_buffer(); - // second turn with workspace-write and second_cwd, ensure exec begins in second_cwd + // Select a new local cwd without the top-level compatibility parameter. The inherited + // workspace-write sandbox must follow the local environment cwd. let second_turn = mcp .send_turn_start_request(TurnStartParams { - environments: None, + environments: Some(vec![TurnEnvironmentParams { + environment_id: LOCAL_ENVIRONMENT_ID.to_string(), + cwd: second_cwd.abs().into(), + runtime_workspace_roots: None, + }]), thread_id: thread.id.clone(), client_user_message_id: None, input: vec![V2UserInput::Text { @@ -2648,11 +2655,11 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { }], responsesapi_client_metadata: None, additional_context: None, - cwd: Some(second_cwd.clone()), + cwd: None, runtime_workspace_roots: None, approval_policy: Some(codex_app_server_protocol::AskForApproval::Never), approvals_reviewer: None, - sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::DangerFullAccess), + sandbox_policy: None, permissions: None, model: Some("mock-model".to_string()), effort: Some(ReasoningEffort::Medium), @@ -2669,6 +2676,17 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { mcp.read_stream_until_response_message(RequestId::Integer(second_turn)), ) .await??; + let settings_updated = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("thread/settings/updated"), + ) + .await??; + let settings_updated: ThreadSettingsUpdatedNotification = serde_json::from_value( + settings_updated + .params + .context("thread/settings/updated should include params")?, + )?; + assert_eq!(settings_updated.thread_settings.cwd, second_cwd.abs()); let command_exec_item = timeout(DEFAULT_READ_TIMEOUT, async { loop {