Support projectless TUI sessions with workspace defaults (#49160)

## 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
This commit is contained in:
Eric Traut
2026-09-29 04:13:14 +00:00
committed by copyberry
parent c6c7c8d270
commit 0d7b8117d3
55 changed files with 1836 additions and 528 deletions
+1
View File
@@ -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<String, String> = std::env::vars().collect();
for (key, value) in command.get_envs() {
+2 -1
View File
@@ -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],
+1 -3
View File
@@ -541,6 +541,7 @@ pub(crate) struct App {
loader_overrides: LoaderOverrides,
cloud_config_bundle: CloudConfigBundleLoader,
runtime_approval_policy_override: Option<RuntimeApprovalPolicyOverride>,
runtime_approvals_reviewer_override: Option<ApprovalsReviewer>,
runtime_permission_profile_override: Option<RuntimePermissionProfileOverride>,
/// In-flight remote selections; confirmed settings live in each task's server snapshot.
pending_server_profiles: HashMap<ThreadId, PermissionProfileSelection>,
@@ -672,7 +673,6 @@ struct RuntimePermissionProfileOverride {
permission_profile: PermissionProfile,
active_permission_profile: Option<ActivePermissionProfile>,
network: Option<crate::legacy_core::config::NetworkProxySpec>,
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> {
+57 -27
View File
@@ -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(),
@@ -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 <requirements>)");
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"
+71 -42
View File
@@ -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<Config> {
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()
+2 -3
View File
@@ -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(
@@ -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<Item = ThreadId>,
) -> 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::<ListResponse>(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::<ListResponse>(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
+8 -4
View File
@@ -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())
+11 -2
View File
@@ -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,
);
+2 -2
View File
@@ -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)),
});
+9 -9
View File
@@ -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()
{
+18 -5
View File
@@ -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(),
+25 -13
View File
@@ -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;
+41 -10
View File
@@ -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<bool> {
environments: &EnvironmentManager,
) -> Result<FreshStartupDefaults> {
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(),
+1
View File
@@ -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,
+47 -36
View File
@@ -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<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,
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(),
@@ -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"}}})
@@ -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::<Vec<_>>();
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::<Vec<_>>();
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
&params["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 =
@@ -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 {
@@ -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,
@@ -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(),
@@ -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"}})),
@@ -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);
@@ -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
@@ -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(())
}
@@ -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
@@ -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<_>>(),
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::<Vec<_>>();
assert!(!reads.is_empty());
assert!(
reads.iter().all(|params| params["cwd"] == expected_cwd),
"{reads:?}"
);
server.shutdown().await?;
proxy.await??;
+32 -15
View File
@@ -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) = &notification {
self.apply_thread_settings_to_cached_session(thread_id, &notification.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) = &notification
@@ -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);
+201 -88
View File
@@ -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 <checkout-path>` 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::<ListResponse>(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 <checkout-path>` 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);
}
+25 -18
View File
@@ -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<ThreadSessionState, String> {
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,
@@ -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(
@@ -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()
@@ -137,6 +137,24 @@ impl AppServerSession {
config: Config,
thread_id: ThreadId,
model_settings: ResumeModelSettings,
) -> Result<AppServerStartedThread> {
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<AppServerStartedThread> {
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);
@@ -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(())
}
@@ -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,
@@ -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
<https://developers.openai.com/codex/windows>
› 1. Set up default sandbox (requires Administrator permissions)
2. Use non-admin sandbox (higher risk if prompt injected)
3. Quit
enter select · esc back
@@ -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")]
@@ -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
+35 -7
View File
@@ -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<Option<RemoteProjectTrust>> {
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::<Option<Vec<String>>>(
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<Vec<JsonValue>>,
}
/// 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<Option<codex_app_server_protocol::Config>> {
match read_effective_config(request_handle, cwd.display().to_string()).await {
Ok(response) => Ok(Some(response.config)),
) -> Result<Option<EffectiveConfig>> {
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::<TypedRequestError>(),
+52 -7
View File
@@ -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(())
}
+14 -65
View File
@@ -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()?;
+26 -29
View File
@@ -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<TrustCancelAction>,
}
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<OnboardingResult> {
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,
+2
View File
@@ -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;
@@ -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)]
@@ -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
@@ -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 {
+110
View File
@@ -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())
})
}
+57
View File
@@ -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;
@@ -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(())
}
+13 -4
View File
@@ -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<AppServerStartedThread> {
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) => {
+9 -3
View File
@@ -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?
@@ -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 <requirements>)
› 1. Return to command center
enter select · esc back
@@ -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
+6 -6
View File
@@ -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": {