From 0d7b8117d3cc6d9ffe21d22375d70307133e9f16 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 29 Sep 2026 04:02:06 +0000 Subject: [PATCH] Support projectless TUI sessions with workspace defaults (#49160) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What changed - Skip folder trust prompts for locally discovered projectless directories without a saved trust decision. Apply workspace-write permissions and granular approval defaults only when local execution, configuration, and managed policy allow them. - Prompt for Windows sandbox setup when needed before enabling implicit workspace-write permissions. - Restore saved task permissions on resume unless explicitly overridden, and keep ordinary turns from overwriting the server's saved permission profile. Track explicit approval reviewer choices separately from restored permissions. - Let `/cd` request folder trust with a “Keep current directory” option. Recheck task activity and background terminals before switching, and enforce destination permission requirements. ## Testing Add regression coverage for projectless defaults and session transitions, explicit permission overrides, saved profile preservation, trust discovery, destination policy conflicts, and the directory consent and Windows sandbox prompts. GitOrigin-RevId: cf74f5a90245f2aee96caa80d76de64da6ec1ebd --- codex-rs/cli/tests/doctor_path_safety.rs | 1 + codex-rs/config/src/loader/mod.rs | 3 +- codex-rs/tui/src/app.rs | 4 +- codex-rs/tui/src/app/agents_overview.rs | 84 ++-- codex-rs/tui/src/app/agents_overview_tests.rs | 82 ++++ codex-rs/tui/src/app/config_persistence.rs | 113 ++++-- codex-rs/tui/src/app/event_dispatch.rs | 5 +- .../tui/src/app/managed_worktree_creation.rs | 46 ++- codex-rs/tui/src/app/misalignment_policy.rs | 12 +- codex-rs/tui/src/app/new_session.rs | 13 +- codex-rs/tui/src/app/realtime_settings.rs | 4 +- codex-rs/tui/src/app/reconnect.rs | 18 +- codex-rs/tui/src/app/resume_config.rs | 23 +- codex-rs/tui/src/app/session_lifecycle.rs | 38 +- codex-rs/tui/src/app/startup.rs | 51 ++- codex-rs/tui/src/app/test_support.rs | 1 + codex-rs/tui/src/app/tests.rs | 83 ++-- .../src/app/tests/active_reconnect_tests.rs | 12 +- .../tests/background_task_defaults_tests.rs | 106 +++--- .../tui/src/app/tests/disconnect_tests.rs | 1 + .../src/app/tests/history_hydration_tests.rs | 3 +- .../app/tests/misalignment_policy_tests.rs | 14 +- .../app/tests/navigation_reconnect_tests.rs | 1 + .../tui/src/app/tests/new_session_tests.rs | 6 +- .../app/tests/permission_selection_tests.rs | 1 + .../tui/src/app/tests/projectless_tests.rs | 358 ++++++++++++++++++ .../app/tests/session_lifecycle_requests.rs | 112 +++++- .../src/app/tests/startup_defaults_tests.rs | 51 ++- codex-rs/tui/src/app/thread_routing.rs | 47 ++- codex-rs/tui/src/app/working_directory.rs | 289 +++++++++----- codex-rs/tui/src/app_server_session.rs | 43 ++- .../prompt_history_tests.rs | 6 + .../reasoning_defaults_tests.rs | 6 + .../src/app_server_session/rollout_history.rs | 19 + .../rollout_history_tests.rs | 87 +++-- .../workspace_roots_tests.rs | 1 + ...ts__projectless_windows_sandbox_setup.snap | 13 + .../tui/src/chatwidget/tests/permissions.rs | 38 +- .../src/chatwidget/windows_sandbox_prompts.rs | 12 +- codex-rs/tui/src/config_update.rs | 42 +- codex-rs/tui/src/config_update_tests.rs | 59 ++- codex-rs/tui/src/lib.rs | 79 +--- .../tui/src/onboarding/directory_trust.rs | 55 ++- codex-rs/tui/src/onboarding/mod.rs | 2 + .../tui/src/onboarding/onboarding_screen.rs | 1 + ...ge_consent_can_keep_current_directory.snap | 18 + .../tui/src/onboarding/trust_directory.rs | 17 + codex-rs/tui/src/projectless.rs | 110 ++++++ codex-rs/tui/src/resume_permissions.rs | 57 +++ codex-rs/tui/src/resume_permissions_tests.rs | 63 +++ codex-rs/tui/src/session_start.rs | 17 +- codex-rs/tui/src/session_start_tests.rs | 12 +- ...ts__agents_overview_reviewer_conflict.snap | 12 + codex-rs/tui/src/unarchive_prompt_tests.rs | 1 + codex-rs/tui/tests/suite/directory_trust.rs | 12 +- 55 files changed, 1836 insertions(+), 528 deletions(-) create mode 100644 codex-rs/tui/src/app/tests/projectless_tests.rs create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__projectless_windows_sandbox_setup.snap create mode 100644 codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__directory_change_consent_can_keep_current_directory.snap create mode 100644 codex-rs/tui/src/projectless.rs create mode 100644 codex-rs/tui/src/resume_permissions.rs create mode 100644 codex-rs/tui/src/resume_permissions_tests.rs create mode 100644 codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_reviewer_conflict.snap diff --git a/codex-rs/cli/tests/doctor_path_safety.rs b/codex-rs/cli/tests/doctor_path_safety.rs index c6bd556870..0b53ebb9b4 100644 --- a/codex-rs/cli/tests/doctor_path_safety.rs +++ b/codex-rs/cli/tests/doctor_path_safety.rs @@ -497,6 +497,7 @@ fn filesystem_probe_does_not_load_configuration() -> Result<()> { #[tokio::test] async fn interactive_tmux_startup_does_not_execute_workspace_helpers() -> Result<()> { let fixture = Fixture::new()?; + std::fs::create_dir(fixture.workspace.join(".codex"))?; let command = fixture.command()?; let mut env: std::collections::HashMap = std::env::vars().collect(); for (key, value) in command.get_envs() { diff --git a/codex-rs/config/src/loader/mod.rs b/codex-rs/config/src/loader/mod.rs index 615d394a89..c450587985 100644 --- a/codex-rs/config/src/loader/mod.rs +++ b/codex-rs/config/src/loader/mod.rs @@ -1487,7 +1487,8 @@ pub async fn find_project_root( .unwrap_or_else(|| cwd.clone())) } -async fn discover_project_root( +/// Find a project marker, preserving absence instead of falling back to cwd. +pub async fn discover_project_root( fs: &dyn ExecutorFileSystem, cwd: &AbsolutePathBuf, project_root_markers: &[String], diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 7923527fff..37b5c788ad 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -541,6 +541,7 @@ pub(crate) struct App { loader_overrides: LoaderOverrides, cloud_config_bundle: CloudConfigBundleLoader, runtime_approval_policy_override: Option, + runtime_approvals_reviewer_override: Option, runtime_permission_profile_override: Option, /// In-flight remote selections; confirmed settings live in each task's server snapshot. pending_server_profiles: HashMap, @@ -672,7 +673,6 @@ struct RuntimePermissionProfileOverride { permission_profile: PermissionProfile, active_permission_profile: Option, network: Option, - approvals_reviewer: ApprovalsReviewer, turn_override: RuntimePermissionProfileTurnOverride, } @@ -709,7 +709,6 @@ impl RuntimePermissionProfileOverride { permission_profile: config.permissions.permission_profile().clone(), active_permission_profile: config.permissions.active_permission_profile(), network: config.permissions.network.clone(), - approvals_reviewer: config.approvals_reviewer, turn_override: RuntimePermissionProfileTurnOverride::LegacySandbox, } } @@ -725,7 +724,6 @@ impl RuntimePermissionProfileOverride { self.permission_profile == *config.permissions.permission_profile() && self.active_permission_profile == config.permissions.active_permission_profile() && self.network == config.permissions.network - && self.approvals_reviewer == config.approvals_reviewer } fn turn_permission_profile(&self) -> Option<&PermissionProfile> { diff --git a/codex-rs/tui/src/app/agents_overview.rs b/codex-rs/tui/src/app/agents_overview.rs index 7a7fa7539b..89db4ec2dd 100644 --- a/codex-rs/tui/src/app/agents_overview.rs +++ b/codex-rs/tui/src/app/agents_overview.rs @@ -535,7 +535,10 @@ impl App { app_server, &mut resume_config, target_thread.cwd.as_path(), - Some(&target_thread), + crate::onboarding::DirectoryTrustOptions { + resumed_thread: Some(&target_thread), + ..Default::default() + }, /*startup_draft*/ None, ) .await @@ -549,14 +552,18 @@ impl App { loading::draw(tui)?; } let baseline_approval = resume_config.permissions.approval_policy.value(); + let baseline_reviewer = resume_config.approvals_reviewer; let baseline_permissions = RuntimePermissionProfileOverride::from_config(&resume_config); let resume_model_settings = match target_thread.status { codex_app_server_protocol::ThreadStatus::NotLoaded => { - self.apply_runtime_policy_overrides( + if let Err(error) = self.apply_runtime_policy_overrides( &mut resume_config, RuntimePolicyOverrideScope::ExplicitOnly, - ); + ) { + self.add_agents_overview_error(format!("{error:#}")); + return Ok(AppRunControl::Continue); + } if matches!(self.runtime_approval_policy_override, Some(RuntimeApprovalPolicyOverride::Explicit(policy)) if policy.to_core() != resume_config.permissions.approval_policy.value()) @@ -604,11 +611,12 @@ impl App { (blank, false) } else { match app_server - .resume_thread( + .resume_thread_with_permission_overrides( &local_settings, resume_config.clone(), root_thread_id, resume_model_settings, + self.resume_permission_overrides(&resume_config), ) .await { @@ -702,7 +710,11 @@ impl App { } } } - // Explicit choices carry across cold resumes and new sessions. + // Read-only views retain explicit choices but never another task's restored state. + let preserve_explicit_permissions = preserve_explicit_permissions || read_only; + self.runtime_approvals_reviewer_override = self + .runtime_approvals_reviewer_override + .filter(|_| preserve_explicit_permissions); self.runtime_approval_policy_override = self.runtime_approval_policy_override.filter(|policy| { preserve_explicit_permissions @@ -761,22 +773,27 @@ impl App { .set_workspace_roots(self.config.permissions.workspace_roots().to_vec()); } self.config = destination_config; - let approval = self.config.permissions.approval_policy.value(); - if self - .runtime_approval_policy_override - .is_none_or(|policy| policy.policy().to_core() != approval) - { - self.runtime_approval_policy_override = (approval != baseline_approval) - .then_some(RuntimeApprovalPolicyOverride::Restored(approval.into())); - } - if self - .runtime_permission_profile_override - .as_ref() - .is_none_or(|profile| !profile.matches_config(&self.config)) - { - self.runtime_permission_profile_override = (!baseline_permissions - .matches_config(&self.config)) - .then(|| RuntimePermissionProfileOverride::from_restored_config(&self.config)); + if !read_only { + let approval = self.config.permissions.approval_policy.value(); + if self + .runtime_approval_policy_override + .is_none_or(|policy| policy.policy().to_core() != approval) + { + self.runtime_approval_policy_override = (approval != baseline_approval) + .then_some(RuntimeApprovalPolicyOverride::Restored(approval.into())); + } + if self + .runtime_permission_profile_override + .as_ref() + .is_none_or(|profile| !profile.matches_config(&self.config)) + { + self.runtime_permission_profile_override = (!baseline_permissions + .matches_config(&self.config) + || baseline_reviewer != self.config.approvals_reviewer) + .then(|| { + RuntimePermissionProfileOverride::from_restored_config(&self.config) + }); + } } // A new session has no descendants. Scanning every loaded thread here // adds a serial round trip per agent before the composer can render. @@ -925,7 +942,7 @@ impl App { app_server, &mut config, &trust_cwd, - /*resumed_thread*/ None, + crate::onboarding::DirectoryTrustOptions::default(), startup_draft.as_deref_mut(), ) .await @@ -948,16 +965,14 @@ impl App { ); return None; } - // New sessions use the destination settings plus explicit user choices, not - // a permission snapshot inherited when attaching to another task. - self.apply_runtime_policy_overrides(&mut config, RuntimePolicyOverrideScope::ExplicitOnly); let defaults_cwd = match app_server.thread_params_mode() { crate::app_server_session::ThreadParamsMode::Embedded => config.cwd.as_path(), crate::app_server_session::ThreadParamsMode::Remote => remote_cwd .as_deref() .or_else(|| app_server.remote_cwd_override()) .unwrap_or(Path::new(".")), - }; + } + .to_path_buf(); if let Some(draft) = startup_draft.as_deref_mut() { draft.apply_config(&config); } @@ -967,12 +982,20 @@ impl App { tui, crate::config_update::read_effective_config_if_supported( app_server.request_handle(), - defaults_cwd, + &defaults_cwd, ), ) .await { Ok(Some(defaults)) => { + crate::projectless::apply_defaults( + &mut config, + &self.harness_overrides, + app_server, + &self.environment_manager, + &defaults, + ); + let defaults = defaults.config; server_model_cleared = defaults.model.is_none(); let use_server_provider = matches!( app_server.thread_params_mode(), @@ -1010,6 +1033,13 @@ impl App { return None; } } + // New sessions inherit explicit choices, not permissions restored from another task. + if let Err(error) = self + .apply_runtime_policy_overrides(&mut config, RuntimePolicyOverrideScope::ExplicitOnly) + { + self.add_agents_overview_error(format!("{error:#}")); + return None; + } apply_managed_new_thread_defaults( &mut config, app_server.managed_new_thread_defaults(), diff --git a/codex-rs/tui/src/app/agents_overview_tests.rs b/codex-rs/tui/src/app/agents_overview_tests.rs index 4abcfe41ed..b5f4d88924 100644 --- a/codex-rs/tui/src/app/agents_overview_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_tests.rs @@ -1733,6 +1733,7 @@ async fn overview_selection_applies_user_permissions_only_to_unloaded_threads() ) .await? .session; + app.runtime_approvals_reviewer_override = Some(ApprovalsReviewer::AutoReview); app.harness_overrides.sandbox_mode = Some(codex_protocol::config_types::SandboxMode::ReadOnly); app.harness_overrides.approval_policy = Some(codex_protocol::protocol::AskForApproval::UnlessTrusted); @@ -1777,6 +1778,7 @@ async fn overview_selection_applies_user_permissions_only_to_unloaded_threads() Some(codex_protocol::config_types::SandboxMode::WorkspaceWrite); app.harness_overrides.approval_policy = Some(codex_protocol::protocol::AskForApproval::Never); + app.runtime_approvals_reviewer_override = Some(app.config.approvals_reviewer); app.runtime_permission_profile_override = Some(RuntimePermissionProfileOverride::from_config(&app.config)); app.runtime_approval_policy_override = Some(RuntimeApprovalPolicyOverride::Explicit( @@ -1785,6 +1787,11 @@ async fn overview_selection_applies_user_permissions_only_to_unloaded_threads() } app.select_agents_overview_thread(&mut tui, &mut app_server, thread_id) .await?; + assert_eq!( + app.runtime_approvals_reviewer_override, + (thread_id == thread_ids[2] || thread_id == thread_ids[3]) + .then_some(ApprovalsReviewer::User) + ); let observed = app_server .resume_thread( &app.local_settings, @@ -1815,6 +1822,8 @@ async fn overview_selection_applies_user_permissions_only_to_unloaded_threads() "allowed_approvals_reviewers = [\"auto_review\"]\n", )?; app.loader_overrides.system_requirements_path = Some(requirements.to_path_buf()); + let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); + app.chat_widget.show_bottom_pane_view(Box::new(view)); app.select_agents_overview_thread(&mut tui, &mut app_server, thread_ids[4]) .await?; assert_eq!( @@ -1830,6 +1839,59 @@ async fn overview_selection_applies_user_permissions_only_to_unloaded_threads() codex_app_server_protocol::ThreadStatus::NotLoaded ), ); + let mut rendered = + crate::chatwidget::tests::helpers::render_bottom_popup(&app.chat_widget, /*width*/ 96); + let start = rendered.find("(set by ").expect("requirement source"); + let end = start + rendered[start..].find(')').expect("source end") + 1; + rendered.replace_range(start..end, "(set by )"); + insta::with_settings!({snapshot_path => "../snapshots"}, { + insta::assert_snapshot!("agents_overview_reviewer_conflict", rendered); + }); + // The picker must also abort when a session-only choice violates destination policy. + std::fs::write(&requirements, "allowed_approval_policies = [\"never\"]\n")?; + app.harness_overrides.approval_policy = Some(codex_protocol::protocol::AskForApproval::Never); + app.resume_target_session( + &mut tui, + &mut app_server, + SessionTarget { + path: None, + thread_id: thread_ids[4], + cwd: None, + history_mode: None, + }, + ) + .await?; + assert_eq!(app.primary_thread_id, Some(thread_ids[3])); + assert_eq!( + app_server + .thread_read(thread_ids[4], /*include_turns*/ false) + .await? + .status, + codex_app_server_protocol::ThreadStatus::NotLoaded, + ); + + app.loader_overrides.system_requirements_path = None; + app.runtime_permission_profile_override = None; + Box::pin(app.handle_event( + &mut tui, + &mut app_server, + AppEvent::UpdateApprovalsReviewer(ApprovalsReviewer::AutoReview), + )) + .await?; + app.select_agents_overview_thread(&mut tui, &mut app_server, thread_ids[4]) + .await?; + let resumed = app_server + .resume_thread( + &app.local_settings, + app.config.clone(), + thread_ids[4], + crate::app_server_session::ResumeModelSettings::PreserveExistingThread, + ) + .await?; + assert_eq!( + resumed.session.approvals_reviewer, + ApprovalsReviewer::AutoReview + ); app_server.shutdown().await?; Ok(()) } @@ -2432,6 +2494,18 @@ async fn command_center_attach_conflict_opens_read_only_and_retries() -> Result< .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID); let mut tui = crate::tui::test_support::make_test_tui()?; + app.config.approvals_reviewer = ApprovalsReviewer::AutoReview; + app.runtime_approvals_reviewer_override = Some(ApprovalsReviewer::AutoReview); + app.runtime_approval_policy_override = Some(RuntimeApprovalPolicyOverride::Explicit( + AskForApproval::Never, + )); + app.runtime_permission_profile_override = + Some(RuntimePermissionProfileOverride::from_config(&app.config)); + let expected_overrides = ( + app.runtime_approvals_reviewer_override, + app.runtime_approval_policy_override, + app.runtime_permission_profile_override.clone(), + ); app.chat_widget.handle_key_event(KeyCode::Right.into()); let event = rx.try_recv()?; assert!( @@ -2496,6 +2570,14 @@ async fn command_center_attach_conflict_opens_read_only_and_retries() -> Result< // An explicit retry remains read-only while the other server owns the task. Box::pin(app.handle_key_event(&mut tui, &mut server, KeyCode::Char('r').into())).await; assert!(app.chat_widget.is_external_writer_view()); + assert_eq!( + ( + app.runtime_approvals_reviewer_override, + app.runtime_approval_policy_override, + app.runtime_permission_profile_override.clone() + ), + expected_overrides, + ); assert_eq!( app.chat_widget.composer_text_with_pending(), "Retained task draft" diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index d04b74576d..00d2794e32 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -58,36 +58,43 @@ pub(super) fn has_explicit_resume_permission_override( config: &Config, overrides: &ConfigOverrides, ) -> bool { - overrides.approval_policy.is_some() - || overrides.approvals_reviewer.is_some() - || overrides.sandbox_mode.is_some() - || overrides.permission_profile.is_some() - || overrides.default_permissions.is_some() - || !overrides.additional_writable_roots.is_empty() - || overrides.workspace_roots.is_some() - || config.config_layer_stack.layers_high_to_low().any(|layer| { - matches!( - &layer.name, - ConfigLayerSource::SessionFlags - | ConfigLayerSource::User { - profile: Some(_), - .. - } - ) && [ - "approval_policy", - "approvals_reviewer", - "sandbox_mode", - "default_permissions", - "permissions", - "network", - "sandbox_workspace_write", - ] - .iter() - .any(|key| layer.config.get(*key).is_some()) - }) + // A directory choice is supported remotely even when permission overrides are not. + let selected = crate::resume_permissions::ResumePermissions::from_overrides( + config, + &ConfigOverrides { + cwd: None, + ..overrides.clone() + }, + ); + selected.approval_policy + || selected.approvals_reviewer + || selected.profile + || selected.workspace_roots } impl App { + pub(super) fn resume_permission_overrides( + &self, + config: &Config, + ) -> crate::resume_permissions::ResumePermissions { + let mut permission_overrides = crate::resume_permissions::ResumePermissions::from_overrides( + config, + &self.harness_overrides, + ); + permission_overrides.approvals_reviewer |= + self.runtime_approvals_reviewer_override.is_some(); + permission_overrides.approval_policy |= matches!( + self.runtime_approval_policy_override, + Some(RuntimeApprovalPolicyOverride::Explicit(_)) + ); + if let Some(profile) = self.runtime_permission_profile_override.as_ref() + && profile.turn_override == RuntimePermissionProfileTurnOverride::LegacySandbox + { + permission_overrides.profile = true; + } + permission_overrides + } + pub(super) async fn rebuild_config_for_cwd(&self, cwd: PathBuf) -> Result { let mut overrides = self.harness_overrides.clone(); overrides.cwd = Some(cwd.clone()); @@ -227,6 +234,8 @@ impl App { .selected_permission_profiles .insert(thread_id, profile_id.clone()); } + self.runtime_approvals_reviewer_override = + approvals_reviewer.or(self.runtime_approvals_reviewer_override); self.runtime_permission_profile_override = Some(RuntimePermissionProfileOverride::from_config(&self.config)); self.sync_active_thread_permission_settings_to_cached_session() @@ -409,7 +418,7 @@ impl App { let mut config = self .rebuild_config_for_cwd(self.chat_widget.config_ref().cwd.to_path_buf()) .await?; - self.apply_runtime_policy_overrides(&mut config, RuntimePolicyOverrideScope::All); + self.apply_runtime_policy_overrides(&mut config, RuntimePolicyOverrideScope::All)?; self.local_settings = self.local_settings.reloaded(&config); self.refresh_server_version_overview_notice(CODEX_CLI_VERSION); // Other preferences have runtime caches and are adopted when the widget is replaced. @@ -485,7 +494,7 @@ impl App { &mut self, config: &mut Config, scope: RuntimePolicyOverrideScope, - ) { + ) -> Result<()> { if let Some(policy) = self.runtime_approval_policy_override && (scope == RuntimePolicyOverrideScope::All || matches!(policy, RuntimeApprovalPolicyOverride::Explicit(_))) @@ -494,27 +503,38 @@ impl App { .approval_policy .set(policy.policy().to_core()) { + if scope == RuntimePolicyOverrideScope::ExplicitOnly { + return Err(err).wrap_err("Failed to carry forward approval policy override"); + } tracing::warn!(%err, "failed to carry forward approval policy override"); self.chat_widget.add_error_message(format!( "Failed to carry forward approval policy override: {err}" )); } - if let Some(profile_override) = self.runtime_permission_profile_override.as_ref() - && (scope == RuntimePolicyOverrideScope::All - || profile_override.turn_override - == RuntimePermissionProfileTurnOverride::LegacySandbox) - { - match config + let profile_override = + self.runtime_permission_profile_override + .as_ref() + .filter(|profile| { + scope == RuntimePolicyOverrideScope::All + || profile.turn_override + == RuntimePermissionProfileTurnOverride::LegacySandbox + }); + if let Some(reviewer) = self.runtime_approvals_reviewer_override.or_else(|| { + profile_override + .filter(|profile| { + profile.turn_override == RuntimePermissionProfileTurnOverride::Preserve + }) + .map(|_| self.config.approvals_reviewer) + }) { + config .config_layer_stack .requirements() .approvals_reviewer - .can_set(&profile_override.approvals_reviewer) - { - Ok(()) => config.approvals_reviewer = profile_override.approvals_reviewer, - Err(error) => self.chat_widget.add_error_message(format!( - "Failed to carry forward approvals reviewer: {error}" - )), - } + .can_set(&reviewer) + .wrap_err("Failed to carry forward approvals reviewer")?; + config.approvals_reviewer = reviewer; + } + if let Some(profile_override) = profile_override { match config .permissions .set_permission_profile_from_session_snapshot( @@ -527,6 +547,10 @@ impl App { config.permissions.network = profile_override.network.clone(); } Err(err) => { + if scope == RuntimePolicyOverrideScope::ExplicitOnly { + return Err(err) + .wrap_err("Failed to carry forward permission profile override"); + } tracing::warn!(%err, "failed to carry forward permission profile override"); self.chat_widget.add_error_message(format!( "Failed to carry forward permission profile override: {err}" @@ -534,6 +558,7 @@ impl App { } } } + Ok(()) } pub(super) fn set_approvals_reviewer_in_app_and_widget(&mut self, reviewer: ApprovalsReviewer) { @@ -770,6 +795,7 @@ impl App { self.chat_widget.add_memories_enable_notice(); } if approvals_reviewer_override.is_some() { + self.runtime_approvals_reviewer_override = approvals_reviewer_override; self.set_approvals_reviewer_in_app_and_widget(self.config.approvals_reviewer); } if approval_policy_override.is_some() { @@ -798,6 +824,7 @@ impl App { .add_error_message(format!("Failed to enable Approve for me: {err}")); } if permission_profile_override.is_some() { + self.runtime_approvals_reviewer_override = Some(self.config.approvals_reviewer); self.runtime_permission_profile_override = Some(RuntimePermissionProfileOverride::from_config(&self.config)); } @@ -1059,6 +1086,7 @@ impl App { } let explicitly_selected = has_explicit_resume_permission_override(config, &self.harness_overrides) + || self.runtime_approvals_reviewer_override.is_some() || matches!( self.runtime_approval_policy_override, Some(RuntimeApprovalPolicyOverride::Explicit(_)) @@ -1208,6 +1236,7 @@ impl App { return; } + self.runtime_approvals_reviewer_override = Some(self.config.approvals_reviewer); self.runtime_permission_profile_override = Some(RuntimePermissionProfileOverride::from_config(&self.config)); self.sync_active_thread_permission_settings_to_cached_session() diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index b03492d89e..a3af86385d 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -2462,6 +2462,7 @@ impl App { .add_error_message(format!("Failed to set permission profile: {err}")); return Ok(AppRunControl::Continue); } + self.runtime_approvals_reviewer_override = Some(self.config.approvals_reviewer); self.runtime_permission_profile_override = Some(RuntimePermissionProfileOverride::from_config(&self.config)); self.sync_active_thread_permission_settings_to_cached_session() @@ -2475,11 +2476,9 @@ impl App { if self.reject_pending_permission_change() { return Ok(AppRunControl::Continue); } + self.runtime_approvals_reviewer_override = Some(policy); self.config.approvals_reviewer = policy; self.chat_widget.set_approvals_reviewer(policy); - if let Some(profile) = self.runtime_permission_profile_override.as_mut() { - profile.approvals_reviewer = policy; - } self.sync_active_thread_permission_settings_to_cached_session() .await; if let Err(err) = crate::config_update::write_config_batch( diff --git a/codex-rs/tui/src/app/managed_worktree_creation.rs b/codex-rs/tui/src/app/managed_worktree_creation.rs index f541836524..3fd35cdf7b 100644 --- a/codex-rs/tui/src/app/managed_worktree_creation.rs +++ b/codex-rs/tui/src/app/managed_worktree_creation.rs @@ -32,6 +32,31 @@ pub(super) fn background_terminals_blocker( } } +pub(super) async fn check_background_terminals( + app_server: &mut AppServerSession, + target: &AppServerTarget, + thread_ids: impl IntoIterator, +) -> Option<&'static str> { + for thread_id in thread_ids { + let request = ClientRequest::ThreadBackgroundTerminalsList { + request_id: app_server.next_request_id(), + params: ThreadBackgroundTerminalsListParams { + thread_id: thread_id.to_string(), + cursor: None, + limit: Some(1), + }, + }; + let result = app_server + .request_handle() + .request_typed::(request) + .await; + if let Some(message) = background_terminals_blocker(result, target) { + return Some(message); + } + } + None +} + impl App { pub(super) async fn start_managed_worktree( &mut self, @@ -132,23 +157,10 @@ impl App { .iter() .filter_map(|(id, agent)| (!agent.is_closed).then_some(*id)), ); - for tracked_id in ids { - let request = ClientRequest::ThreadBackgroundTerminalsList { - request_id: app_server.next_request_id(), - params: ThreadBackgroundTerminalsListParams { - thread_id: tracked_id.to_string(), - cursor: None, - limit: Some(1), - }, - }; - let result = app_server - .request_handle() - .request_typed::(request) - .await; - if let Some(message) = background_terminals_blocker(result, &self.app_server_target) - { - return self.working_directory_error(message); - } + if let Some(message) = + check_background_terminals(app_server, &self.app_server_target, ids).await + { + return self.working_directory_error(message); } let setup = async { let source = self diff --git a/codex-rs/tui/src/app/misalignment_policy.rs b/codex-rs/tui/src/app/misalignment_policy.rs index 3f3dbad870..97554a030d 100644 --- a/codex-rs/tui/src/app/misalignment_policy.rs +++ b/codex-rs/tui/src/app/misalignment_policy.rs @@ -115,12 +115,16 @@ impl App { return; }; let config = self.chat_widget.config_ref(); - let permissions_override = Self::turn_permissions_override_from_config( - config, - config.permissions.active_permission_profile().as_ref(), + let explicit_profile = self.runtime_permission_profile_override .as_ref() - .and_then(RuntimePermissionProfileOverride::turn_permission_profile), + .filter(|profile| { + profile.turn_override == RuntimePermissionProfileTurnOverride::LegacySandbox + }); + let permissions_override = Self::turn_permissions_override_from_config( + config, + explicit_profile.and_then(|profile| profile.active_permission_profile.as_ref()), + explicit_profile.and_then(RuntimePermissionProfileOverride::turn_permission_profile), ); let Ok((sandbox_policy, permissions)) = turn_permissions_overrides(permissions_override, config.cwd.as_path()) diff --git a/codex-rs/tui/src/app/new_session.rs b/codex-rs/tui/src/app/new_session.rs index fde119b1c2..d09aaa3fd6 100644 --- a/codex-rs/tui/src/app/new_session.rs +++ b/codex-rs/tui/src/app/new_session.rs @@ -67,12 +67,21 @@ impl App { self.config.clone() } }; - self.apply_runtime_policy_overrides(&mut config, RuntimePolicyOverrideScope::All); + if let Some(defaults) = defaults.as_ref() { + crate::projectless::apply_defaults( + &mut config, + &self.harness_overrides, + app_server, + &self.environment_manager, + defaults, + ); + } + self.apply_runtime_policy_overrides(&mut config, RuntimePolicyOverrideScope::ExplicitOnly)?; config.service_tier = self.chat_widget.configured_service_tier(); if let Some(defaults) = defaults.as_ref() { overlay_new_session_defaults( &mut config, - defaults, + &defaults.config, &self.cli_kv_overrides, &self.harness_overrides, ); diff --git a/codex-rs/tui/src/app/realtime_settings.rs b/codex-rs/tui/src/app/realtime_settings.rs index ea1491fd9a..2f31148ee6 100644 --- a/codex-rs/tui/src/app/realtime_settings.rs +++ b/codex-rs/tui/src/app/realtime_settings.rs @@ -58,7 +58,7 @@ impl App { .await?; config .as_ref() - .map(|config| configured_voice(config, voices.default_v1)) + .map(|config| configured_voice(&config.config, voices.default_v1)) .transpose() .map(Option::flatten) } @@ -107,7 +107,7 @@ impl App { let effective = crate::config_update::read_effective_config_if_supported( app_server.request_handle(), &self.chat_widget.config_ref().cwd, ).await.and_then(|config| match config.as_ref() { - Some(config) => configured_voice(config, voices.default_v1), + Some(config) => configured_voice(&config.config, voices.default_v1), None if response.status == codex_app_server_protocol::WriteStatus::OkOverridden => Err(color_eyre::eyre::eyre!("the saved voice is overridden, but this server cannot read effective settings")), None => Ok(Some(voice)), }); diff --git a/codex-rs/tui/src/app/reconnect.rs b/codex-rs/tui/src/app/reconnect.rs index 61f7edc1e3..e362bb20a4 100644 --- a/codex-rs/tui/src/app/reconnect.rs +++ b/codex-rs/tui/src/app/reconnect.rs @@ -324,16 +324,16 @@ impl App { .with_collaboration_modes(bootstrap.collaboration_modes), ); self.pending_app_server_requests.clear(); - let pending_displayed_profile = - displayed.is_some_and(|id| self.pending_server_profiles.contains_key(&id)); - if pending_displayed_profile { - self.runtime_approval_policy_override = None; - self.runtime_permission_profile_override = None; - } // The displayed task was resumed above. Keep offscreen selections pending until those // tasks can be resumed from the server too; their old confirmations cannot arrive. - if let Some(id) = displayed { - self.pending_server_profiles.remove(&id); + let pending_displayed_profile = + displayed.and_then(|id| self.pending_server_profiles.remove(&id)); + if let Some(selection) = &pending_displayed_profile { + self.runtime_approval_policy_override = None; + if selection.approvals_reviewer.is_some() { + self.runtime_approvals_reviewer_override = None; + } + self.runtime_permission_profile_override = None; } self.pending_primary_events.clear(); self.pending_plugin_enabled_writes.clear(); @@ -383,7 +383,7 @@ impl App { } if let Some(mut started) = thread { let id = started.session.thread_id; - if !pending_displayed_profile + if pending_displayed_profile.is_none() && let Some(channel) = self.thread_event_channels.get(&id) && let Some(cached) = channel.store.lock().await.session.as_ref() { diff --git a/codex-rs/tui/src/app/resume_config.rs b/codex-rs/tui/src/app/resume_config.rs index d1eea17889..3ccce3cb47 100644 --- a/codex-rs/tui/src/app/resume_config.rs +++ b/codex-rs/tui/src/app/resume_config.rs @@ -135,7 +135,10 @@ impl App { app_server, &mut resume_config.0, &trust_cwd, - resumed_thread.as_ref(), + crate::onboarding::DirectoryTrustOptions { + resumed_thread: resumed_thread.as_ref(), + ..Default::default() + }, /*startup_draft*/ None, ) .await?; @@ -149,7 +152,7 @@ impl App { app_server: &mut AppServerSession, config: &mut Config, cwd: &Path, - resumed_thread: Option<&codex_app_server_protocol::Thread>, + options: crate::onboarding::DirectoryTrustOptions<'_>, mut startup_draft: Option<&mut StartupDraftPump>, ) -> std::result::Result<(), AppRunControl> { // Keep the existing explicit remote --cd gate, including retries after cancellation. @@ -168,7 +171,7 @@ impl App { config, &self.app_server_target, cwd, - resumed_thread, + options, startup_draft.as_deref_mut(), ) .await @@ -177,6 +180,9 @@ impl App { AppRunControl::Continue })?; if result.should_exit { + if options.cancel == Some(crate::onboarding::TrustCancelAction::CurrentTask) { + return Err(AppRunControl::Continue); + } if matches!(self.app_server_target, AppServerTarget::Embedded) { return Err(AppRunControl::Exit(ExitReason::UserRequested)); } @@ -189,7 +195,10 @@ impl App { } return Err(AppRunControl::Continue); } - if result.directory_trust_persisted && !app_server.uses_remote_workspace() { + if !app_server.uses_remote_workspace() + && (result.directory_trust_persisted + || options.cancel == Some(crate::onboarding::TrustCancelAction::CurrentTask)) + { *config = StartupDraftPump::run_with_optional_draft( startup_draft.as_deref_mut(), tui, @@ -202,7 +211,11 @@ impl App { )); AppRunControl::Continue })?; - if resumed_thread.is_none() { + // Directory changes review hooks once, after attaching the destination. + if result.directory_trust_persisted + && options.resumed_thread.is_none() + && options.cancel != Some(crate::onboarding::TrustCancelAction::CurrentTask) + { let load_hooks = load_startup_hooks_review_entry( app_server.request_handle(), config.cwd.to_path_buf(), diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index 6e73c002e4..545577d840 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -1297,19 +1297,25 @@ impl App { Ok(config) => config, Err(control) => return Ok(control), }; - let baseline_approval = resume_config.permissions.approval_policy.value(); - let baseline_permissions = RuntimePermissionProfileOverride::from_config(&resume_config); - self.apply_runtime_policy_overrides(&mut resume_config, RuntimePolicyOverrideScope::All); + if let Err(error) = self.apply_runtime_policy_overrides( + &mut resume_config, + RuntimePolicyOverrideScope::ExplicitOnly, + ) { + self.add_session_picker_error(format!("{error:#}")); + return Ok(AppRunControl::Continue); + } + let permission_overrides = self.resume_permission_overrides(&resume_config); if let Some(history_mode) = target_session.history_mode { app_server.remember_thread_history_mode(target_session.thread_id, history_mode); } let resumed = app_server - .resume_thread( + .resume_thread_with_permission_overrides( &local_settings, resume_config.clone(), target_session.thread_id, self.resume_model_settings(), + permission_overrides, ) .await; let mut history_notice = None; @@ -1381,16 +1387,22 @@ impl App { .add_info_message(notice.to_string(), /*hint*/ None); } } - if self.app_server_target.uses_remote_workspace() { - let config = self.chat_widget.config_ref(); - let approval = config.permissions.approval_policy.value(); - self.runtime_approval_policy_override = (approval != baseline_approval) - .then_some(RuntimeApprovalPolicyOverride::Restored(approval.into())); - self.runtime_permission_profile_override = (!baseline_permissions - .matches_config(config)) - .then(|| RuntimePermissionProfileOverride::from_restored_config(config)); - } self.backfill_loaded_subagent_threads(app_server).await; + if matches!( + self.runtime_approval_policy_override, + Some(RuntimeApprovalPolicyOverride::Restored(_)) + ) { + self.runtime_approval_policy_override = None; + } + if self + .runtime_permission_profile_override + .as_ref() + .is_some_and(|profile| { + profile.turn_override == RuntimePermissionProfileTurnOverride::Preserve + }) + { + self.runtime_permission_profile_override = None; + } if !read_only { self.replay_agents_overview_requests(app_server, resumed_thread_id) .await; diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs index f9da39c26c..7799eb5664 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -44,12 +44,19 @@ fn spawn_startup_thread_start( }); } +#[derive(Default)] +pub(super) struct FreshStartupDefaults { + pub(super) server_defaults_read: bool, + pub(super) prompt_windows_sandbox: bool, +} + pub(super) async fn prepare_fresh_startup_config( config: &mut Config, app_server: &AppServerSession, cli_kv_overrides: &[(String, TomlValue)], harness_overrides: &ConfigOverrides, -) -> Result { + environments: &EnvironmentManager, +) -> Result { let defaults_cwd = match app_server.thread_params_mode() { crate::app_server_session::ThreadParamsMode::Embedded => config.cwd.as_path(), crate::app_server_session::ThreadParamsMode::Remote => { @@ -61,13 +68,21 @@ pub(super) async fn prepare_fresh_startup_config( defaults_cwd, ) .await?; + let mut prompt_windows_sandbox = false; if let Some(defaults) = defaults.as_ref() { super::new_session::overlay_new_session_defaults( config, - defaults, + &defaults.config, cli_kv_overrides, harness_overrides, ); + prompt_windows_sandbox = crate::projectless::apply_defaults( + config, + harness_overrides, + app_server, + environments, + defaults, + ); } apply_managed_new_thread_defaults( config, @@ -75,7 +90,10 @@ pub(super) async fn prepare_fresh_startup_config( cli_kv_overrides, harness_overrides, ); - Ok(defaults.is_some()) + Ok(FreshStartupDefaults { + server_defaults_read: defaults.is_some(), + prompt_windows_sandbox, + }) } pub(super) fn startup_model( @@ -242,7 +260,7 @@ impl App { config.model_reasoning_effort = None; } } - let server_defaults_read = if matches!( + let startup_defaults = if matches!( &session_selection, SessionSelection::StartFresh | SessionSelection::Exit ) { @@ -254,16 +272,17 @@ impl App { &app_server, &cli_kv_overrides, &harness_overrides, + &environment_manager, ), ) .await { - Ok(Ok(defaults_read)) => defaults_read, + Ok(Ok(defaults)) => defaults, Ok(Err(err)) => return shutdown_on_startup_error(app_server, err).await, Err(err) => return shutdown_on_startup_error(app_server, err).await, } } else { - false + FreshStartupDefaults::default() }; if matches!(&session_selection, SessionSelection::AgentsOverview) { apply_managed_new_thread_defaults( @@ -273,7 +292,7 @@ impl App { &harness_overrides, ); } - let mut model = startup_model(&config, &bootstrap, server_defaults_read); + let mut model = startup_model(&config, &bootstrap, startup_defaults.server_defaults_read); let available_models = bootstrap.available_models; let remote_connection = crate::status::remote_connection::remote_connection_status_value( &app_server_target, @@ -486,11 +505,15 @@ impl App { let resumed = match startup_draft .run_until( tui, - app_server.resume_thread( + app_server.resume_thread_with_permission_overrides( &local_settings, config.clone(), target_session.thread_id, model_settings, + crate::resume_permissions::ResumePermissions::from_overrides( + &config, + &harness_overrides, + ), ), ) .await @@ -525,7 +548,13 @@ impl App { Err(err) => return shutdown_on_startup_error(app_server, err).await, } } else { - let action = SessionStartAction::Resume(model_settings); + let action = SessionStartAction::Resume( + model_settings, + crate::resume_permissions::ResumePermissions::from_overrides( + &config, + &harness_overrides, + ), + ); let resumed = complete_session_start( &mut app_server, SessionStartConfig { @@ -760,6 +789,7 @@ See the Codex keymap documentation for supported actions and examples." loader_overrides, cloud_config_bundle, runtime_approval_policy_override: None, + runtime_approvals_reviewer_override: None, runtime_permission_profile_override: None, pending_server_profiles: HashMap::new(), file_search, @@ -803,7 +833,8 @@ See the Codex keymap documentation for supported actions and examples." pending_update_action: None, pending_shutdown_exit_thread_id: None, windows_sandbox: WindowsSandboxState { - prompt_after_trust: should_prompt_windows_sandbox_nux_at_startup, + prompt_after_trust: should_prompt_windows_sandbox_nux_at_startup + || startup_defaults.prompt_windows_sandbox, ..Default::default() }, thread_event_channels: HashMap::new(), diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index 2dc84abf48..135c411dd1 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -34,6 +34,7 @@ pub(crate) async fn make_test_app() -> App { loader_overrides: LoaderOverrides::without_managed_config_for_tests(), cloud_config_bundle: CloudConfigBundleLoader::default(), runtime_approval_policy_override: None, + runtime_approvals_reviewer_override: None, runtime_permission_profile_override: None, pending_server_profiles: HashMap::new(), file_search, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 0c1fb8f4f4..65271708dc 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -56,6 +56,8 @@ mod pagination_completion_tests; mod patch_approval_tests; #[path = "tests/permission_selection_tests.rs"] mod permission_selection_tests; +#[path = "tests/projectless_tests.rs"] +mod projectless_tests; #[path = "tests/unavailable_commands_tests.rs"] mod unavailable_commands; @@ -3083,7 +3085,7 @@ async fn server_only_profile_selection_keeps_turns_on_the_selected_profile() -> let selection = PermissionProfileSelection { profile_id: "server-only".into(), approval_policy: None, - approvals_reviewer: None, + approvals_reviewer: Some(ApprovalsReviewer::User), display_label: "server-only".into(), }; app.select_permission_profile(&mut server, selection.clone()) @@ -3096,6 +3098,9 @@ async fn server_only_profile_selection_keeps_turns_on_the_selected_profile() -> let thread_id = started.session.thread_id; app.enqueue_primary_thread_session(started.session, started.turns) .await?; + app.runtime_approvals_reviewer_override = Some(ApprovalsReviewer::AutoReview); + app.runtime_permission_profile_override = + Some(RuntimePermissionProfileOverride::from_config(&app.config)); while events.try_recv().is_ok() {} app.select_permission_profile(&mut server, selection.clone()) .await; @@ -3104,7 +3109,8 @@ async fn server_only_profile_selection_keeps_turns_on_the_selected_profile() -> @"• Permission selection requested: server-only" ); let mut tui = crate::tui::test_support::make_test_tui()?; - app.select_permission_profile(&mut server, selection).await; + app.select_permission_profile(&mut server, selection.clone()) + .await; insta::assert_snapshot!( next_history_message(&mut events), @"■ Wait for permissions to update before changing permissions." @@ -3134,6 +3140,32 @@ async fn server_only_profile_selection_keeps_turns_on_the_selected_profile() -> ) .await?; assert!(!app.pending_server_profiles.contains_key(&thread_id)); + assert_eq!( + ( + app.runtime_approvals_reviewer_override, + app.chat_widget.config_ref().approvals_reviewer + ), + (None, ApprovalsReviewer::User), + ); + app.runtime_approvals_reviewer_override = Some(ApprovalsReviewer::User); + app.select_permission_profile( + &mut server, + PermissionProfileSelection { + approvals_reviewer: None, + ..selection.clone() + }, + ) + .await; + let settings = next_thread_settings_updated(&mut server, thread_id).await; + app.enqueue_thread_notification( + thread_id, + ServerNotification::ThreadSettingsUpdated(settings), + ) + .await?; + assert_eq!( + app.runtime_approvals_reviewer_override, + Some(ApprovalsReviewer::User), + ); let endpoint = crate::resolve_remote_addr("ws://127.0.0.1:8765")?; app.app_server_target = crate::AppServerTarget::Remote { endpoint }; while events.try_recv().is_ok() {} @@ -3189,33 +3221,6 @@ async fn server_only_profile_selection_keeps_turns_on_the_selected_profile() -> Ok(()) } -#[tokio::test] -async fn resume_rejects_selected_config_profile_permission_definitions() -> Result<()> { - let home = tempdir()?; - std::fs::write( - home.path().join("config.toml"), - "default_permissions = \":workspace\"\n", - )?; - let selected = home.path().join("work.config.toml"); - std::fs::write( - &selected, - "default_permissions = \":workspace\"\n[permissions.unused]\nextends = \":read-only\"\n", - )?; - let mut loader = codex_config::LoaderOverrides::without_managed_config_for_tests(); - loader.user_config_path = Some(selected.abs()); - loader.user_config_profile = Some("work".parse()?); - let config = ConfigBuilder::default() - .codex_home(home.path().to_path_buf()) - .loader_overrides(loader) - .build() - .await?; - assert!(config_persistence::has_explicit_resume_permission_override( - &config, - &ConfigOverrides::default() - )); - Ok(()) -} - #[tokio::test] async fn remote_resume_rejects_explicit_permission_override() -> Result<()> { let (mut app, mut events, _ops) = make_test_app_with_channels().await; @@ -3228,13 +3233,8 @@ async fn remote_resume_rejects_explicit_permission_override() -> Result<()> { )?; app.config.codex_home = home.path().to_path_buf().abs(); let mut server = crate::start_embedded_app_server_for_picker(&app.config).await?; - let selected = home.path().join("work.config.toml"); - std::fs::write( - &selected, - "[permissions.unused]\nextends = \":read-only\"\n", - )?; - app.loader_overrides.user_config_path = Some(selected.abs()); - app.loader_overrides.user_config_profile = Some("work".parse()?); + app.cli_kv_overrides + .push(("default_permissions".into(), ":workspace".into())); assert!( !config_persistence::has_explicit_resume_permission_override( &app.config, @@ -3294,6 +3294,7 @@ default_permissions = "locked-down" app.harness_overrides.sandbox_mode = Some(SandboxMode::WorkspaceWrite); app.harness_overrides.permission_profile = Some(PermissionProfile::workspace_write()); + app.set_approvals_reviewer_in_app_and_widget(ApprovalsReviewer::AutoReview); assert!( app.apply_permission_profile_selection(PermissionProfileSelection { profile_id: "locked-down".to_string(), @@ -3325,6 +3326,14 @@ default_permissions = "locked-down" app.runtime_permission_profile_override, Some(RuntimePermissionProfileOverride::from_config(&app.config)) ); + let mut destination = app.config.clone(); + destination.approvals_reviewer = ApprovalsReviewer::User; + app.apply_runtime_policy_overrides(&mut destination, RuntimePolicyOverrideScope::ExplicitOnly)?; + assert_eq!(destination.approvals_reviewer, ApprovalsReviewer::User); + assert!( + !app.resume_permission_overrides(&destination) + .approvals_reviewer + ); let op = match app_event_rx.try_recv() { Ok(AppEvent::CodexOp(op)) => op, other => panic!("expected CodexOp event, got {other:?}"), @@ -6001,6 +6010,7 @@ async fn make_test_app() -> Box { loader_overrides: LoaderOverrides::without_managed_config_for_tests(), cloud_config_bundle: CloudConfigBundleLoader::default(), runtime_approval_policy_override: None, + runtime_approvals_reviewer_override: None, runtime_permission_profile_override: None, file_search, transcript_cells: Vec::new(), @@ -6117,6 +6127,7 @@ pub(super) async fn make_test_app_with_channels() -> ( loader_overrides: LoaderOverrides::without_managed_config_for_tests(), cloud_config_bundle: CloudConfigBundleLoader::default(), runtime_approval_policy_override: None, + runtime_approvals_reviewer_override: None, runtime_permission_profile_override: None, file_search, transcript_cells: Vec::new(), diff --git a/codex-rs/tui/src/app/tests/active_reconnect_tests.rs b/codex-rs/tui/src/app/tests/active_reconnect_tests.rs index a0e061ba1b..8f9c547618 100644 --- a/codex-rs/tui/src/app/tests/active_reconnect_tests.rs +++ b/codex-rs/tui/src/app/tests/active_reconnect_tests.rs @@ -205,6 +205,7 @@ async fn reconnect_restores_history_permissions_and_resumes_unsent_input() -> Re ); let mut tui = crate::tui::test_support::make_test_tui()?; if pending_profile { + app.runtime_approvals_reviewer_override = Some(ApprovalsReviewer::User); app.pending_server_profiles.insert( id, PermissionProfileSelection { @@ -302,6 +303,15 @@ async fn reconnect_restores_history_permissions_and_resumes_unsent_input() -> Re ); } assert!(app.pending_server_profiles.is_empty()); + if pending_profile { + assert_eq!( + app.resume_permission_overrides(&app.config), + crate::resume_permissions::ResumePermissions { + approvals_reviewer: true, + ..Default::default() + } + ); + } assert!(!app.pending_managed_worktree_creation); assert!( !app.agents_overview @@ -518,7 +528,7 @@ async fn reconnect_reconciles_offscreen_pending_profile_before_restoring_permiss "thread/read" => json!({"result": {"thread": thread(primary)}}), "turn/start" => { let params = request.params.as_ref().unwrap(); - assert_eq!(params["permissions"], "server-only"); + assert_eq!(params["permissions"], serde_json::Value::Null); assert_eq!(params["approvalPolicy"], "on-request"); assert_eq!(params["sandboxPolicy"], json!(null)); json!({"result": {"turn": {"id": "fresh", "items": [], "status": "inProgress"}}}) diff --git a/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs b/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs index f9b572d5e8..16e4f8d56a 100644 --- a/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs +++ b/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs @@ -31,6 +31,17 @@ async fn confirm_permission_selection( } fn trust_launch_folder(app: &mut App) { + for cwd in [ + app.config.cwd.to_path_buf(), + app.chat_widget.config_ref().cwd.to_path_buf(), + ] { + crate::legacy_core::config::set_project_trust_level( + &app.config.codex_home, + cwd.as_path(), + codex_protocol::config_types::TrustLevel::Trusted, + ) + .expect("persist trusted fixture folder"); + } let projects = serde_json::json!({ app.config.cwd.display().to_string(): {"trust_level": "trusted"}, app.chat_widget.config_ref().cwd.display().to_string(): {"trust_level": "trusted"}, @@ -363,11 +374,11 @@ async fn command_center_new_reads_server_defaults_for_actual_destination() -> Re "." => ".".to_string(), _ => unreachable!(), }; - assert_eq!( - recorded_params(&requests, "config/read"), - vec![serde_json::json!({"cwd": cwd})], - "{mode} {expected_cwd}" - ); + let reads = recorded_params(&requests, "config/read") + .into_iter() + .filter(|params| params["includeLayers"] == true) + .collect::>(); + assert_eq!(reads.last().expect("defaults read")["cwd"], cwd); let starts = recorded_params(&requests, "thread/start"); assert_eq!(starts.len(), 1, "{mode} {expected_cwd}"); assert_eq!( @@ -470,6 +481,17 @@ async fn command_center_new_preserves_explicit_choices_and_managed_defaults() -> .build() .await?; trust_launch_folder(&mut app); + for cwd in [ + app.config.cwd.as_path(), + app.chat_widget.config_ref().cwd.as_path(), + ] { + crate::legacy_core::config::set_project_trust_level( + server_home.path(), + cwd, + codex_protocol::config_types::TrustLevel::Trusted, + ) + .map_err(std::io::Error::other)?; + } let mut server_config = app.config.clone(); server_config.codex_home = server_home.path().to_path_buf().abs(); server_config.sqlite = SqliteConfig::new_for_testing(server_home.path().abs()); @@ -520,8 +542,6 @@ async fn command_center_new_read_failure_keeps_overview_and_does_not_start() -> for capability in [ HistoryCapabilities::ConfigReadFails, HistoryCapabilities::ThreadStartFails, - HistoryCapabilities::ConfigReadUnsupported(-32600), - HistoryCapabilities::ConfigReadUnsupported(-32601), ] { let (mut app, mut events, _) = make_test_app_with_channels().await; trust_launch_folder(&mut app); @@ -541,48 +561,44 @@ async fn command_center_new_read_failure_keeps_overview_and_does_not_start() -> app.local_settings.tui.status_line_use_colors = source_colors; app.new_agents_overview_session(&mut tui, &mut server, /*cwd*/ None) .await?; - let failed = matches!( - capability, - HistoryCapabilities::ConfigReadFails | HistoryCapabilities::ThreadStartFails - ); - assert_eq!(recorded_params(&requests, "config/read").len(), 1); + let reads = recorded_params(&requests, "config/read") + .into_iter() + .filter(|params| params["includeLayers"] == true) + .collect::>(); + assert!(!reads.is_empty()); + assert!(reads.iter().all(|params| params["cwd"] == reads[0]["cwd"])); assert_eq!( recorded_params(&requests, "thread/start").len(), usize::from(capability != HistoryCapabilities::ConfigReadFails) ); assert_eq!(recorded_params(&requests, "turn/start").len(), 0); - if failed { - assert_eq!(app.local_settings.tui.status_line_use_colors, source_colors); - assert!(app.agents_overview.dispatched_requests.is_empty()); - assert!( - app.chat_widget - .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID) - .is_some() - ); - let error = std::iter::from_fn(|| events.try_recv().ok()) - .filter_map(|event| match event { - AppEvent::InsertHistoryCell(cell) => { - Some(lines_to_single_string(&cell.display_lines(/*width*/ 80))) - } - _ => None, - }) - .find(|message| message.contains("Failed to")) - .expect("visible read error"); - if capability == HistoryCapabilities::ConfigReadFails { - insta::assert_snapshot!(error, @"■ Failed to load new session settings: config/read failed in TUI"); - } else { - insta::assert_snapshot!( - "command_center_session_start_error", - crate::chatwidget::tests::helpers::render_bottom_popup( - &app.chat_widget, - /*width*/ 80, - ) - ); - } + assert_eq!(app.local_settings.tui.status_line_use_colors, source_colors); + assert!(app.agents_overview.dispatched_requests.is_empty()); + assert!( + app.chat_widget + .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID) + .is_some() + ); + let error = std::iter::from_fn(|| events.try_recv().ok()) + .filter_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => { + Some(lines_to_single_string(&cell.display_lines(/*width*/ 80))) + } + _ => None, + }) + .find(|message| { + message.contains("Failed to") || message.contains("Unable to check folder trust") + }) + .expect("visible read error"); + if capability == HistoryCapabilities::ConfigReadFails { + insta::assert_snapshot!(error, @"■ Unable to check folder trust: config/read failed while checking remote project trust"); } else { - assert_eq!( - recorded_params(&requests, "thread/start")[0]["model"], - "local-model" + insta::assert_snapshot!( + "command_center_session_start_error", + crate::chatwidget::tests::helpers::render_bottom_popup( + &app.chat_widget, + /*width*/ 80, + ) ); } server.shutdown().await?; @@ -978,7 +994,7 @@ async fn command_center_new_restores_blank_drafts_and_builtin_permissions() -> R ¶ms["model"] ), ( - &serde_json::json!(":read-only"), + &serde_json::Value::Null, &serde_json::json!("on-request"), &serde_json::json!("gpt-5.5") ) @@ -1048,7 +1064,7 @@ async fn command_center_new_checkout_and_worktree_preserve_source_and_default_br home.join("config.toml"), format!( "approvals_reviewer = \"auto_review\"\nsandbox_mode = \"workspace-write\"\napproval_policy = \"on-request\"\nwindows.sandbox = \"unelevated\"\n[projects.{:?}]\ntrust_level = \"trusted\"\n", - source.canonicalize()?.display().to_string(), + codex_config::loader::project_trust_key(&source), ), )?; let manager = diff --git a/codex-rs/tui/src/app/tests/disconnect_tests.rs b/codex-rs/tui/src/app/tests/disconnect_tests.rs index b47efa4265..4ab60da3c0 100644 --- a/codex-rs/tui/src/app/tests/disconnect_tests.rs +++ b/codex-rs/tui/src/app/tests/disconnect_tests.rs @@ -324,6 +324,7 @@ where "model/list" => Some(json!({"result": {"data": [], "nextCursor": null}})), "collaborationMode/list" => Some(json!({"result": {"data": []}})), "configRequirements/read" => Some(json!({"result": {"requirements": null}})), + "config/read" => Some(json!({"error": {"code": -32601, "message": "unsupported"}})), _ => respond(request).await, }; let Some(mut response) = response else { diff --git a/codex-rs/tui/src/app/tests/history_hydration_tests.rs b/codex-rs/tui/src/app/tests/history_hydration_tests.rs index 97c983cb66..9a5d34fae6 100644 --- a/codex-rs/tui/src/app/tests/history_hydration_tests.rs +++ b/codex-rs/tui/src/app/tests/history_hydration_tests.rs @@ -244,6 +244,7 @@ async fn history_hydration_archived_retry_uses_first_attempt_runtime_settings() for action in [ SessionStartAction::Resume( crate::app_server_session::ResumeModelSettings::RestoreFromThread, + crate::resume_permissions::ResumePermissions::default(), ), SessionStartAction::Fork(crate::app_server_session::ForkPermissionMode::InheritSaved), ] { @@ -267,7 +268,7 @@ async fn history_hydration_archived_retry_uses_first_attempt_runtime_settings() ) .await?; let initial = match action { - SessionStartAction::Resume(settings) => { + SessionStartAction::Resume(settings, _) => { server .resume_thread( &app.local_settings, diff --git a/codex-rs/tui/src/app/tests/misalignment_policy_tests.rs b/codex-rs/tui/src/app/tests/misalignment_policy_tests.rs index 48c7bfaf39..c1f8ab79e2 100644 --- a/codex-rs/tui/src/app/tests/misalignment_policy_tests.rs +++ b/codex-rs/tui/src/app/tests/misalignment_policy_tests.rs @@ -37,7 +37,7 @@ fn error_notification( #[tokio::test] async fn misalignment_continuation_requires_current_review_and_submits_once() -> Result<()> { - for reject in [false, true] { + for (reject, preserve) in [(false, false), (false, true), (true, false)] { let (mut app, mut rx, _) = make_test_app_with_channels().await; let (mut server, requests, proxy) = start_recording_app_server( &app.config, @@ -57,9 +57,11 @@ async fn misalignment_continuation_requires_current_review_and_submits_once() -> session.active_permission_profile = None; app.active_thread_id = Some(thread_id); app.chat_widget.handle_thread_session(session); - app.runtime_permission_profile_override = Some( - RuntimePermissionProfileOverride::from_config(app.chat_widget.config_ref()), - ); + app.runtime_permission_profile_override = Some(if preserve { + RuntimePermissionProfileOverride::from_restored_config(app.chat_widget.config_ref()) + } else { + RuntimePermissionProfileOverride::from_config(app.chat_widget.config_ref()) + }); app.chat_widget.handle_server_notification( error_notification(thread_id, "failed-turn", policy_error()), /*replay_kind*/ None, @@ -191,9 +193,7 @@ async fn misalignment_continuation_requires_current_review_and_submits_once() -> ), approval_policy: Some(AskForApproval::OnRequest), approvals_reviewer: Some(config.approvals_reviewer.into()), - sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::ReadOnly { - network_access: false - }), + sandbox_policy: (!preserve).then(|| config.legacy_sandbox_policy().into()), input: vec![codex_app_server_protocol::UserInput::Text { text: "Continue **only** within the requested scope.\nDo not edit files." .to_string(), diff --git a/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs b/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs index c3148c92f3..457f5970b5 100644 --- a/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs +++ b/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs @@ -194,6 +194,7 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv let socket = tokio_tungstenite::accept_async(stream).await?; methods.extend(serve_reconnect_requests(socket, |request| std::future::ready({ match request.method.as_str() { + "config/read" => Some(json!({"error": {"code": -32601, "message": "unsupported"}})), "thread/loaded/list" if connection == 0 => None, "thread/resume" if connection == 1 && previous_thread.is_some() => None, "thread/resume" if request.params.as_ref().unwrap()["threadId"] != child.to_string() && !server_available.load(std::sync::atomic::Ordering::SeqCst) => Some(json!({"error": {"code": -32600, "message": "thread no longer exists"}})), diff --git a/codex-rs/tui/src/app/tests/new_session_tests.rs b/codex-rs/tui/src/app/tests/new_session_tests.rs index e05d5119d7..e77967d796 100644 --- a/codex-rs/tui/src/app/tests/new_session_tests.rs +++ b/codex-rs/tui/src/app/tests/new_session_tests.rs @@ -170,7 +170,9 @@ async fn replacement_uses_server_defaults_and_preserves_explicit_launch_settings ); assert_eq!( recorded_params(&requests, "config/read"), - vec![serde_json::json!({"cwd": server_config.cwd.display().to_string()})], + vec![ + serde_json::json!({"cwd": server_config.cwd.display().to_string(), "includeLayers": true}) + ], ); if explicit == "saved" { let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 80) @@ -293,7 +295,7 @@ async fn replacement_preserves_remote_launch_paths_and_older_servers() -> Result .await; assert_eq!( recorded_params(&requests, "config/read"), - vec![serde_json::json!({"cwd": "."}),] + vec![serde_json::json!({"cwd": ".", "includeLayers": true}),] ); let starts = recorded_params(&requests, "thread/start"); assert_eq!(starts.len(), 1); diff --git a/codex-rs/tui/src/app/tests/permission_selection_tests.rs b/codex-rs/tui/src/app/tests/permission_selection_tests.rs index ab5ec45155..2c66fdfe44 100644 --- a/codex-rs/tui/src/app/tests/permission_selection_tests.rs +++ b/codex-rs/tui/src/app/tests/permission_selection_tests.rs @@ -39,6 +39,7 @@ proxy_url = "http://127.0.0.1:43128" /*replay_kind*/ None, ); assert!(app.chat_widget.is_user_turn_pending_or_running()); + app.runtime_approvals_reviewer_override = Some(ApprovalsReviewer::AutoReview); let before = RuntimePermissionProfileOverride::from_config(app.chat_widget.config_ref()); let profile_id = if before .active_permission_profile diff --git a/codex-rs/tui/src/app/tests/projectless_tests.rs b/codex-rs/tui/src/app/tests/projectless_tests.rs new file mode 100644 index 0000000000..f89622ba26 --- /dev/null +++ b/codex-rs/tui/src/app/tests/projectless_tests.rs @@ -0,0 +1,358 @@ +//! Exercise implicit defaults and permission inheritance through TUI session requests. + +use super::session_lifecycle_requests::HistoryCapabilities; +use super::session_lifecycle_requests::start_recording_app_server_with_history; +use super::*; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn projectless_permissions_survive_session_transitions_without_trust() -> Result<()> { + let (mut app, mut events, _) = make_test_app_with_channels().await; + let home = tempfile::tempdir()?; + app.config.codex_home = home.path().to_path_buf().abs(); + app.config.sqlite = codex_state::SqliteConfig::new_for_testing(home.path().abs()); + let source = tempfile::tempdir()?; + let destination = tempfile::tempdir()?; + let model = core_test_support::responses::start_mock_server().await; + let response = core_test_support::responses::mount_sse_once( + &model, + core_test_support::responses::sse(vec![ + core_test_support::responses::ev_response_created("saved-permissions"), + core_test_support::responses::ev_completed("saved-permissions"), + ]), + ) + .await; + std::fs::write( + home.path().join("config.toml"), + format!( + r#"model = "gpt-5.2" +model_provider = "test" +[model_providers.test] +name = "test" +base_url = "{}/v1" +wire_api = "responses" +[windows] +sandbox = "unelevated" +"#, + model.uri() + ), + )?; + app.config = app + .rebuild_config_for_cwd(source.path().to_path_buf()) + .await?; + app.chat_widget + .handle_thread_session_quiet(test_thread_session( + ThreadId::new(), + source.path().to_path_buf(), + )); + let mut server = crate::start_embedded_app_server_for_picker(&app.config).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + tui.pause_events(); + app.start_fresh_session( + &mut tui, + &mut server, + /*session_start_source*/ None, + /*initial_user_message*/ None, + /*new_thread_name*/ None, + ) + .await; + assert!( + app.config + .permissions + .permission_profile() + .file_system_sandbox_policy() + .can_write_local_path_with_cwd(source.path(), source.path()) + ); + assert!(matches!( + app.config.permissions.approval_policy.value(), + codex_protocol::protocol::AskForApproval::Granular(_) + )); + // Saving history must not turn implicit writable defaults into read-only on /cd. + server + .thread_inject_items( + app.chat_widget.thread_id().expect("fresh task"), + vec![App::side_boundary_prompt_item()], + ) + .await?; + let fresh_destination = tempfile::tempdir()?; + for cwd in [fresh_destination.path(), source.path()] { + assert!( + app.chat_widget + .rollout_path() + .as_deref() + .is_some_and(rollout_path_is_resumable) + ); + app.change_working_directory(&mut tui, &mut server, cwd.abs()) + .await; + assert_eq!(app.config.cwd.as_path(), cwd); + assert_eq!( + app.config.permissions.active_permission_profile(), + Some(ActivePermissionProfile::new(":workspace")) + ); + assert!(matches!( + app.config.permissions.approval_policy.value(), + codex_protocol::protocol::AskForApproval::Granular(_) + )); + } + let mut readonly = app.config.clone(); + readonly + .permissions + .set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active( + PermissionProfile::read_only(), + ActivePermissionProfile::new(":read-only"), + ))?; + let saved = server.start_thread(&readonly).await?; + server + .thread_inject_items( + saved.session.thread_id, + vec![App::side_boundary_prompt_item()], + ) + .await?; + tui.pause_events(); + app.launch_cwd = source.path().to_path_buf(); + app.resume_target_session( + &mut tui, + &mut server, + SessionTarget { + path: None, + thread_id: saved.session.thread_id, + cwd: Some(source.path().to_path_buf()), + history_mode: None, + }, + ) + .await?; + assert_eq!(app.chat_widget.thread_id(), Some(saved.session.thread_id)); + assert_eq!( + app.config.permissions.active_permission_profile(), + Some(ActivePermissionProfile::new(":read-only")) + ); + // A normal turn must leave the saved profile on the server, even though the + // widget knows its ID. Complete the turn so /cd exercises saved history. + while events.try_recv().is_ok() {} + app.chat_widget.apply_external_edit("hello".into()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let op = std::iter::from_fn(|| events.try_recv().ok()) + .find_map(|event| match event { + AppEvent::CodexOp(op @ AppCommand::UserTurn { .. }) => Some(op), + _ => None, + }) + .expect("user turn"); + assert!( + app.try_submit_active_thread_op_via_app_server(&mut server, saved.session.thread_id, &op) + .await? + ); + tokio::time::timeout(Duration::from_secs(/*secs*/ 30), async { + loop { + let page = server + .thread_turns_page( + saved.session.thread_id, + /*cursor*/ None, + /*limit*/ 1, + ) + .await?; + if page + .data + .first() + .is_some_and(|turn| turn.status == codex_app_server_protocol::TurnStatus::Completed) + { + return Ok::<_, color_eyre::Report>(()); + } + tokio::time::sleep(Duration::from_millis(/*millis*/ 10)).await; + } + }) + .await??; + response.single_request(); + let next = app.load_new_session_config(&server).await?; + assert_eq!( + next.permissions.permission_profile(), + &PermissionProfile::workspace_write() + ); + let before = app.chat_widget.thread_id(); + app.change_working_directory( + &mut tui, + &mut server, + destination.path().to_path_buf().abs(), + ) + .await; + assert_eq!(app.config.cwd.as_path(), destination.path()); + assert_ne!(app.chat_widget.thread_id(), before); + assert!( + !app.config + .permissions + .effective_permission_profile() + .file_system_sandbox_policy() + .can_write_local_path_with_cwd(destination.path(), destination.path()) + ); + assert!(!std::fs::read_to_string(home.path().join("config.toml"))?.contains("trust_level")); + let untrusted = tempfile::tempdir()?; + let config_path = home.path().join("config.toml"); + std::fs::write( + &config_path, + format!( + "{}\n[projects.{}]\ntrust_level = \"untrusted\"\n", + std::fs::read_to_string(&config_path)?, + serde_json::to_string(untrusted.path())? + ), + )?; + app.change_working_directory(&mut tui, &mut server, untrusted.path().abs()) + .await; + assert_eq!( + ( + app.config.cwd.as_path(), + app.config.permissions.approval_policy.value() + ), + ( + untrusted.path(), + codex_protocol::protocol::AskForApproval::UnlessTrusted + ) + ); + // A new task in a marked project still gets implicit defaults when leaving it. + std::fs::create_dir(untrusted.path().join(".codex"))?; + std::fs::write(untrusted.path().join(".codex/config.toml"), "")?; + crate::config_update::write_trusted_project(server.request_handle(), untrusted.path()).await?; + app.new_agents_overview_session(&mut tui, &mut server, Some(untrusted.path().abs())) + .await?; + server + .thread_inject_items( + app.chat_widget.thread_id().expect("new project task"), + vec![App::side_boundary_prompt_item()], + ) + .await?; + app.change_working_directory(&mut tui, &mut server, source.path().abs()) + .await; + assert_eq!( + ( + app.config.cwd.as_path(), + app.config.permissions.active_permission_profile() + ), + ( + source.path(), + Some(ActivePermissionProfile::new(":workspace")) + ) + ); + server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn local_projectless_defaults_respect_trust_scope_and_explicit_settings() -> Result<()> { + use codex_protocol::protocol::AskForApproval as Approval; + for (settings, project_marker, expected_profile, expected_approval, sandbox_ready) in [ + ("", false, ":workspace", None, true), + #[cfg(target_os = "windows")] + ("", false, ":read-only", None, false), + ("", true, ":read-only", None, true), + ( + "sandbox_mode = \"read-only\"\n", + false, + ":read-only", + None, + true, + ), + ( + "sandbox_mode = \"read-only\"\n", + false, + ":read-only", + None, + false, + ), + ( + "default_permissions = \":read-only\"\n", + false, + ":read-only", + None, + false, + ), + ( + "approval_policy = \"on-request\"\n", + false, + ":workspace", + Some(Approval::OnRequest), + true, + ), + ] { + let home = tempfile::tempdir()?; + let parent = tempfile::tempdir()?; + let cwd = parent.path().join("notes"); + std::fs::create_dir(&cwd)?; + let parent_path = serde_json::to_string(parent.path())?; + let windows = if sandbox_ready { + "[windows]\nsandbox = \"unelevated\"\n" + } else { + "" + }; + std::fs::write( + home.path().join("config.toml"), + format!("{settings}{windows}[projects.{parent_path}]\ntrust_level = \"untrusted\"\n"), + )?; + let overrides = ConfigOverrides { + cwd: Some(cwd), + ..Default::default() + }; + let mut config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) + .harness_overrides(overrides.clone()) + .build() + .await?; + let original_approval = if settings.is_empty() && !project_marker { + Approval::Granular(codex_protocol::protocol::GranularApprovalConfig { + sandbox_approval: false, + rules: false, + skill_approval: false, + request_permissions: true, + mcp_elicitations: true, + }) + } else { + config.permissions.approval_policy.value() + }; + if project_marker { + // The server must notice a project layer added after client discovery. + assert!(config.config_layer_stack.is_projectless()); + std::fs::create_dir(config.cwd.join(".codex"))?; + std::fs::write( + config.cwd.join(".codex/config.toml"), + "model = \"untrusted-model\"\n", + )?; + } + let (server, _, proxy) = start_recording_app_server_with_history( + &config, + HistoryCapabilities::Current, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + crate::app_server_session::ThreadParamsMode::Embedded, + LoaderOverrides::without_managed_config_for_tests(), + ) + .await?; + let startup = crate::app::startup::prepare_fresh_startup_config( + &mut config, + &server, + &[], + &overrides, + &EnvironmentManager::default_for_tests(), + ) + .await?; + assert_eq!( + startup.prompt_windows_sandbox, + cfg!(target_os = "windows") && !sandbox_ready && settings.is_empty() && !project_marker + ); + assert_eq!( + ( + config.permissions.permission_profile(), + config.permissions.approval_policy.value() + ), + ( + &if expected_profile == ":workspace" { + PermissionProfile::workspace_write() + } else { + PermissionProfile::read_only() + }, + expected_approval.unwrap_or(original_approval) + ) + ); + server.shutdown().await?; + proxy.await??; + } + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs index 0007879bd2..f98ff749b9 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -190,7 +190,7 @@ pub(super) async fn start_recording_app_server( blocked_thread_list, failed_thread_name, crate::app_server_session::ThreadParamsMode::Embedded, - LoaderOverrides::default(), + LoaderOverrides::without_managed_config_for_tests(), ) .await } @@ -1764,8 +1764,11 @@ async fn dynamic_tool_requests_ignore_other_namespaces_and_dispatch_tui_namespac }, }) .await?; - assert!(source_settings.active_permission_profile.is_none()); - let source_sandbox = serde_json::to_value(source_settings.sandbox)?; + let source_sandbox = if source_settings.active_permission_profile.is_some() { + serde_json::Value::Null + } else { + serde_json::to_value(source_settings.sandbox)? + }; spawn_approved_task_tool_call( &app, &app_server, @@ -1831,6 +1834,16 @@ async fn dynamic_tool_requests_ignore_other_namespaces_and_dispatch_tui_namespac .expect("background task creation")["projectId"], project.project.id ); + assert_eq!( + recorded_params(&requests, "thread/start") + .last() + .expect("background task creation")["permissions"], + serde_json::json!( + source_settings + .active_permission_profile + .map(|profile| profile.id) + ) + ); let turn = recorded_params(&requests, "turn/start") .pop() .expect("background task turn request"); @@ -3805,7 +3818,7 @@ async fn changing_directory_preserves_project_trust_permissions_history_and_hook let requirements = codex_home.path().join("requirements.toml"); let rules = "allowed_approval_policies=[\"untrusted\"]\nallowed_sandbox_modes=[\"read-only\"]"; fs::write(&requirements, rules)?; - fs::create_dir_all(unknown.join(".git"))?; + fs::create_dir_all(unknown.join(".codex"))?; for dir in [&trusted, &untrusted, &mismatch, &failed] { let trust = [T::Trusted, T::Untrusted][usize::from(dir == &untrusted)]; crate::legacy_core::config::set_project_trust_level(codex_home.path(), dir, trust) @@ -3851,6 +3864,30 @@ async fn changing_directory_preserves_project_trust_permissions_history_and_hook thread_id, requested_cwd: path.into(), }; + let mut stale_config = app.rebuild_config_for_cwd(untrusted.clone()).await?; + stale_config.active_project.trust_level = None; + app.app_server_target = crate::AppServerTarget::LocalDaemon { + endpoint: crate::resolve_remote_addr("ws://127.0.0.1:8765")?, + allow_embedded_fallback: false, + }; + history(); + assert!( + app.confirm_directory_trust( + &mut tui, + &mut server, + &mut stale_config, + &untrusted, + crate::onboarding::DirectoryTrustOptions { + cancel: Some(crate::onboarding::TrustCancelAction::CurrentTask), + ..Default::default() + }, + /*startup_draft*/ None, + ) + .await + .is_err() + ); + assert_snapshot!(history().join(""), @"■ Unable to check folder trust: Folder trust changed. Reopen the destination with its restricted settings."); + app.app_server_target = crate::AppServerTarget::Embedded; for (path, kind, expected) in [ ("missing", "local", "Cannot access directory"), ("../config.toml", "local", "Not a directory"), @@ -3860,9 +3897,12 @@ async fn changing_directory_preserves_project_trust_permissions_history_and_hook ("../trusted", "running", "another agent is running"), ("../trusted", "active", "another agent is running"), ("../trusted", "mcp", "inventory is still loading"), + ("../unknown", "main", "background terminals"), + ("../unknown", "child", "background terminals"), ("../trusted", "approval", "approval policy override"), ("../trusted", "profile", "permission profile override"), ("../trusted", "reviewer", "reviewer"), + ("../trusted", "standalone_reviewer", "reviewer"), ("../p", "named", "different settings"), ( "../trusted", @@ -3870,19 +3910,22 @@ async fn changing_directory_preserves_project_trust_permissions_history_and_hook "Permission profile cannot be preserved", ), ("../p", "keymap", "open_transcript"), - ("../unknown", "local", "This directory is not trusted"), - ("../trusted", "main", "background terminals"), - ("../trusted", "child", "background terminals"), ] { app.config.approvals_reviewer = ApprovalsReviewer::User; - if kind == "reviewer" { + if matches!(kind, "reviewer" | "standalone_reviewer") { app.config.approvals_reviewer = ApprovalsReviewer::AutoReview; fs::write(&requirements, "allowed_approvals_reviewers = [\"user\"]")?; } app.agent_navigation.set_running(child, kind == "running"); store.lock().await.active_turn_id = (kind == "active").then(|| "active".into()); - app.loader_overrides.system_requirements_path = - matches!(kind, "approval" | "profile" | "reviewer").then_some(requirements.clone()); + app.loader_overrides.system_requirements_path = matches!( + kind, + "approval" | "profile" | "reviewer" | "standalone_reviewer" + ) + .then_some(requirements.clone()); + app.runtime_approvals_reviewer_override = + matches!(kind, "reviewer" | "standalone_reviewer") + .then_some(ApprovalsReviewer::AutoReview); app.harness_overrides.permission_profile = (kind != "named").then_some(PermissionProfile::workspace_write()); app.runtime_approval_policy_override = (kind == "approval").then_some( @@ -3925,9 +3968,14 @@ async fn changing_directory_preserves_project_trust_permissions_history_and_hook assert_eq!(app.chat_widget.thread_id(), Some(original)); assert_eq!(app.config.cwd, current.clone().abs()); assert!(app.runtime_working_directory_override.is_none()); - let count = requests.lock().expect("request recorder lock").len(); - let checked = usize::from(kind == "main") + 2 * usize::from(kind == "child"); - assert_eq!(count, checked, "{kind}"); + assert!( + recorded_params(&requests, "thread/start").is_empty(), + "{kind}" + ); + assert!( + recorded_params(&requests, "thread/fork").is_empty(), + "{kind}" + ); let listed = recorded_params(&requests, "thread/backgroundTerminals/list"); let mut ids = listed.iter().zip([original, child]); assert!(ids.all(|(p, id)| p["threadId"] == id.to_string())); @@ -3984,6 +4032,7 @@ async fn changing_directory_preserves_project_trust_permissions_history_and_hook child, /*agent_nickname*/ None, /*agent_role*/ None, /*is_closed*/ false, ); app.set_approvals_reviewer_in_app_and_widget(ApprovalsReviewer::AutoReview); + app.runtime_approvals_reviewer_override = Some(ApprovalsReviewer::AutoReview); app.runtime_permission_profile_override = Some(RuntimePermissionProfileOverride::from_config(&app.config)); for (path, expected) in [(&failed, 0), (&trusted, 2)] { @@ -4021,6 +4070,7 @@ async fn changing_directory_preserves_project_trust_permissions_history_and_hook let configured = app.primary_session_configured.as_ref().expect("session"); let source = codex_utils_path_uri::PathUri::from_abs_path(&agents.abs()); assert!(configured.instruction_source_paths.contains(&source)); + assert_eq!(configured.approvals_reviewer, ApprovalsReviewer::AutoReview); let (cwd, result) = (current.clone(), Err("stale skills".into())); let skills = AppEvent::SkillsListLoaded { cwd, result }; let (cwd, plugins) = (current.clone(), Some(vec![])); @@ -4073,11 +4123,18 @@ async fn changing_directory_preserves_project_trust_permissions_history_and_hook .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); app.harness_overrides.bypass_hook_trust = Some(true); requests.lock().expect("request recorder lock").clear(); - app.change_working_directory(&mut tui, &mut server, trusted.abs()) + app.change_working_directory(&mut tui, &mut server, trusted.clone().abs()) .await; assert!(app.config.bypass_hook_trust && !app.chat_widget.has_active_view()); assert!(recorded_params(&requests, "hooks/list").is_empty()); app.harness_overrides.bypass_hook_trust = None; + app.harness_overrides.permission_profile = None; + app.runtime_approval_policy_override = Some(RuntimeApprovalPolicyOverride::Restored( + AskForApproval::OnRequest, + )); + app.runtime_permission_profile_override = Some( + RuntimePermissionProfileOverride::from_restored_config(&app.config), + ); requests.lock().expect("request recorder lock").clear(); app.change_working_directory(&mut tui, &mut server, untrusted.clone().abs()) .await; @@ -4087,6 +4144,20 @@ async fn changing_directory_preserves_project_trust_permissions_history_and_hook assert_eq!(rec(req, "thread/fork")[0]["approvalPolicy"], "untrusted"); let warning = "Project-local config, hooks, and exec policies are disabled"; assert!(history().iter().any(|line| line.contains(warning))); + let untrusted_permissions = app.config.permissions.permission_profile().clone(); + app.change_working_directory(&mut tui, &mut server, trusted.clone().abs()) + .await; + assert_eq!(app.config.cwd, trusted.abs()); + assert_eq!( + ( + app.config.permissions.approval_policy.value(), + app.config.permissions.permission_profile() + ), + ( + AskForApproval::UnlessTrusted.to_core(), + &untrusted_permissions + ) + ); server.shutdown().await?; proxy.await??; Ok(()) @@ -4679,6 +4750,12 @@ async fn command_center_read_only_open_requests_and_failure_preservation() -> Re let (tx, mut events) = tokio::sync::mpsc::unbounded_channel(); app.app_event_tx = AppEventSender::new(tx); + app.runtime_approval_policy_override = Some(RuntimeApprovalPolicyOverride::Restored( + AskForApproval::Never, + )); + app.runtime_permission_profile_override = Some( + RuntimePermissionProfileOverride::from_restored_config(&app.config), + ); Box::pin(app.select_agents_overview_thread(&mut tui, &mut server, thread_id)).await?; assert_eq!(recorded_params(&requests, "thread/resume").len(), 1); assert!(recorded_params(&requests, "turn/start").is_empty()); @@ -4706,6 +4783,13 @@ async fn command_center_read_only_open_requests_and_failure_preservation() -> Re } else { assert_eq!(app.current_displayed_thread_id(), Some(thread_id)); assert!(app.chat_widget.is_external_writer_view()); + assert_eq!( + ( + app.runtime_approval_policy_override, + app.runtime_permission_profile_override.as_ref() + ), + (None, None) + ); assert_eq!( app.thread_event_channels[&thread_id].attachment(), ThreadEventAttachment::ExternalWriter diff --git a/codex-rs/tui/src/app/tests/startup_defaults_tests.rs b/codex-rs/tui/src/app/tests/startup_defaults_tests.rs index 5e2409e78e..d1f7ee3da8 100644 --- a/codex-rs/tui/src/app/tests/startup_defaults_tests.rs +++ b/codex-rs/tui/src/app/tests/startup_defaults_tests.rs @@ -256,8 +256,10 @@ async fn fresh_startup_uses_server_defaults_with_explicit_and_managed_precedence &server, &cli_kv_overrides, &harness_overrides, + &EnvironmentManager::default_for_tests() ) .await? + .server_defaults_read ); let selected_model = startup_model(&config, &bootstrap, /*server_defaults_read*/ true); let started = crate::app_server_session::start_thread_with_request_handle( @@ -285,8 +287,13 @@ async fn fresh_startup_uses_server_defaults_with_explicit_and_managed_precedence ); assert_eq!(started.session.model, expected_model, "{choice}"); assert_eq!( - recorded_params(&requests, "config/read"), - vec![serde_json::json!({"cwd": server_config.cwd.display().to_string()})], + recorded_params(&requests, "config/read") + .into_iter() + .filter(|params| !params["cwd"].is_null()) + .collect::>(), + vec![ + serde_json::json!({"cwd": server_config.cwd.display().to_string(), "includeLayers": true}) + ], ); server.shutdown().await?; proxy.await??; @@ -307,7 +314,7 @@ async fn fresh_startup_reads_destination_and_cleared_model_uses_catalog() -> Res )?; std::fs::write( server_home.path().join("config.toml"), - "model_reasoning_effort = \"high\"\n", + "model_reasoning_effort = \"high\"\nsandbox_mode = \"read-only\"\n", )?; let mut config = ConfigBuilder::default() .codex_home(client_home.path().to_path_buf()) @@ -341,14 +348,21 @@ async fn fresh_startup_reads_destination_and_cleared_model_uses_catalog() -> Res if override_cwd { server = server.with_remote_cwd_override(Some(launch_cwd.path().to_path_buf())); } + assert!(config.config_layer_stack.is_projectless()); let bootstrap = server.bootstrap(&config).await?; assert_eq!(bootstrap.default_model, "stale-client-model"); - let defaults_read = - prepare_fresh_startup_config(&mut config, &server, &[], &ConfigOverrides::default()) - .await?; - assert!(defaults_read); + let defaults_read = prepare_fresh_startup_config( + &mut config, + &server, + &[], + &ConfigOverrides::default(), + &EnvironmentManager::default_for_tests(), + ) + .await?; + assert!(defaults_read.server_defaults_read); + assert!(!defaults_read.prompt_windows_sandbox); assert_eq!(config.model, None); - let selected_model = startup_model(&config, &bootstrap, defaults_read); + let selected_model = startup_model(&config, &bootstrap, defaults_read.server_defaults_read); assert_ne!(selected_model, "stale-client-model"); let started = crate::app_server_session::start_thread_with_request_handle( server.request_handle(), @@ -360,6 +374,16 @@ async fn fresh_startup_reads_destination_and_cleared_model_uses_catalog() -> Res ) .await?; assert_eq!(started.session.model, selected_model); + assert!( + !started + .session + .permission_profile + .file_system_sandbox_policy() + .can_write_local_path_with_cwd( + started.session.cwd.as_path(), + started.session.cwd.as_path() + ) + ); let starts = recorded_params(&requests, "thread/start"); assert_eq!(starts.len(), 1); assert_eq!(starts[0]["model"], serde_json::Value::Null); @@ -382,9 +406,14 @@ async fn fresh_startup_reads_destination_and_cleared_model_uses_catalog() -> Res } else { ".".to_string() }; - assert_eq!( - recorded_params(&requests, "config/read"), - vec![serde_json::json!({"cwd": expected_cwd})] + let reads = recorded_params(&requests, "config/read") + .into_iter() + .filter(|params| !params["cwd"].is_null()) + .collect::>(); + assert!(!reads.is_empty()); + assert!( + reads.iter().all(|params| params["cwd"] == expected_cwd), + "{reads:?}" ); server.shutdown().await?; proxy.await??; diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index 78178575b1..341eaa057c 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -739,7 +739,7 @@ impl App { cwd, approval_policy, approvals_reviewer, - active_permission_profile, + active_permission_profile: _, model, effort, summary, @@ -830,10 +830,6 @@ impl App { let selected_profile = self.pending_server_profiles.get(&thread_id); let selected_active = selected_profile .map(|profile| ActivePermissionProfile::new(profile.profile_id.clone())); - let confirmed_active = (self.app_server_target.thread_params_mode() - == crate::app_server_session::ThreadParamsMode::Remote) - .then(|| config.permissions.active_permission_profile()) - .flatten(); let (turn_approval_policy, turn_approvals_reviewer) = if let Some(profile) = selected_profile { ( @@ -857,14 +853,21 @@ impl App { ), ) }; + // Only explicit choices may override the saved server profile. + let explicit_profile = self + .runtime_permission_profile_override + .as_ref() + .filter(|profile| { + profile.turn_override + == RuntimePermissionProfileTurnOverride::LegacySandbox + }); let permissions_override = Self::turn_permissions_override_from_config( config, - selected_active - .as_ref() - .or(confirmed_active.as_ref()) - .or(active_permission_profile.as_ref()), - self.runtime_permission_profile_override - .as_ref() + selected_active.as_ref().or_else(|| { + explicit_profile + .and_then(|profile| profile.active_permission_profile.as_ref()) + }), + explicit_profile .and_then(RuntimePermissionProfileOverride::turn_permission_profile), ); let response = app_server @@ -1186,7 +1189,7 @@ impl App { { return Ok(()); } - let mut permission_change_confirmed = false; + let mut confirmed_profile = None; if let ServerNotification::ThreadSettingsUpdated(notification) = ¬ification { self.apply_thread_settings_to_cached_session(thread_id, ¬ification.thread_settings) .await; @@ -1207,8 +1210,7 @@ impl App { }) }) { - self.pending_server_profiles.remove(&thread_id); - permission_change_confirmed = true; + confirmed_profile = self.pending_server_profiles.remove(&thread_id); } } let inferred_session = if let ServerNotification::ThreadStarted(started) = ¬ification @@ -1296,7 +1298,13 @@ impl App { .on_thread_settings_updated(settings.clone()); notification = None; } - if permission_change_confirmed { + if let Some(selected) = confirmed_profile { + if self.chat_widget.thread_id() == Some(thread_id) { + if selected.approvals_reviewer.is_some() { + self.runtime_approvals_reviewer_override = None; + } + self.runtime_permission_profile_override = None; + } if self.chat_widget.thread_id() == Some(thread_id) && let Some(profile) = self .chat_widget @@ -1322,6 +1330,7 @@ impl App { Some(RuntimeApprovalPolicyOverride::Explicit( self.config.permissions.approval_policy.value().into(), )); + self.runtime_approvals_reviewer_override = Some(self.config.approvals_reviewer); self.runtime_permission_profile_override = Some(RuntimePermissionProfileOverride::from_config(&self.config)); } @@ -1554,6 +1563,14 @@ impl App { tracing::warn!(%err, "failed to sync app permissions from thread session"); } self.config.approvals_reviewer = session.approvals_reviewer; + if !self.app_server_target.uses_remote_workspace() { + self.config + .workspace_roots + .clone_from(&session.runtime_workspace_roots); + self.config + .permissions + .set_workspace_roots(session.runtime_workspace_roots.clone()); + } let thread_id = session.thread_id; self.pending_server_profiles.remove(&thread_id); diff --git a/codex-rs/tui/src/app/working_directory.rs b/codex-rs/tui/src/app/working_directory.rs index 36b4765947..76187b34a3 100644 --- a/codex-rs/tui/src/app/working_directory.rs +++ b/codex-rs/tui/src/app/working_directory.rs @@ -1,14 +1,13 @@ //! Local directory transitions and managed worktrees with fresh or preserved conversation history. //! Managed transitions and widget attachment run separately at the top of the event loop. +use super::managed_worktree_creation::check_background_terminals; use super::session_lifecycle::ThreadAttachPresentation; use super::*; use crate::app_event::ManagedWorktreeTransition; use crate::app_server_session::ForkGoalContinuation::DeferUntilNextTurn; use crate::history_cell::McpInventoryLoadingCell as LoadingCell; use crate::terminal_visualization_instructions::with_terminal_visualization_instructions; -use codex_app_server_protocol::ThreadBackgroundTerminalsListParams; -use codex_app_server_protocol::ThreadBackgroundTerminalsListResponse as ListResponse; enum DestinationConfig { Load, @@ -202,82 +201,12 @@ impl App { .iter() .filter_map(|(id, agent)| (!agent.is_closed).then_some(*id)) .collect(); - let mut config = match destination_config { - DestinationConfig::Prepared(config) => *config, - DestinationConfig::Load => match self.rebuild_config_for_cwd(cwd.to_path_buf()).await { - Ok(config) => config, - Err(err) => { - return self.working_directory_error(format!("Cannot load {cwd:?}: {err}")); - } - }, - }; - if config.active_project.trust_level.is_none() { - return self.working_directory_error("This directory is not trusted; run Codex there."); - } - if let Some((_, checkout, crate::app_event::ManagedWorktreeMode::Fork, _)) = - managed_worktree.as_ref() - && with_terminal_visualization_instructions( - &self.config, - self.config.developer_instructions.clone(), - ) != with_terminal_visualization_instructions( - &config, - config.developer_instructions.clone(), - ) - { - return self.working_directory_error(format!( - "Cannot fork into this worktree because developer instructions differ. Start a new conversation instead. An unused checkout was created at {}; remove it with `git worktree remove ` from the source repository.", - checkout.root.display() - )); - } - if let Some(profile) = self.runtime_permission_profile_override.as_ref() - && profile.active_permission_profile.is_some() - && (!profile.matches_config(&config) - || config.permissions.profile_workspace_roots() - != self.config.permissions.profile_workspace_roots()) - { - return self.working_directory_error("Permission profile has different settings."); - } - if let Some(profile) = self.runtime_permission_profile_override.as_ref() - && profile.turn_override == RuntimePermissionProfileTurnOverride::Preserve - && profile.active_permission_profile.is_none() - && !crate::app_server_session::permission_profile_is_safely_represented_by_sandbox_mode( - &profile.permission_profile, - cwd.as_path(), - ) - { - return self.working_directory_error("Permission profile cannot be preserved by /cd."); - } - self.apply_runtime_policy_overrides(&mut config, RuntimePolicyOverrideScope::All); - if self.runtime_permission_profile_override.is_some() { - let reviewer = self.config.approvals_reviewer; - let reviewers = &config.config_layer_stack.requirements().approvals_reviewer; - if let Err(error) = reviewers.can_set(&reviewer) { - return self.working_directory_error(format!("Approvals reviewer: {error}")); - } - config.approvals_reviewer = reviewer; - } - let actual = config.permissions.approval_policy.value(); - let approval = self - .runtime_approval_policy_override - .map(RuntimeApprovalPolicyOverride::policy); - let profile = self.runtime_permission_profile_override.as_ref(); - if approval.is_some_and(|p| actual != p.to_core()) - || profile.is_some_and(|profile| !profile.matches_config(&config)) - { - return; - } - let local_settings = self.local_settings.reloaded(&config); - let keymap = match RuntimeKeymap::from_config(&local_settings.tui.keymap) { - Ok(keymap) => keymap, - Err(error) => return self.chat_widget.add_error_message(error), - }; - config.service_tier = self.chat_widget.configured_service_tier(); let is_new_worktree = matches!( managed_worktree.as_ref().map(|(_, _, mode, _)| mode), Some(crate::app_event::ManagedWorktreeMode::New) ); let rollout = self.chat_widget.rollout_path(); - let has_rollout = rollout.as_deref().is_some_and(rollout_path_is_resumable); + let mut has_rollout = rollout.as_deref().is_some_and(rollout_path_is_resumable); let channels = &self.thread_event_channels; if !has_rollout && !is_new_worktree @@ -305,25 +234,150 @@ impl App { .filter(|id| !closed_agents.contains(id)) .collect(); ids.extend(open_agents); - let descendants = ids.iter().copied().filter(|id| *id != thread_id); - for tracked_id in std::iter::once(thread_id).chain(descendants) { - let request = ClientRequest::ThreadBackgroundTerminalsList { - request_id: app_server.next_request_id(), - params: ThreadBackgroundTerminalsListParams { - thread_id: tracked_id.to_string(), - cursor: None, - limit: Some(1), - }, - }; - let handle = app_server.request_handle(); - let result = handle.request_typed::(request).await; - if let Some(message) = super::managed_worktree_creation::background_terminals_blocker( - result, + let mut tracked_ids: Vec<_> = std::iter::once(thread_id) + .chain(ids.iter().copied().filter(|id| *id != thread_id)) + .collect(); + let mut config = match destination_config { + DestinationConfig::Prepared(config) => *config, + DestinationConfig::Load => match self.rebuild_config_for_cwd(cwd.to_path_buf()).await { + Ok(config) => config, + Err(err) => { + return self.working_directory_error(format!("Cannot load {cwd:?}: {err}")); + } + }, + }; + if config.active_project.trust_level.is_none() { + if let Some(message) = check_background_terminals( + app_server, &self.app_server_target, - ) { + tracked_ids.iter().copied(), + ) + .await + { return self.working_directory_error(message); } + if self + .confirm_directory_trust( + tui, + app_server, + &mut config, + cwd.as_path(), + crate::onboarding::DirectoryTrustOptions { + cancel: Some(crate::onboarding::TrustCancelAction::CurrentTask), + ..Default::default() + }, + /*startup_draft*/ None, + ) + .await + .is_err() + { + return; + } } + if let Some((_, checkout, crate::app_event::ManagedWorktreeMode::Fork, _)) = + managed_worktree.as_ref() + && with_terminal_visualization_instructions( + &self.config, + self.config.developer_instructions.clone(), + ) != with_terminal_visualization_instructions( + &config, + config.developer_instructions.clone(), + ) + { + return self.working_directory_error(format!( + "Cannot fork into this worktree because developer instructions differ. Start a new conversation instead. An unused checkout was created at {}; remove it with `git worktree remove ` from the source repository.", + checkout.root.display() + )); + } + if !has_rollout + || is_new_worktree + || (self.runtime_permission_profile_override.is_none() + && self + .config + .permissions + .active_permission_profile() + .is_some_and(|profile| { + profile.id == codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE + })) + { + match crate::config_update::read_effective_config_if_supported( + app_server.request_handle(), + cwd.as_path(), + ) + .await + { + Ok(Some(defaults)) => { + crate::projectless::apply_defaults( + &mut config, + &self.harness_overrides, + app_server, + &self.environment_manager, + &defaults, + ); + } + Ok(None) => {} + Err(error) => { + return self + .working_directory_error(format!("Cannot read defaults: {error:#}")); + } + } + } + let scope = if config.active_project.is_untrusted() { + RuntimePolicyOverrideScope::ExplicitOnly + } else { + RuntimePolicyOverrideScope::All + }; + let profile = self + .runtime_permission_profile_override + .as_ref() + .filter(|profile| { + scope == RuntimePolicyOverrideScope::All + || profile.turn_override == RuntimePermissionProfileTurnOverride::LegacySandbox + }) + .cloned(); + if let Some(profile) = profile.as_ref() + && profile + .active_permission_profile + .as_ref() + .is_some_and(|active| !active.id.starts_with(':')) + && (!profile.matches_config(&config) + || config.permissions.profile_workspace_roots() + != self.config.permissions.profile_workspace_roots()) + { + return self.working_directory_error("Permission profile has different settings."); + } + if let Some(profile) = profile.as_ref() + && profile.turn_override == RuntimePermissionProfileTurnOverride::Preserve + && profile.active_permission_profile.is_none() + && !crate::app_server_session::permission_profile_is_safely_represented_by_sandbox_mode( + &profile.permission_profile, + cwd.as_path(), + ) + { + return self.working_directory_error("Permission profile cannot be preserved by /cd."); + } + if let Err(error) = self.apply_runtime_policy_overrides(&mut config, scope) { + return self.working_directory_error(format!("{error:#}")); + } + let actual = config.permissions.approval_policy.value(); + let approval = self + .runtime_approval_policy_override + .filter(|value| { + scope == RuntimePolicyOverrideScope::All + || matches!(value, RuntimeApprovalPolicyOverride::Explicit(_)) + }) + .map(RuntimeApprovalPolicyOverride::policy); + if approval.is_some_and(|p| actual != p.to_core()) + || profile.is_some_and(|profile| !profile.matches_config(&config)) + { + return; + } + let local_settings = self.local_settings.reloaded(&config); + let keymap = match RuntimeKeymap::from_config(&local_settings.tui.keymap) { + Ok(keymap) => keymap, + Err(error) => return self.chat_widget.add_error_message(error), + }; + config.service_tier = self.chat_widget.configured_service_tier(); if is_new_worktree { apply_managed_new_thread_defaults( &mut config, @@ -332,6 +386,46 @@ impl App { &self.harness_overrides, ); } + // Config reads and folder consent can overlap work from another client. + if !self + .backfill_loaded_subagent_threads(app_server) + .await + .completed + { + return self.working_directory_error("Cannot check agent status."); + } + ids.extend( + self.agent_navigation + .ordered_threads() + .iter() + .filter_map(|(id, agent)| (!agent.is_closed).then_some(*id)), + ); + tracked_ids = std::iter::once(thread_id) + .chain(ids.iter().copied().filter(|id| *id != thread_id)) + .collect(); + for id in &tracked_ids { + match app_server.thread_read(*id, /*include_turns*/ false).await { + Ok(thread) + if matches!( + thread.status, + codex_app_server_protocol::ThreadStatus::Active { .. } + ) => + { + return self.working_directory_error("Cannot change: a task is running."); + } + Ok(_) => {} + Err(error) => { + return self + .working_directory_error(format!("Cannot check task status: {error}")); + } + } + } + if let Some(message) = + check_background_terminals(app_server, &self.app_server_target, tracked_ids).await + { + return self.working_directory_error(message); + } + has_rollout |= rollout.as_deref().is_some_and(rollout_path_is_resumable); let preserve_history = has_rollout && !is_new_worktree; let transitioned = if preserve_history { app_server @@ -466,6 +560,25 @@ impl App { if let Err(error) = attach_widget(self, tui, started, lineage, message).await { return self.working_directory_error(format!("Could not restore session: {error}")); } + if matches!( + self.runtime_approval_policy_override, + Some(RuntimeApprovalPolicyOverride::Restored(_)) + ) { + self.runtime_approval_policy_override = Some(RuntimeApprovalPolicyOverride::Restored( + self.config.permissions.approval_policy.value().into(), + )); + } + if self + .runtime_permission_profile_override + .as_ref() + .is_some_and(|profile| { + profile.turn_override == RuntimePermissionProfileTurnOverride::Preserve + }) + { + self.runtime_permission_profile_override = Some( + RuntimePermissionProfileOverride::from_restored_config(&self.config), + ); + } if let Some(error) = name_error { self.chat_widget.add_error_message(error); } diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index dc90d8e373..38f5b874e4 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -2066,6 +2066,7 @@ fn thread_resume_params_from_config( thread_params_mode: ThreadParamsMode, remote_cwd_override: Option<&std::path::Path>, model_settings: ResumeModelSettings, + permission_overrides: crate::resume_permissions::ResumePermissions, ) -> ThreadResumeParams { if model_settings == ResumeModelSettings::PreserveExistingThread { return ThreadResumeParams { @@ -2118,14 +2119,20 @@ fn thread_resume_params_from_config( ), ..ThreadResumeParams::default() }; - if thread_params_mode == ThreadParamsMode::Remote { - // Resuming restores the server's saved permission settings, including named profiles. + if thread_params_mode == ThreadParamsMode::Remote || !permission_overrides.approval_policy { params.approval_policy = None; + } + if thread_params_mode == ThreadParamsMode::Remote || !permission_overrides.approvals_reviewer { params.approvals_reviewer = None; + } + if thread_params_mode == ThreadParamsMode::Remote || !permission_overrides.profile { params.sandbox = None; params.permissions = None; remove_permission_config_overrides(&mut params.config); } + if !permission_overrides.workspace_roots { + params.runtime_workspace_roots = None; + } params } @@ -2294,23 +2301,14 @@ async fn thread_session_state_from_thread_resume_response( response: &ThreadResumeResponse, local_settings: &LocalSettings, config: &Config, - thread_params_mode: ThreadParamsMode, + _thread_params_mode: ThreadParamsMode, ) -> Result { - let permission_profile = if matches!(thread_params_mode, ThreadParamsMode::Embedded) - && response.active_permission_profile.is_none() - { - PermissionProfile::from_legacy_sandbox_policy_for_cwd( - &response.sandbox.to_core(), - response.cwd.as_path(), - ) - } else { - display_permission_profile_from_thread_response( - &response.sandbox, - response.cwd.as_path(), - config, - thread_params_mode, - ) - }; + // The server owns restored rules. This legacy projection is for display only; + // normal turns do not send it back as an execution override. + let permission_profile = PermissionProfile::from_legacy_sandbox_policy_for_cwd( + &response.sandbox.to_core(), + response.cwd.as_path(), + ); let mut session = thread_session_state_from_thread_response( &response.thread.id, crate::windows_sandbox::host_from_environments(response.thread.environments.as_deref()), @@ -3175,6 +3173,7 @@ mod tests { ThreadParamsMode::Remote, /*remote_cwd_override*/ None, ResumeModelSettings::OverrideFromCurrentConfig, + crate::resume_permissions::ResumePermissions::CURRENT_CONFIG, ); let fork = thread_fork_params_from_config( config, @@ -3220,6 +3219,7 @@ mod tests { ThreadParamsMode::Remote, Some(remote_cwd.as_path()), ResumeModelSettings::RestoreFromThread, + crate::resume_permissions::ResumePermissions::CURRENT_CONFIG, ); assert_eq!(resume.cwd, Some(remote_cwd.to_string_lossy().to_string())); @@ -3316,6 +3316,7 @@ mod tests { ThreadParamsMode::Remote, Some(remote_cwd.as_path()), ResumeModelSettings::OverrideFromCurrentConfig, + crate::resume_permissions::ResumePermissions::CURRENT_CONFIG, ); let fork = thread_fork_params_from_config( config, @@ -3371,6 +3372,7 @@ mod tests { ThreadParamsMode::Embedded, /*remote_cwd_override*/ None, ResumeModelSettings::OverrideFromCurrentConfig, + crate::resume_permissions::ResumePermissions::CURRENT_CONFIG, ); let fork = thread_fork_params_from_config( config, @@ -3481,6 +3483,7 @@ mod tests { ThreadParamsMode::Embedded, /*remote_cwd_override*/ None, ResumeModelSettings::RestoreFromThread, + crate::resume_permissions::ResumePermissions::CURRENT_CONFIG, ); assert_eq!(params.model, None); @@ -3506,6 +3509,7 @@ mod tests { ThreadParamsMode::Embedded, /*remote_cwd_override*/ None, ResumeModelSettings::PreserveExistingThread, + crate::resume_permissions::ResumePermissions::CURRENT_CONFIG, ); assert_eq!( @@ -3824,6 +3828,7 @@ mod tests { mode, /*remote_cwd_override*/ None, ResumeModelSettings::OverrideFromCurrentConfig, + crate::resume_permissions::ResumePermissions::CURRENT_CONFIG, ); let fork = thread_fork_params_from_config( config.clone(), @@ -3935,6 +3940,7 @@ mod tests { ThreadParamsMode::Embedded, /*remote_cwd_override*/ None, ResumeModelSettings::OverrideFromCurrentConfig, + crate::resume_permissions::ResumePermissions::CURRENT_CONFIG, ); let control_fork = thread_fork_params_from_config( config.clone(), @@ -3965,6 +3971,7 @@ mod tests { ThreadParamsMode::Embedded, /*remote_cwd_override*/ None, ResumeModelSettings::OverrideFromCurrentConfig, + crate::resume_permissions::ResumePermissions::CURRENT_CONFIG, ); let treatment_fork = thread_fork_params_from_config( config, diff --git a/codex-rs/tui/src/app_server_session/prompt_history_tests.rs b/codex-rs/tui/src/app_server_session/prompt_history_tests.rs index 3969bc2ab8..8ee74ed7e0 100644 --- a/codex-rs/tui/src/app_server_session/prompt_history_tests.rs +++ b/codex-rs/tui/src/app_server_session/prompt_history_tests.rs @@ -31,6 +31,12 @@ async fn lifecycle_metadata_uses_local_prompt_history() -> Result<()> { .codex_home(server_home.path().to_path_buf()) .build() .await?; + crate::legacy_core::config::set_project_trust_level( + server_home.path(), + &std::env::current_dir()?, + codex_protocol::config_types::TrustLevel::Trusted, + ) + .map_err(|error| color_eyre::eyre::eyre!(error.to_string()))?; config.history.persistence = HistoryPersistence::None; config.history.max_bytes = Some(1); let thread_id = ThreadId::from_string( diff --git a/codex-rs/tui/src/app_server_session/reasoning_defaults_tests.rs b/codex-rs/tui/src/app_server_session/reasoning_defaults_tests.rs index 9813f2b9a1..3cb3cbdfba 100644 --- a/codex-rs/tui/src/app_server_session/reasoning_defaults_tests.rs +++ b/codex-rs/tui/src/app_server_session/reasoning_defaults_tests.rs @@ -69,6 +69,12 @@ stream_max_retries = 0 "# ), )?; + crate::legacy_core::config::set_project_trust_level( + home.path(), + &std::env::current_dir()?, + codex_protocol::config_types::TrustLevel::Trusted, + ) + .map_err(|error| color_eyre::eyre::eyre!(error.to_string()))?; let mut config = ConfigBuilder::default() .codex_home(home.path().to_path_buf()) .build() diff --git a/codex-rs/tui/src/app_server_session/rollout_history.rs b/codex-rs/tui/src/app_server_session/rollout_history.rs index 5a86ee0260..597bc152c0 100644 --- a/codex-rs/tui/src/app_server_session/rollout_history.rs +++ b/codex-rs/tui/src/app_server_session/rollout_history.rs @@ -137,6 +137,24 @@ impl AppServerSession { config: Config, thread_id: ThreadId, model_settings: ResumeModelSettings, + ) -> Result { + Box::pin(self.resume_thread_with_permission_overrides( + local_settings, + config, + thread_id, + model_settings, + crate::resume_permissions::ResumePermissions::default(), + )) + .await + } + + pub(crate) async fn resume_thread_with_permission_overrides( + &mut self, + local_settings: &crate::local_settings::LocalSettings, + config: Config, + thread_id: ThreadId, + model_settings: ResumeModelSettings, + permission_overrides: crate::resume_permissions::ResumePermissions, ) -> Result { let session_config = if matches!( model_settings, @@ -152,6 +170,7 @@ impl AppServerSession { self.thread_params_mode(), self.remote_cwd_override.as_deref(), model_settings, + permission_overrides, ); self.thread_tool_transport() .configure_mcp(&mut params.config); diff --git a/codex-rs/tui/src/app_server_session/rollout_history_tests.rs b/codex-rs/tui/src/app_server_session/rollout_history_tests.rs index c08e9407cb..0a6cd18c92 100644 --- a/codex-rs/tui/src/app_server_session/rollout_history_tests.rs +++ b/codex-rs/tui/src/app_server_session/rollout_history_tests.rs @@ -103,30 +103,33 @@ async fn remote_resume_restores_saved_server_profile_without_permission_override let mut server = crate::start_embedded_app_server_for_picker(&config).await?; server.thread_params_mode = ThreadParamsMode::Remote; - let resumed = server - .resume_thread( - &local_settings, - client_config.clone(), - thread_id, - ResumeModelSettings::RestoreFromThread, - ) - .await?; - assert_eq!( - resumed - .session - .active_permission_profile - .as_ref() - .map(|profile| profile.id.as_str()), - Some("server-only") - ); - assert_eq!( - resumed.session.approval_policy, - codex_app_server_protocol::AskForApproval::Never - ); - assert_eq!( - resumed.session.approvals_reviewer, - codex_protocol::config_types::ApprovalsReviewer::AutoReview - ); + for mode in [ThreadParamsMode::Embedded, ThreadParamsMode::Remote] { + server.thread_params_mode = mode; + let resumed = server + .resume_thread_with_permission_overrides( + &local_settings, + client_config.clone(), + thread_id, + ResumeModelSettings::RestoreFromThread, + crate::resume_permissions::ResumePermissions::default(), + ) + .await?; + assert_eq!( + ( + resumed + .session + .active_permission_profile + .map(|profile| profile.id), + resumed.session.approval_policy, + resumed.session.approvals_reviewer, + ), + ( + Some("server-only".to_string()), + codex_app_server_protocol::AskForApproval::Never, + codex_protocol::config_types::ApprovalsReviewer::AutoReview, + ), + ); + } // A locally remembered profile may have been removed since selection. let stale_selection = crate::app_event::PermissionProfileSelection { profile_id: "removed-profile".into(), @@ -194,6 +197,42 @@ async fn remote_resume_restores_saved_server_profile_without_permission_override codex_protocol::models::PermissionProfile::read_only() ); server.shutdown().await?; + let extra = tempfile::tempdir()?; + let mut overrides = crate::legacy_core::config::ConfigOverrides { + default_permissions: Some(":workspace".into()), + additional_writable_roots: vec![extra.path().to_path_buf()], + ..Default::default() + }; + let config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .harness_overrides(overrides.clone()) + .build() + .await?; + let mut server = crate::start_embedded_app_server_for_picker(&config).await?; + overrides.default_permissions = None; // Exercise --add-dir without a profile flag. + let permissions = + crate::resume_permissions::ResumePermissions::from_overrides(&config, &overrides); + let resumed = server + .resume_thread_with_permission_overrides( + &local_settings, + config.clone(), + thread_id, + ResumeModelSettings::RestoreFromThread, + permissions, + ) + .await?; + assert_eq!( + resumed.session.runtime_workspace_roots, + config.workspace_roots + ); + assert!( + resumed + .session + .permission_profile + .file_system_sandbox_policy() + .can_write_local_path_with_cwd(extra.path(), config.cwd.as_path()) + ); + server.shutdown().await?; Ok(()) } diff --git a/codex-rs/tui/src/app_server_session/workspace_roots_tests.rs b/codex-rs/tui/src/app_server_session/workspace_roots_tests.rs index 29e8611d80..ee2dcef069 100644 --- a/codex-rs/tui/src/app_server_session/workspace_roots_tests.rs +++ b/codex-rs/tui/src/app_server_session/workspace_roots_tests.rs @@ -188,6 +188,7 @@ async fn embedded_lifecycle_requests_preserve_explicit_workspace_roots() -> Resu ThreadParamsMode::Embedded, /*remote_cwd_override*/ None, ResumeModelSettings::RestoreFromThread, + crate::resume_permissions::ResumePermissions::CURRENT_CONFIG, ); let fork = thread_fork_params_from_config( config, diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__projectless_windows_sandbox_setup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__projectless_windows_sandbox_setup.snap new file mode 100644 index 0000000000..54f5d94a25 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__projectless_windows_sandbox_setup.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/chatwidget/tests/permissions.rs +expression: popup +--- + Set up the Codex agent sandbox to protect your files and control network access. Learn more + + + +› 1. Set up default sandbox (requires Administrator permissions) + 2. Use non-admin sandbox (higher risk if prompt injected) + 3. Quit + + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index 68f7a1e263..b20599a43d 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -571,11 +571,36 @@ async fn windows_auto_mode_prompt_requests_enabling_sandbox_feature() { ); } -#[cfg(target_os = "windows")] #[tokio::test] async fn startup_prompts_for_windows_sandbox_when_agent_requested() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let cwd = tempfile::tempdir().unwrap(); + chat.config = crate::legacy_core::config::ConfigBuilder::default() + .codex_home(chat.config.codex_home.to_path_buf()) + .loader_overrides(codex_config::LoaderOverrides::without_managed_config_for_tests()) + .harness_overrides(crate::legacy_core::config::ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }) + .build() + .await + .unwrap(); + assert!(chat.config.config_layer_stack.is_projectless()); + let approval = codex_protocol::protocol::AskForApproval::Granular( + codex_protocol::protocol::GranularApprovalConfig { + sandbox_approval: false, + rules: false, + skill_approval: false, + request_permissions: true, + mcp_elicitations: true, + }, + ); + chat.config + .permissions + .approval_policy + .set(approval) + .unwrap(); chat.set_windows_sandbox_mode(/*mode*/ None); chat.maybe_prompt_windows_sandbox_enable(/*show_now*/ true); @@ -597,6 +622,15 @@ async fn startup_prompts_for_windows_sandbox_when_agent_requested() { popup.contains("Quit"), "expected startup prompt to offer quit action: {popup}" ); + assert_chatwidget_snapshot!("projectless_windows_sandbox_setup", popup); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + let preset = std::iter::from_fn(|| rx.try_recv().ok()) + .find_map(|event| match event { + AppEvent::BeginWindowsSandboxElevatedSetup { preset, .. } => Some(preset), + _ => None, + }) + .expect("sandbox setup event"); + assert_eq!(preset.approval, approval); } #[cfg(target_os = "windows")] diff --git a/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs b/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs index bb3d63988b..0bcf5fbe1a 100644 --- a/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs +++ b/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs @@ -272,23 +272,25 @@ impl ChatWidget { ) { } - #[cfg(target_os = "windows")] + #[cfg(any(target_os = "windows", test))] pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self, show_now: bool) { let setup_is_required = !self.windows_sandbox_config.is_enabled() || self.elevated_windows_sandbox_setup_required(); if show_now && setup_is_required - && let Some(preset) = builtin_approval_presets() + && let Some(mut preset) = builtin_approval_presets() .into_iter() .find(|preset| preset.id == "auto") { + if self.config.active_project.trust_level.is_none() + && self.config.config_layer_stack.is_projectless() + { + preset.approval = self.config.permissions.approval_policy.value(); + } self.open_windows_sandbox_enable_prompt(preset, /*profile_selection*/ None); } } - #[cfg(all(not(target_os = "windows"), test))] - pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self, _show_now: bool) {} - #[cfg(any(target_os = "windows", test))] pub(crate) fn show_windows_sandbox_setup_status(&mut self) { // While elevated sandbox setup runs, prevent typing so the user doesn't diff --git a/codex-rs/tui/src/config_update.rs b/codex-rs/tui/src/config_update.rs index 7a50e6dd04..2595bac4c0 100644 --- a/codex-rs/tui/src/config_update.rs +++ b/codex-rs/tui/src/config_update.rs @@ -19,7 +19,7 @@ use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SkillsConfigWriteParams; use codex_app_server_protocol::SkillsConfigWriteResponse; use codex_config::default_project_root_markers; -use codex_config::loader::find_project_root; +use codex_config::loader::discover_project_root; use codex_config::loader::normalized_project_trust_keys; use codex_config::loader::project_trust_key; use codex_exec_server::LOCAL_ENVIRONMENT_ID; @@ -199,7 +199,12 @@ pub(crate) async fn read_remote_project_trust( cwd: &Path, host: ProjectTrustHost, ) -> Result> { - let cwd_string = cwd.to_string_lossy().into_owned(); + let cwd_string = cwd + .to_str() + .ok_or_else(|| { + color_eyre::eyre::eyre!("Cannot check folder trust: directory path is not valid UTF-8") + })? + .to_owned(); let cwd = match LegacyAppPathString::from_string(cwd_string.clone()).to_inferred_path_uri() { Some(cwd) => cwd, None => { @@ -272,16 +277,21 @@ pub(crate) async fn read_remote_project_trust( .min_by_key(|(path, _)| *path) }) }; + let mut projectless = false; let local_roots = if host == ProjectTrustHost::Local && project_layers.is_empty() { let cwd = AbsolutePathBuf::from_absolute_path(&cwd)?; let markers = serde_json::from_value::>>( response["config"]["project_root_markers"].clone(), )? .unwrap_or_else(default_project_root_markers); - let project_root = find_project_root(LOCAL_FS.as_ref(), &cwd, &markers).await?; + let project_root = discover_project_root(LOCAL_FS.as_ref(), &cwd, &markers).await?; let git_root = resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &cwd).await; + projectless = project_root.is_none() + && discover_project_root(LOCAL_FS.as_ref(), &cwd, &default_project_root_markers()) + .await? + .is_none(); Some(( - normalized_project_trust_keys(project_root.as_path()), + normalized_project_trust_keys(project_root.as_ref().unwrap_or(&cwd).as_path()), git_root.map(|root| normalized_project_trust_keys(root.as_path())), )) } else { @@ -345,6 +355,7 @@ pub(crate) async fn read_remote_project_trust( }); if !explicitly_untrusted && (trust_level == Some(TrustLevel::Trusted) + || trust_level.is_none() && projectless || (trust_level.is_none() && disabled_project.is_none() && project_layers @@ -405,13 +416,30 @@ pub(crate) async fn write_skill_enabled( #[path = "config_update_tests.rs"] mod tests; +/// Settings and layer kinds used by the TUI; ignore server-native paths and origins. +#[derive(serde::Deserialize)] +pub(crate) struct EffectiveConfig { + pub config: codex_app_server_protocol::Config, + pub layers: Option>, +} + /// Read effective server settings, retaining compatibility with servers without config/read. pub(crate) async fn read_effective_config_if_supported( request_handle: AppServerRequestHandle, cwd: &Path, -) -> Result> { - match read_effective_config(request_handle, cwd.display().to_string()).await { - Ok(response) => Ok(Some(response.config)), +) -> Result> { + match request_handle + .request_typed(ClientRequest::ConfigRead { + request_id: RequestId::String(format!("tui-config-read-{}", Uuid::new_v4())), + params: ConfigReadParams { + include_layers: true, + cwd: Some(cwd.display().to_string()), + }, + }) + .await + .wrap_err("config/read failed in TUI") + { + Ok(response) => Ok(Some(response)), Err(err) if matches!( err.downcast_ref::(), diff --git a/codex-rs/tui/src/config_update_tests.rs b/codex-rs/tui/src/config_update_tests.rs index c67888757e..7f0f7c9efd 100644 --- a/codex-rs/tui/src/config_update_tests.rs +++ b/codex-rs/tui/src/config_update_tests.rs @@ -67,6 +67,19 @@ async fn remote_project_trust_guards_thread_start_and_preserves_repository_decis let read_trust = async |cwd: &Path, host| { read_remote_project_trust(app_server.request_handle(), cwd, host).await }; + #[cfg(unix)] + { + use std::os::unix::ffi::OsStringExt; + let non_utf8 = project_root.join(std::ffi::OsString::from_vec(vec![0xff])); + let error = read_trust(&non_utf8, ProjectTrustHost::Local) + .await + .expect_err("trust checks must not inspect a lossy replacement path"); + assert!( + error + .to_string() + .contains("directory path is not valid UTF-8") + ); + } let relative_cwd = pathdiff::diff_paths(&project_cwd, std::env::current_dir()?) .ok_or_else(|| color_eyre::eyre::eyre!("failed to calculate relative project path"))?; @@ -262,17 +275,28 @@ async fn remote_project_trust_guards_thread_start_and_preserves_repository_decis } assert_eq!( read_trust(&canonical_project_cwd, ProjectTrustHost::Local).await?, - Some(RemoteProjectTrust { - trust_level: marked_root.then_some(TrustLevel::Untrusted), + marked_root.then(|| RemoteProjectTrust { + trust_level: Some(TrustLevel::Untrusted), cwd: canonical_project_cwd.clone(), - trust_target: if marked_root { - PathBuf::from(project_trust_key(&project_root)) - } else { - canonical_project_cwd.clone() - }, + trust_target: PathBuf::from(project_trust_key(&project_root)), }) ); } + // A gitfile checkout is still a project when configured markers exclude Git. + write_config_batch( + app_server.request_handle(), + vec![replace_config_value( + "project_root_markers", + serde_json::json!([]), + )], + ) + .await?; + std::fs::write(project_cwd.join(".git"), "gitdir: ../separate-git-dir\n")?; + assert!( + read_trust(&canonical_project_cwd, ProjectTrustHost::Local) + .await? + .is_some() + ); app_server.shutdown().await?; Ok(()) } @@ -292,3 +316,24 @@ fn format_config_error_preserves_server_validation_message() { features.fast_mode=true violates managed requirements; allowed set [fast_mode=false]" ); } + +#[test] +fn effective_defaults_accept_foreign_layer_paths() -> Result<()> { + let layers = serde_json::json!([ + {"name": {"type": "system", "file": "/etc/codex/config.toml"}, "version": "1", "config": {}}, + {"name": {"type": "project", "dotCodexFolder": "C:\\work\\.codex"}, "disabledReason": "untrusted", "version": "1", "config": {}} + ]); + let response: EffectiveConfig = serde_json::from_value(serde_json::json!({ + "config": {"model": "server-model"}, + "origins": {"model": {"name": {"type": "user", "file": "C:\\Users\\user\\.codex\\config.toml", "profile": null}, "version": "1"}}, + "layers": layers, + }))?; + assert_eq!( + ( + response.config.model.as_deref(), + serde_json::to_value(response.layers)? + ), + (Some("server-model"), layers) + ); + Ok(()) +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 8da049ef4f..ea3cd156ab 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -183,9 +183,11 @@ mod npm_registry; pub(crate) mod onboarding; mod oss_selection; mod pager_overlay; +mod projectless; pub(crate) mod public_widgets; mod render; mod resize_reflow_cap; +mod resume_permissions; mod resume_picker; mod screen_reader; mod service_tier_resolution; @@ -1828,7 +1830,10 @@ async fn run_ratatui_app( &config, &app_server_target, trust_cwd, - resumed_thread.as_ref(), + onboarding::DirectoryTrustOptions { + resumed_thread: resumed_thread.as_ref(), + ..Default::default() + }, Some(&mut startup_draft), ) .await?; @@ -1952,7 +1957,7 @@ async fn run_ratatui_app( } set_default_client_residency_requirement(config.enforce_residency.value()); - let should_show_trust_screen = should_show_trust_screen(&config); + let is_first_run = config.active_project.trust_level.is_none(); #[cfg(target_os = "windows")] let should_prompt_windows_sandbox_nux_at_startup = trust_decision_was_made; #[cfg(not(target_os = "windows"))] @@ -2041,7 +2046,7 @@ async fn run_ratatui_app( images, session_selection, feedback, - should_show_trust_screen, // Proxy to: is it a first run in this directory? + is_first_run, should_prompt_windows_sandbox_nux_at_startup, app_server_target, state_db, @@ -2277,11 +2282,6 @@ async fn load_bootstrap_config_or_exit( } } -/// Determine if the user has decided whether to trust the current directory. -fn should_show_trust_screen(config: &Config) -> bool { - config.active_project.trust_level.is_none() -} - fn should_show_onboarding( login_status: LoginStatus, requires_openai_auth: bool, @@ -2343,10 +2343,8 @@ pub(crate) mod tests { use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; - use codex_config::config_toml::ProjectConfig; use codex_utils_absolute_path::test_support::PathExt; use pretty_assertions::assert_eq; - use serial_test::serial; use tempfile::TempDir; #[test] @@ -3654,6 +3652,12 @@ requires_openai_auth = {requires_openai_auth} )?; assert_eq!(config_cwd, None); + assert!(!projectless::has_only_local_environments( + &environment_manager + )); + assert!(projectless::has_only_local_environments( + &EnvironmentManager::default_for_tests() + )); assert!(uses_remote_workspace_or_environment( &target, &environment_manager @@ -3671,22 +3675,6 @@ requires_openai_auth = {requires_openai_auth} Ok(()) } - #[tokio::test] - #[serial] - async fn windows_shows_trust_prompt_without_sandbox() -> std::io::Result<()> { - let temp_dir = TempDir::new()?; - let mut config = build_config(&temp_dir).await?; - config.active_project = ProjectConfig { trust_level: None }; - config.set_windows_sandbox_enabled(/*value*/ false); - - let should_show = should_show_trust_screen(&config); - assert!( - should_show, - "Trust prompt should be shown when project trust is undecided" - ); - Ok(()) - } - #[tokio::test] async fn embedded_app_server_supports_thread_start_rpc() -> color_eyre::Result<()> { let temp_dir = TempDir::new()?; @@ -3922,45 +3910,6 @@ requires_openai_auth = {requires_openai_auth} Ok(()) } - #[tokio::test] - #[serial] - async fn windows_shows_trust_prompt_with_sandbox() -> std::io::Result<()> { - let temp_dir = TempDir::new()?; - let mut config = build_config(&temp_dir).await?; - config.active_project = ProjectConfig { trust_level: None }; - config.set_windows_sandbox_enabled(/*value*/ true); - - let should_show = should_show_trust_screen(&config); - if cfg!(target_os = "windows") { - assert!( - should_show, - "Windows trust prompt should be shown on native Windows with sandbox enabled" - ); - } else { - assert!( - should_show, - "Non-Windows should still show trust prompt when project is untrusted" - ); - } - Ok(()) - } - #[tokio::test] - async fn untrusted_project_skips_trust_prompt() -> std::io::Result<()> { - use codex_protocol::config_types::TrustLevel; - let temp_dir = TempDir::new()?; - let mut config = build_config(&temp_dir).await?; - config.active_project = ProjectConfig { - trust_level: Some(TrustLevel::Untrusted), - }; - - let should_show = should_show_trust_screen(&config); - assert!( - !should_show, - "Trust prompt should not be shown for projects explicitly marked as untrusted" - ); - Ok(()) - } - #[tokio::test] async fn config_rebuild_changes_trust_defaults_with_cwd() -> std::io::Result<()> { let temp_dir = TempDir::new()?; diff --git a/codex-rs/tui/src/onboarding/directory_trust.rs b/codex-rs/tui/src/onboarding/directory_trust.rs index 2f90e32e1d..fde87b6c0a 100644 --- a/codex-rs/tui/src/onboarding/directory_trust.rs +++ b/codex-rs/tui/src/onboarding/directory_trust.rs @@ -9,7 +9,6 @@ use super::run_onboarding_screen; use crate::AppServerTarget; use crate::app_server_session::AppServerSession; use crate::config_update::ProjectTrustHost; -use crate::config_update::RemoteProjectTrust; use crate::config_update::read_remote_project_trust; use crate::legacy_core::config::Config; use crate::onboarding::trust_directory::TrustCancelAction; @@ -22,23 +21,31 @@ use codex_app_server_protocol::RequestId; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadReadParams; use codex_app_server_protocol::ThreadReadResponse; -use codex_exec_server::LOCAL_FS; -use codex_git_utils::resolve_root_git_project_for_trust; use codex_protocol::config_types::TrustLevel; use color_eyre::eyre::Result; use std::collections::VecDeque; use std::path::Path; use uuid::Uuid; +#[derive(Clone, Copy, Default)] +pub(crate) struct DirectoryTrustOptions<'a> { + pub resumed_thread: Option<&'a Thread>, + pub cancel: Option, +} + pub(crate) async fn check_directory_trust( tui: &mut Tui, app_server: &AppServerSession, config: &Config, target: &AppServerTarget, cwd: &Path, - resumed_thread: Option<&Thread>, + options: DirectoryTrustOptions<'_>, mut startup_draft: Option<&mut StartupDraftPump>, ) -> Result { + let DirectoryTrustOptions { + resumed_thread, + cancel, + } = options; let connected = !matches!(target, AppServerTarget::Embedded); let mut consent = OnboardingResult::default(); // Another client can load the saved task while consent is pending. Check both folders. @@ -58,30 +65,12 @@ pub(crate) async fn check_directory_trust( } checked_cwds.push(cwd.clone()); let cwd = cwd.as_path(); - let lookup = async { - if connected { - let host = if matches!(target, AppServerTarget::LocalDaemon { .. }) { - ProjectTrustHost::Local - } else { - ProjectTrustHost::Remote - }; - read_remote_project_trust(app_server.request_handle(), cwd, host).await - } else if config.active_project.trust_level == Some(TrustLevel::Trusted) { - Ok(None) - } else { - Ok(Some(RemoteProjectTrust { - cwd: cwd.to_path_buf(), - trust_target: resolve_root_git_project_for_trust( - LOCAL_FS.as_ref(), - &config.cwd, - ) - .await - .map(Into::into) - .unwrap_or_else(|| cwd.to_path_buf()), - trust_level: config.active_project.trust_level, - })) - } + let host = if !target.uses_remote_workspace() { + ProjectTrustHost::Local + } else { + ProjectTrustHost::Remote }; + let lookup = read_remote_project_trust(app_server.request_handle(), cwd, host); let project = if let Some(draft) = startup_draft.as_deref_mut() { draft.run_until(tui, lookup).await?? } else { @@ -95,6 +84,14 @@ pub(crate) async fn check_directory_trust( if target.uses_remote_workspace() && project.trust_level == Some(TrustLevel::Untrusted) { continue; } + if cancel == Some(TrustCancelAction::CurrentTask) + && project.trust_level == Some(TrustLevel::Untrusted) + && !config.active_project.is_untrusted() + { + color_eyre::eyre::bail!( + "Folder trust changed. Reopen the destination with its restricted settings." + ); + } let remote_trust_key = connected.then(|| project.trust_target.to_string_lossy().into_owned()); if let Some(draft) = startup_draft.as_deref_mut() { @@ -105,11 +102,11 @@ pub(crate) async fn check_directory_trust( steps: vec![Step::TrustDirectory(TrustDirectoryWidget { restricted: project.trust_level == Some(TrustLevel::Untrusted), existing_task: connected && resumed_thread.is_some(), - cancel: if connected { + cancel: cancel.unwrap_or(if connected { TrustCancelAction::AgentsOverview } else { TrustCancelAction::Quit - }, + }), cwd: project.cwd, trust_target: project.trust_target, show_windows_create_sandbox_hint: false, diff --git a/codex-rs/tui/src/onboarding/mod.rs b/codex-rs/tui/src/onboarding/mod.rs index c5438a1e51..6a9ea96124 100644 --- a/codex-rs/tui/src/onboarding/mod.rs +++ b/codex-rs/tui/src/onboarding/mod.rs @@ -5,4 +5,6 @@ pub(crate) mod onboarding_screen; mod trust_directory; pub(crate) use auth::mark_underlined_hyperlink; pub(crate) use auth::mark_url_hyperlink; +pub(crate) use onboarding_screen::DirectoryTrustOptions; +pub(crate) use trust_directory::TrustCancelAction; mod welcome; diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index 415c7d6376..0ec1006eca 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -61,6 +61,7 @@ use uuid::Uuid; #[path = "directory_trust.rs"] mod directory_trust; +pub(crate) use directory_trust::DirectoryTrustOptions; pub(crate) use directory_trust::check_directory_trust; #[allow(clippy::large_enum_variant)] diff --git a/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__directory_change_consent_can_keep_current_directory.snap b/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__directory_change_consent_can_keep_current_directory.snap new file mode 100644 index 0000000000..802d9e87d5 --- /dev/null +++ b/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__directory_change_consent_can_keep_current_directory.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/onboarding/trust_directory.rs +assertion_line: 365 +expression: terminal.backend() +--- + + Folder access + /workspace/project + + Trust this folder? Codex can read, edit, and run files here, + subject to your permission settings. Folder settings can run code + automatically, even without a model request. Continue only if you + trust these files. Your trust decision will be saved. + +› 1. Trust and continue + 2. Keep current directory + + enter continue · esc cancel diff --git a/codex-rs/tui/src/onboarding/trust_directory.rs b/codex-rs/tui/src/onboarding/trust_directory.rs index baab5861eb..64ea9ce98e 100644 --- a/codex-rs/tui/src/onboarding/trust_directory.rs +++ b/codex-rs/tui/src/onboarding/trust_directory.rs @@ -44,6 +44,7 @@ pub(crate) struct TrustDirectoryWidget { pub(crate) enum TrustCancelAction { Quit, AgentsOverview, + CurrentTask, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -91,6 +92,7 @@ impl WidgetRef for &TrustDirectoryWidget { match self.cancel { TrustCancelAction::Quit => "Quit", TrustCancelAction::AgentsOverview => "Back to Agent Command Center", + TrustCancelAction::CurrentTask => "Keep current directory", }, TrustDirectorySelection::Quit, ), @@ -130,6 +132,7 @@ impl WidgetRef for &TrustDirectoryWidget { match self.cancel { TrustCancelAction::Quit => " quit", TrustCancelAction::AgentsOverview => " back", + TrustCancelAction::CurrentTask => " cancel", } .dim(), ])) @@ -353,6 +356,20 @@ mod tests { insta::assert_snapshot!(terminal.backend()); } + #[test] + fn directory_change_consent_can_keep_current_directory() { + let mut widget = widget(/*error*/ None); + widget.cancel = TrustCancelAction::CurrentTask; + let mut terminal = + Terminal::new(VT100Backend::new(/*width*/ 70, /*height*/ 18)).expect("terminal"); + terminal + .draw(|f| (&widget).render_ref(f.area(), f.buffer_mut())) + .expect("draw"); + insta::assert_snapshot!(terminal.backend()); + widget.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(widget.should_quit()); + } + #[test] fn renders_snapshot_for_remote_git_subdirectory() { let widget = TrustDirectoryWidget { diff --git a/codex-rs/tui/src/projectless.rs b/codex-rs/tui/src/projectless.rs new file mode 100644 index 0000000000..a545b66ae5 --- /dev/null +++ b/codex-rs/tui/src/projectless.rs @@ -0,0 +1,110 @@ +//! Select desktop-like execution defaults for positively discovered local projectless folders. +//! Saved trust, explicit choices, managed policy, and remote execution retain their own defaults. + +use crate::app_server_session::AppServerSession; +use crate::legacy_core::config::Config; +use crate::legacy_core::config::ConfigOverrides; +use codex_exec_server::EnvironmentManager; +use codex_protocol::models::ActivePermissionProfile; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; +use codex_protocol::models::PermissionProfile; +use codex_protocol::models::PermissionProfileSnapshot; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::GranularApprovalConfig; + +/// Returns whether eligible implicit permissions require Windows sandbox setup. +pub(crate) fn apply_defaults( + config: &mut Config, + overrides: &ConfigOverrides, + app_server: &AppServerSession, + environments: &EnvironmentManager, + response: &crate::config_update::EffectiveConfig, +) -> bool { + let defaults = &response.config; + let effective = config.config_layer_stack.effective_config(); + if response.layers.as_ref().is_none_or(|layers| layers.iter().any(|layer| { + layer["name"]["type"] == "project" + })) + || app_server.uses_remote_workspace() + || defaults.sandbox_mode.is_some() + || defaults.sandbox_workspace_write.is_some() + || ["default_permissions", "permissions", "network"].iter() + .any(|key| defaults.additional.get(*key).is_some_and(|value| !value.is_null())) + // Reuse local discovery only when server markers and trust decisions agree. + || ["project_root_markers", "projects"].iter().any(|key| + serde_json::to_value(effective.get(*key)).ok().as_ref() + != Some(defaults.additional.get(*key).unwrap_or(&serde_json::Value::Null))) + || !config.config_layer_stack.is_projectless() + || config.active_project.trust_level.is_some() + || config.workspace_roots.len() != 1 + || config.workspace_roots.first() != Some(&config.cwd) + || !has_only_local_environments(environments) + || overrides.sandbox_mode.is_some() + || overrides.permission_profile.is_some() + || overrides.default_permissions.is_some() + || [ + "sandbox_mode", + "default_permissions", + "permissions", + "sandbox_workspace_write", + "network", + ] + .iter() + .any(|key| effective.get(*key).is_some()) + || config + .config_layer_stack + .requirements_toml() + .default_permissions + .is_some() + || !config.is_permission_profile_allowed( + BUILT_IN_PERMISSION_PROFILE_WORKSPACE, + &PermissionProfile::workspace_write(), + ) + { + return false; + } + let granular = AskForApproval::Granular(GranularApprovalConfig { + sandbox_approval: false, + rules: false, + skill_approval: false, + request_permissions: true, + mcp_elicitations: true, + }); + if overrides.approval_policy.is_none() && effective.get("approval_policy").is_none() { + let granular = defaults + .approval_policy + .as_ref() + .map_or(granular, |policy| policy.to_core()); + let _ = config.permissions.approval_policy.set(granular); + } + #[cfg(target_os = "windows")] + if config.effective_local_windows_sandbox_type() == codex_sandboxing::SandboxType::None { + return true; + } + if config + .permissions + .set_permission_profile(PermissionProfile::workspace_write()) + .is_err() + { + return false; + } + let snapshot = PermissionProfileSnapshot::active( + config.permissions.permission_profile().clone(), + ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()), + ); + let _ = config + .permissions + .set_permission_profile_from_session_snapshot(snapshot); + false +} + +/// Local filesystem discovery is authoritative only for positively local execution. +pub(crate) fn has_only_local_environments(environments: &EnvironmentManager) -> bool { + let ids = environments.default_environment_ids(); + !ids.is_empty() + && ids.iter().all(|id| { + environments + .get_environment(id) + .is_some_and(|environment| !environment.is_remote()) + }) +} diff --git a/codex-rs/tui/src/resume_permissions.rs b/codex-rs/tui/src/resume_permissions.rs new file mode 100644 index 0000000000..cf6fc5b4c1 --- /dev/null +++ b/codex-rs/tui/src/resume_permissions.rs @@ -0,0 +1,57 @@ +//! Tracks direct launch permission choices separately from configuration defaults. +//! Omitted choices let app-server restore the destination task's saved settings. + +use crate::legacy_core::config::Config; +use crate::legacy_core::config::ConfigOverrides; +use codex_config::ConfigLayerSource; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct ResumePermissions { + pub approval_policy: bool, + pub approvals_reviewer: bool, + pub profile: bool, + pub workspace_roots: bool, +} + +impl ResumePermissions { + pub(crate) fn from_overrides(config: &Config, overrides: &ConfigOverrides) -> Self { + let has = |path: &str| { + config.config_layer_stack.layers_high_to_low().any(|layer| { + matches!(layer.name, ConfigLayerSource::SessionFlags) + && path + .split('.') + .try_fold(&layer.config, |value, key| value.get(key)) + .is_some() + }) + }; + Self { + approval_policy: overrides.approval_policy.is_some() || has("approval_policy"), + approvals_reviewer: overrides.approvals_reviewer.is_some() || has("approvals_reviewer"), + profile: overrides.sandbox_mode.is_some() + || overrides.permission_profile.is_some() + || overrides.default_permissions.is_some() + || !overrides.additional_writable_roots.is_empty() + || has("default_permissions") + || has("sandbox_mode") + || has("sandbox_workspace_write") + || has("permissions") + || has("network"), + workspace_roots: overrides.cwd.is_some() + || overrides.workspace_roots.is_some() + || !overrides.additional_writable_roots.is_empty() + || has("sandbox_workspace_write.writable_roots"), + } + } + + #[cfg(test)] + pub(crate) const CURRENT_CONFIG: Self = Self { + approval_policy: true, + approvals_reviewer: true, + profile: true, + workspace_roots: true, + }; +} + +#[cfg(test)] +#[path = "resume_permissions_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/resume_permissions_tests.rs b/codex-rs/tui/src/resume_permissions_tests.rs new file mode 100644 index 0000000000..b9f68fada7 --- /dev/null +++ b/codex-rs/tui/src/resume_permissions_tests.rs @@ -0,0 +1,63 @@ +use super::ResumePermissions; +use crate::legacy_core::config::ConfigBuilder; +use crate::legacy_core::config::ConfigOverrides; +use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn only_direct_launch_choices_override_saved_permissions() -> color_eyre::Result<()> { + let home = tempfile::tempdir()?; + let profile = home.path().join("work.config.toml"); + std::fs::write( + &profile, + "default_permissions = \":read-only\"\napproval_policy = \"never\"\napprovals_reviewer = \"auto_review\"\n", + )?; + let builder = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .loader_overrides(codex_config::LoaderOverrides { + user_config_path: Some(AbsolutePathBuf::try_from(profile)?), + user_config_profile: Some("work".parse()?), + ..codex_config::LoaderOverrides::without_managed_config_for_tests() + }); + let config = builder.clone().build().await?; + assert_eq!( + ResumePermissions::from_overrides(&config, &ConfigOverrides::default()), + ResumePermissions::default(), + ); + let direct_rules = builder + .clone() + .cli_overrides(vec![( + "permissions.safe.extends".into(), + ":read-only".into(), + )]) + .build() + .await?; + assert_eq!( + ResumePermissions::from_overrides(&direct_rules, &ConfigOverrides::default()), + ResumePermissions { + profile: true, + ..Default::default() + }, + ); + let config = builder + .cli_overrides(vec![("approval_policy".into(), "on-request".into())]) + .build() + .await?; + assert_eq!( + ResumePermissions::from_overrides( + &config, + &ConfigOverrides { + default_permissions: Some(":read-only".into()), + cwd: Some(home.path().to_path_buf()), + ..Default::default() + }, + ), + ResumePermissions { + approval_policy: true, + profile: true, + workspace_roots: true, + ..Default::default() + }, + ); + Ok(()) +} diff --git a/codex-rs/tui/src/session_start.rs b/codex-rs/tui/src/session_start.rs index ba41e706e5..08a6fc8220 100644 --- a/codex-rs/tui/src/session_start.rs +++ b/codex-rs/tui/src/session_start.rs @@ -16,14 +16,17 @@ use color_eyre::eyre::WrapErr; #[derive(Clone, Copy)] pub(crate) enum SessionStartAction { - Resume(ResumeModelSettings), + Resume( + ResumeModelSettings, + crate::resume_permissions::ResumePermissions, + ), Fork(ForkPermissionMode), } impl SessionStartAction { pub(crate) fn verb(self) -> &'static str { match self { - Self::Resume(_) => "resume", + Self::Resume(..) => "resume", Self::Fork(_) => "fork", } } @@ -36,9 +39,15 @@ impl SessionStartAction { target: &SessionTarget, ) -> Result { match self { - Self::Resume(settings) => { + Self::Resume(settings, permissions) => { app_server - .resume_thread(local_settings, config.clone(), target.thread_id, settings) + .resume_thread_with_permission_overrides( + local_settings, + config.clone(), + target.thread_id, + settings, + permissions, + ) .await } Self::Fork(permission_mode) => { diff --git a/codex-rs/tui/src/session_start_tests.rs b/codex-rs/tui/src/session_start_tests.rs index 3be0a4d0ac..42666f541e 100644 --- a/codex-rs/tui/src/session_start_tests.rs +++ b/codex-rs/tui/src/session_start_tests.rs @@ -37,7 +37,10 @@ async fn start_session( #[tokio::test] async fn archived_session_requires_confirmation_before_resume_or_fork() -> Result<()> { for action in [ - SessionStartAction::Resume(ResumeModelSettings::RestoreFromThread), + SessionStartAction::Resume( + ResumeModelSettings::RestoreFromThread, + crate::resume_permissions::ResumePermissions::default(), + ), SessionStartAction::Fork(crate::app_server_session::ForkPermissionMode::InheritSaved), ] { let codex_home = TempDir::new()?; @@ -134,7 +137,7 @@ async fn archived_session_requires_confirmation_before_resume_or_fork() -> Resul (7, false, true) ); match action { - SessionStartAction::Resume(_) => { + SessionStartAction::Resume(..) => { assert_eq!(started.session.thread_id, target.thread_id) } SessionStartAction::Fork(_) => { @@ -153,7 +156,10 @@ async fn archived_session_requires_confirmation_before_resume_or_fork() -> Resul &mut app_server, &config, &target, - SessionStartAction::Resume(ResumeModelSettings::RestoreFromThread), + SessionStartAction::Resume( + ResumeModelSettings::RestoreFromThread, + crate::resume_permissions::ResumePermissions::default(), + ), async || panic!("active sessions must not prompt"), ) .await? diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_reviewer_conflict.snap b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_reviewer_conflict.snap new file mode 100644 index 0000000000..90ce69fd50 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_reviewer_conflict.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/app/agents_overview_tests.rs +expression: "crate::chatwidget::tests::helpers::render_bottom_popup(&app.chat_widget, 96)" +--- + Unable to complete action + Failed to carry forward approvals reviewer: invalid value for `approvals_reviewer`: `User` + is not in the allowed set [AutoReview] (set by ) + + +› 1. Return to command center + + enter select · esc back diff --git a/codex-rs/tui/src/unarchive_prompt_tests.rs b/codex-rs/tui/src/unarchive_prompt_tests.rs index 5c6df51e94..4a7a066fe6 100644 --- a/codex-rs/tui/src/unarchive_prompt_tests.rs +++ b/codex-rs/tui/src/unarchive_prompt_tests.rs @@ -32,6 +32,7 @@ fn render(screen: &UnarchivePrompt) -> String { fn resume_prompt_snapshot() { let screen = prompt(SessionStartAction::Resume( ResumeModelSettings::RestoreFromThread, + crate::resume_permissions::ResumePermissions::default(), )); insta::assert_snapshot!(render(&screen), @" This conversation is archived diff --git a/codex-rs/tui/tests/suite/directory_trust.rs b/codex-rs/tui/tests/suite/directory_trust.rs index b9efce788e..f44a19a9ee 100644 --- a/codex-rs/tui/tests/suite/directory_trust.rs +++ b/codex-rs/tui/tests/suite/directory_trust.rs @@ -4,6 +4,7 @@ use super::focus_palette::PtyCodex; use super::focus_palette::write_test_config; use anyhow::Result; use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::RequestId; use futures::SinkExt; use futures::StreamExt; use pretty_assertions::assert_eq; @@ -19,7 +20,10 @@ use tokio_tungstenite::tungstenite::Message; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn connected_trust_cancellation_and_acceptance_control_task_creation() -> Result<()> { for trust_level in [None, Some("untrusted")] { - let repo_root = codex_utils_cargo_bin::repo_root()?; + let workspace = tempfile::tempdir()?; + let repo_root = workspace.path().canonicalize()?; + std::fs::create_dir(repo_root.join(".git"))?; + std::fs::write(repo_root.join(".git/HEAD"), "ref: refs/heads/main\n")?; let codex_home = tempfile::tempdir_in("/tmp")?; // The server's trust decision must win over the client's trusted-folder setting. write_test_config(codex_home.path(), &repo_root)?; @@ -80,11 +84,7 @@ async fn connected_trust_cancellation_and_acceptance_control_task_creation() -> json!({"account": {"type": "apiKey"}, "requiresOpenaiAuth": false}) } "config/read" => { - if request - .params - .as_ref() - .is_some_and(|params| params["includeLayers"] == true) - { + if matches!(&request.id, RequestId::String(id) if id.starts_with("tui-project-trust-read-")) { trust_reads.fetch_add(1, Ordering::SeqCst); } json!({"config": {"model": "gpt-5.6-terra", "projects": {