diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 4afd41a949..768fcbd6e2 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -7,6 +7,8 @@ use super::resize_reflow::trailing_run_start; use super::session_lifecycle::ThreadAttachPresentation; use super::*; use crate::app_server_session::ForkGoalContinuation; +use crate::app_server_session::UnsupportedLegacyPermissionProfile; +use crate::app_server_session::turn_permissions_overrides; use crate::config_update::format_config_error; use crate::external_agent_config_migration::flow::ExternalAgentConfigMigrationFlowOutcome; use crate::pager_overlay::TranscriptHistoryState; @@ -664,12 +666,19 @@ impl App { } self.chat_widget.prepare_local_op_submission(&op); if let Err(err) = self.submit_active_thread_op(app_server, op).await { + let unsupported_permissions = err + .downcast_ref::() + .is_some(); + if unsupported_permissions { + self.chat_widget + .set_queue_autosend_suppressed(/*suppressed*/ true); + } let handled = is_user_turn - && matches!( + && (matches!( err.downcast_ref::(), Some(TypedRequestError::Server { method, .. }) if method == "turn/start" - ) + ) || unsupported_permissions) && self .chat_widget .handle_turn_start_rejection(format!("Failed to start turn: {err:#}")); @@ -1404,9 +1413,21 @@ impl App { } AppEvent::SettingsSelectionSettled => { if self.chat_widget.no_modal_or_popup_active() { - self.chat_widget - .set_queue_autosend_suppressed(/*suppressed*/ false); - self.chat_widget.maybe_send_next_queued_input(); + let config = self.chat_widget.config_ref(); + let permissions_override = Self::turn_permissions_override_from_config( + config, + config.permissions.active_permission_profile().as_ref(), + self.runtime_permission_profile_override + .as_ref() + .map(|profile| &profile.permission_profile), + ); + if turn_permissions_overrides(permissions_override, config.cwd.as_path()) + .is_ok() + { + self.chat_widget + .set_queue_autosend_suppressed(/*suppressed*/ false); + self.chat_widget.maybe_send_next_queued_input(); + } } } AppEvent::OpenReasoningPopup { model } => { diff --git a/codex-rs/tui/src/app/safety_buffering.rs b/codex-rs/tui/src/app/safety_buffering.rs index 9729a6f487..c9d0ee900b 100644 --- a/codex-rs/tui/src/app/safety_buffering.rs +++ b/codex-rs/tui/src/app/safety_buffering.rs @@ -4,6 +4,7 @@ use super::session_lifecycle::ThreadAttachPresentation; use super::*; use crate::app_server_session::ForkGoalContinuation; use crate::app_server_session::HISTORY_ITEM_PAGE_LIMIT; +use crate::app_server_session::turn_permissions_overrides; use crate::chatwidget::ThreadInputState; use crate::chatwidget::ThreadInputStateRestoreMode; use crate::chatwidget::UserMessage; @@ -52,6 +53,8 @@ impl App { let AppCommand::UserTurn { items, + cwd, + active_permission_profile, model: turn_model, effort, collaboration_mode, @@ -63,6 +66,18 @@ impl App { ); return; }; + let permissions_override = Self::turn_permissions_override_from_config( + &retry_config, + active_permission_profile.as_ref(), + self.runtime_permission_profile_override + .as_ref() + .map(|profile| &profile.permission_profile), + ); + if let Err(err) = turn_permissions_overrides(permissions_override, cwd.as_path()) { + self.chat_widget + .add_error_message(format!("Failed to retry with a faster model: {err}")); + return; + } *turn_model = model.clone(); *effort = Some(ReasoningEffortConfig::Low); *collaboration_mode = collaboration_mode.as_ref().map(|mode| { diff --git a/codex-rs/tui/src/app/tests/safety_buffering.rs b/codex-rs/tui/src/app/tests/safety_buffering.rs index 33ca6be628..887d0bcd5f 100644 --- a/codex-rs/tui/src/app/tests/safety_buffering.rs +++ b/codex-rs/tui/src/app/tests/safety_buffering.rs @@ -4,6 +4,12 @@ use crate::app::session_lifecycle::ThreadAttachPresentation; use crate::chatwidget::UserMessage; use codex_app_server_client::AppServerEvent; use codex_app_server_protocol::ModelSafetyBufferingUpdatedNotification; +use codex_protocol::models::ManagedFileSystemPermissions; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSpecialPath; +use codex_protocol::permissions::NetworkSandboxPolicy; use codex_utils_absolute_path::test_support::PathExt; use core_test_support::responses; use core_test_support::responses::ev_assistant_message; @@ -31,6 +37,7 @@ enum SafetyRetryScenario { Once, RetryTwice, InterruptedPrevious, + UnsupportedPermissions, } fn response_chunks(response_id: &str) -> Vec { @@ -483,7 +490,7 @@ goals = true .expect("source goal usage should be recorded"); submit_prompt(&mut app, RETRY_PROMPT); - let active_turn = next_user_turn_event(&mut app_event_rx); + let mut active_turn = next_user_turn_event(&mut app_event_rx); app.submit_thread_op(&mut app_server, source_thread_id, active_turn.clone()) .await?; let active_turn_id = next_turn_started(&mut app, &mut app_server, source_thread_id).await; @@ -572,12 +579,55 @@ goals = true app.primary_thread_id = Some(source_thread_id); while app_event_rx.try_recv().is_ok() {} + if scenario == SafetyRetryScenario::UnsupportedPermissions { + let extra_root = + AbsolutePathBuf::resolve_path_against_base("extra", app.config.cwd.as_path()); + let permission_profile = PermissionProfile::Managed { + network: NetworkSandboxPolicy::Restricted, + file_system: ManagedFileSystemPermissions::Restricted { + entries: vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + missing_path_behavior: None, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: extra_root.into(), + }, + access: FileSystemAccessMode::Write, + missing_path_behavior: None, + }, + ], + glob_scan_max_depth: None, + }, + }; + app.config + .permissions + .set_permission_profile(permission_profile.clone())?; + app.chat_widget.set_permission_profile_with_active_profile( + permission_profile, + /*active_permission_profile*/ None, + )?; + app.runtime_permission_profile_override = + Some(RuntimePermissionProfileOverride::from_config(&app.config)); + if let AppCommand::UserTurn { + active_permission_profile, + .. + } = &mut active_turn + { + *active_permission_profile = None; + } + } + Box::pin(app.retry_safety_buffered_turn( &mut tui, &mut app_server, SafetyBufferedRetry { thread_id: source_thread_id, - turn_id: active_turn_id, + turn_id: active_turn_id.clone(), model: FASTER_MODEL.to_string(), turn: active_turn, prompt: UserMessage::from(RETRY_PROMPT), @@ -585,6 +635,42 @@ goals = true )) .await; + if scenario == SafetyRetryScenario::UnsupportedPermissions { + assert_eq!(app.active_thread_id, Some(source_thread_id)); + assert_eq!(app.primary_thread_id, Some(source_thread_id)); + assert_eq!(app.chat_widget.thread_id(), Some(source_thread_id)); + assert!( + app.chat_widget + .can_retry_safety_buffered_turn(&active_turn_id) + ); + let source = app_server + .thread_read(source_thread_id, /*include_turns*/ true) + .await?; + assert_eq!( + source.turns.last().map(|turn| &turn.status), + Some(&TurnStatus::InProgress) + ); + let error_cell = std::iter::from_fn(|| app_event_rx.try_recv().ok()) + .find_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => Some(cell), + _ => None, + }) + .expect("unsupported permissions should be added to history"); + insta::assert_snapshot!( + lines_to_single_string(&error_cell.display_lines(/*width*/ 200)), + @"■ Failed to retry with a faster model: the selected permission profile cannot be safely represented by the legacy app-server sandbox policy; select a named or legacy-compatible permission profile" + ); + if let Some(release_active_response) = release_active_response.take() { + let _ = release_active_response.send(()); + } + let _ = release_steered_response.send(()); + let _ = release_previous_response.send(()); + let _ = release_retry_response.send(()); + app_server.shutdown().await?; + server.shutdown().await; + return Ok(()); + } + let first_retry_thread_id = app.chat_widget.thread_id().expect("first retry thread id"); if scenario == SafetyRetryScenario::RetryTwice { let first_retry_turn_id = @@ -890,3 +976,14 @@ async fn safety_retry_branch_failure_preserves_unsent_draft() -> Result<()> { ) .await } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn safety_retry_rejects_unsupported_permissions_before_interrupting() -> Result<()> { + run_safety_retry( + /*previous_prompt*/ None, + /*failing_draft*/ None, + /*committed_steer*/ None, + SafetyRetryScenario::UnsupportedPermissions, + ) + .await +} diff --git a/codex-rs/tui/src/app/tests/turn_submission.rs b/codex-rs/tui/src/app/tests/turn_submission.rs index 8737007f19..4fe124d17e 100644 --- a/codex-rs/tui/src/app/tests/turn_submission.rs +++ b/codex-rs/tui/src/app/tests/turn_submission.rs @@ -1,5 +1,11 @@ use super::*; use crate::chatwidget::UserMessage; +use codex_protocol::models::ManagedFileSystemPermissions; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSpecialPath; +use codex_protocol::permissions::NetworkSandboxPolicy; #[tokio::test] async fn turn_start_failure_is_shown_without_exiting() -> Result<()> { @@ -50,3 +56,110 @@ async fn turn_start_failure_is_shown_without_exiting() -> Result<()> { app_server.shutdown().await?; Ok(()) } + +#[tokio::test] +async fn unsupported_legacy_permissions_are_shown_without_exiting() -> Result<()> { + let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let thread_id = ThreadId::new(); + app.active_thread_id = Some(thread_id); + app.chat_widget + .handle_thread_session(test_thread_session(thread_id, app.config.cwd.to_path_buf())); + + let extra_root = codex_utils_absolute_path::AbsolutePathBuf::resolve_path_against_base( + "extra", + app.config.cwd.as_path(), + ); + let permission_profile = PermissionProfile::Managed { + network: NetworkSandboxPolicy::Restricted, + file_system: ManagedFileSystemPermissions::Restricted { + entries: vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + missing_path_behavior: None, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: extra_root.into(), + }, + access: FileSystemAccessMode::Write, + missing_path_behavior: None, + }, + ], + glob_scan_max_depth: None, + }, + }; + app.config + .permissions + .set_permission_profile(permission_profile.clone())?; + app.chat_widget.set_permission_profile_with_active_profile( + permission_profile, + /*active_permission_profile*/ None, + )?; + app.runtime_permission_profile_override = + Some(RuntimePermissionProfileOverride::from_config(&app.config)); + while app_event_rx.try_recv().is_ok() {} + + app.chat_widget + .restore_user_message_to_composer(UserMessage::from("hello")); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let op = next_user_turn_op(&mut op_rx); + app.chat_widget + .restore_user_message_to_composer(UserMessage::from("queued follow-up")); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(app.chat_widget.has_queued_follow_up_messages()); + while let Ok(event) = app_event_rx.try_recv() { + app.handle_event(&mut tui, &mut app_server, event).await?; + } + + let control = app + .handle_event(&mut tui, &mut app_server, AppEvent::CodexOp(op)) + .await?; + + assert!(matches!(control, AppRunControl::Continue)); + assert!(app.chat_widget.has_queued_follow_up_messages()); + let error_cell = std::iter::from_fn(|| app_event_rx.try_recv().ok()) + .find_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => Some(cell), + _ => None, + }) + .expect("unsupported permissions should be added to history"); + insta::assert_snapshot!( + lines_to_single_string(&error_cell.display_lines(/*width*/ 200)), + @"■ Failed to start turn: the selected permission profile cannot be safely represented by the legacy app-server sandbox policy; select a named or legacy-compatible permission profile" + ); + + app.handle_event( + &mut tui, + &mut app_server, + AppEvent::SettingsSelectionSettled, + ) + .await?; + assert!(app.chat_widget.has_queued_follow_up_messages()); + + app.config + .permissions + .set_permission_profile(PermissionProfile::read_only())?; + app.chat_widget.set_permission_profile_with_active_profile( + PermissionProfile::read_only(), + /*active_permission_profile*/ None, + )?; + app.runtime_permission_profile_override = + Some(RuntimePermissionProfileOverride::from_config(&app.config)); + app.handle_event( + &mut tui, + &mut app_server, + AppEvent::SettingsSelectionSettled, + ) + .await?; + assert!(!app.chat_widget.has_queued_follow_up_messages()); + + app_server.shutdown().await?; + Ok(()) +} diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index a58c9cf960..73ea6b4b91 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -828,7 +828,7 @@ impl App { } } - fn turn_permissions_override_from_config( + pub(super) fn turn_permissions_override_from_config( config: &Config, active_permission_profile: Option<&ActivePermissionProfile>, runtime_permission_profile_override: Option<&PermissionProfile>, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 420c1fdabf..e433418d9c 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -14,7 +14,6 @@ pub(crate) use history::thread_items_page_params; use crate::bottom_pane::FeedbackAudience; use crate::legacy_core::config::Config; -use crate::permission_compat::legacy_compatible_permission_profile; use crate::service_tier_resolution; use crate::session_state::MessageHistoryMetadata; use crate::session_state::ThreadSessionState; @@ -335,6 +334,12 @@ pub(crate) enum TurnPermissionsOverride { LegacySandbox(PermissionProfile), } +#[derive(Debug, thiserror::Error)] +#[error( + "the selected permission profile cannot be safely represented by the legacy app-server sandbox policy; select a named or legacy-compatible permission profile" +)] +pub(crate) struct UnsupportedLegacyPermissionProfile; + impl AppServerSession { pub(crate) fn new(client: AppServerClient, thread_params_mode: ThreadParamsMode) -> Self { Self { @@ -1051,7 +1056,7 @@ impl AppServerSession { ) -> Result { let request_id = self.next_request_id(); let (sandbox_policy, permissions) = - turn_permissions_overrides(permissions_override, cwd.as_path()); + turn_permissions_overrides(permissions_override, cwd.as_path())?; self.client .request_typed(ClientRequest::TurnStart { request_id, @@ -1607,14 +1612,14 @@ fn permission_profile_id_from_active_profile(active: ActivePermissionProfile) -> active.id } -fn turn_permissions_overrides( +pub(crate) fn turn_permissions_overrides( permissions_override: TurnPermissionsOverride, cwd: &std::path::Path, -) -> ( +) -> Result<( Option, Option, -) { - match permissions_override { +)> { + Ok(match permissions_override { TurnPermissionsOverride::Preserve => (None, None), TurnPermissionsOverride::ActiveProfile(active_permission_profile) => ( None, @@ -1623,17 +1628,20 @@ fn turn_permissions_overrides( )), ), TurnPermissionsOverride::LegacySandbox(permission_profile) => { - let legacy_profile = legacy_compatible_permission_profile(&permission_profile, cwd); - let policy = legacy_profile + let policy = permission_profile .to_legacy_sandbox_policy(cwd) - .unwrap_or_else(|err| { - unreachable!( - "legacy-compatible permissions must project to legacy policy: {err}" - ) - }); + .map_err(|_| UnsupportedLegacyPermissionProfile)?; + let projected_profile = + PermissionProfile::from_legacy_sandbox_policy_for_cwd(&policy, cwd); + if !permission_profile + .file_system_sandbox_policy() + .is_semantically_equivalent_to(&projected_profile.file_system_sandbox_policy(), cwd) + { + return Err(UnsupportedLegacyPermissionProfile.into()); + } (Some(policy.into()), None) } - } + }) } fn permissions_selection_from_config( @@ -2426,7 +2434,8 @@ mod tests { let (sandbox_policy, permissions) = turn_permissions_overrides( TurnPermissionsOverride::ActiveProfile(active_permission_profile), cwd.as_path(), - ); + ) + .expect("active permission profile should be supported"); assert_eq!(sandbox_policy, None); assert_eq!(permissions, Some(expected_permissions)); @@ -2441,7 +2450,8 @@ mod tests { let (sandbox_policy, permissions) = turn_permissions_overrides( TurnPermissionsOverride::ActiveProfile(active_permission_profile), cwd.as_path(), - ); + ) + .expect("active permission profile should be supported"); assert_eq!(sandbox_policy, None); assert_eq!( @@ -2455,7 +2465,8 @@ mod tests { let cwd = test_path_buf("/workspace/project").abs(); let (sandbox_policy, permissions) = - turn_permissions_overrides(TurnPermissionsOverride::Preserve, cwd.as_path()); + turn_permissions_overrides(TurnPermissionsOverride::Preserve, cwd.as_path()) + .expect("preserving permissions should be supported"); assert_eq!(sandbox_policy, None); assert_eq!(permissions, None); @@ -2468,7 +2479,8 @@ mod tests { let (sandbox_policy, permissions) = turn_permissions_overrides( TurnPermissionsOverride::LegacySandbox(PermissionProfile::read_only()), cwd.as_path(), - ); + ) + .expect("read-only permission profile should be supported"); assert_eq!( sandbox_policy, @@ -2479,6 +2491,118 @@ mod tests { assert_eq!(permissions, None); } + #[test] + fn legacy_turn_permissions_preserve_workspace_write() { + let cwd = test_path_buf("/workspace/project").abs(); + + let (sandbox_policy, permissions) = turn_permissions_overrides( + TurnPermissionsOverride::LegacySandbox(PermissionProfile::workspace_write()), + cwd.as_path(), + ) + .expect("workspace-write permission profile should be supported"); + + assert_eq!( + sandbox_policy, + Some(codex_app_server_protocol::SandboxPolicy::WorkspaceWrite { + writable_roots: Vec::new(), + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }) + ); + assert_eq!(permissions, None); + } + + #[test] + fn legacy_turn_permissions_reject_non_cwd_write_roots() { + let cwd = test_path_buf("/workspace/project").abs(); + let extra_root = test_path_buf("/workspace/extra").abs(); + let permission_profile = PermissionProfile::Managed { + network: NetworkSandboxPolicy::Restricted, + file_system: ManagedFileSystemPermissions::Restricted { + entries: vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + missing_path_behavior: None, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: extra_root.into(), + }, + access: FileSystemAccessMode::Write, + missing_path_behavior: None, + }, + ], + glob_scan_max_depth: None, + }, + }; + + let error = turn_permissions_overrides( + TurnPermissionsOverride::LegacySandbox(permission_profile), + cwd.as_path(), + ) + .expect_err("non-cwd write roots must not grant cwd write access"); + + assert_eq!( + error.to_string(), + "the selected permission profile cannot be safely represented by the legacy app-server sandbox policy; select a named or legacy-compatible permission profile" + ); + } + + #[test] + fn legacy_turn_permissions_reject_restrictions_lost_by_projection() { + let cwd = test_path_buf("/workspace/project").abs(); + let docs = test_path_buf("/workspace/project/docs").abs(); + + for (path, access) in [ + ( + FileSystemPath::Path { + path: docs.clone().into(), + }, + FileSystemAccessMode::Read, + ), + ( + FileSystemPath::Path { path: docs.into() }, + FileSystemAccessMode::Deny, + ), + ( + FileSystemPath::GlobPattern { + pattern: "**/*.secret".to_string(), + }, + FileSystemAccessMode::Deny, + ), + ] { + let mut permission_profile = PermissionProfile::workspace_write(); + let PermissionProfile::Managed { + file_system: ManagedFileSystemPermissions::Restricted { entries, .. }, + .. + } = &mut permission_profile + else { + unreachable!("workspace-write profiles use restricted managed permissions"); + }; + entries.push(FileSystemSandboxEntry { + path, + access, + missing_path_behavior: None, + }); + + let error = turn_permissions_overrides( + TurnPermissionsOverride::LegacySandbox(permission_profile), + cwd.as_path(), + ) + .expect_err("legacy projection must not discard filesystem restrictions"); + + assert!( + error + .downcast_ref::() + .is_some() + ); + } + } + #[test] fn remote_turn_permissions_preserve_active_profile_selection() { let cwd = test_path_buf("/workspace/project").abs(); @@ -2489,7 +2613,8 @@ mod tests { let (sandbox_policy, permissions) = turn_permissions_overrides( TurnPermissionsOverride::ActiveProfile(active_permission_profile), cwd.as_path(), - ); + ) + .expect("active permission profile should be supported"); assert_eq!(sandbox_policy, None); assert_eq!(permissions, Some(expected_permissions)); diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index ee4fd03051..0e00413658 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -160,7 +160,6 @@ mod npm_registry; pub(crate) mod onboarding; mod oss_selection; mod pager_overlay; -mod permission_compat; pub(crate) mod public_widgets; mod render; mod resize_reflow_cap; diff --git a/codex-rs/tui/src/permission_compat.rs b/codex-rs/tui/src/permission_compat.rs deleted file mode 100644 index 8922f6c243..0000000000 --- a/codex-rs/tui/src/permission_compat.rs +++ /dev/null @@ -1,95 +0,0 @@ -//! Compatibility projections from the canonical permission profile model into -//! legacy shapes still required by older or remote app-server APIs. - -use codex_protocol::models::PermissionProfile; -use codex_utils_absolute_path::AbsolutePathBuf; -use std::path::Path; - -pub(crate) fn legacy_compatible_permission_profile( - permission_profile: &PermissionProfile, - cwd: &Path, -) -> PermissionProfile { - if permission_profile.to_legacy_sandbox_policy(cwd).is_ok() { - return permission_profile.clone(); - } - - let file_system_policy = permission_profile.file_system_sandbox_policy(); - let network_policy = permission_profile.network_sandbox_policy(); - let cwd_abs = AbsolutePathBuf::from_absolute_path(cwd).ok(); - let writable_roots = file_system_policy - .get_writable_roots_with_cwd(cwd) - .into_iter() - .map(|root| root.root) - .filter(|root| cwd_abs.as_ref() != Some(root)) - .collect::>(); - let tmpdir_writable = std::env::var_os("TMPDIR") - .filter(|tmpdir| !tmpdir.is_empty()) - .and_then(|tmpdir| { - AbsolutePathBuf::from_absolute_path(std::path::PathBuf::from(tmpdir)).ok() - }) - .is_some_and(|tmpdir| file_system_policy.can_write_path_with_cwd(tmpdir.as_path(), cwd)); - let slash_tmp = Path::new("/tmp"); - let slash_tmp_writable = slash_tmp.is_absolute() - && slash_tmp.is_dir() - && file_system_policy.can_write_path_with_cwd(slash_tmp, cwd); - - PermissionProfile::workspace_write_with( - &writable_roots, - network_policy, - !tmpdir_writable, - !slash_tmp_writable, - ) -} - -#[cfg(test)] -mod tests { - use super::*; - use codex_protocol::models::ManagedFileSystemPermissions; - use codex_protocol::permissions::FileSystemAccessMode; - use codex_protocol::permissions::FileSystemPath; - use codex_protocol::permissions::FileSystemSandboxEntry; - use codex_protocol::permissions::FileSystemSpecialPath; - use codex_protocol::permissions::NetworkSandboxPolicy; - use pretty_assertions::assert_eq; - - #[test] - fn compatibility_profile_preserves_unbridgeable_write_roots() { - let cwd = AbsolutePathBuf::try_from("/workspace/project").expect("absolute cwd"); - let extra_root = AbsolutePathBuf::try_from("/workspace/extra").expect("absolute root"); - let permission_profile: PermissionProfile = PermissionProfile::Managed { - network: NetworkSandboxPolicy::Restricted, - file_system: ManagedFileSystemPermissions::Restricted { - entries: vec![ - FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Root, - }, - access: FileSystemAccessMode::Read, - missing_path_behavior: None, - }, - FileSystemSandboxEntry { - path: FileSystemPath::Path { - path: extra_root.clone().into(), - }, - access: FileSystemAccessMode::Write, - missing_path_behavior: None, - }, - ], - glob_scan_max_depth: None, - }, - }; - - let compatibility_profile = - legacy_compatible_permission_profile(&permission_profile, cwd.as_path()); - let policy = compatibility_profile - .to_legacy_sandbox_policy(cwd.as_path()) - .expect("compatibility profile should project to legacy policy"); - let roots = policy - .get_writable_roots_with_cwd(cwd.as_path()) - .into_iter() - .map(|root| root.root) - .collect::>(); - - assert_eq!(roots, vec![extra_root, cwd]); - } -}