mirror of
https://github.com/openai/codex.git
synced 2026-10-21 05:22:21 +00:00
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:
@@ -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() {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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)),
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
¶ms["model"]
|
||||
),
|
||||
(
|
||||
&serde_json::json!(":read-only"),
|
||||
&serde_json::Value::Null,
|
||||
&serde_json::json!("on-request"),
|
||||
&serde_json::json!("gpt-5.5")
|
||||
)
|
||||
@@ -1048,7 +1064,7 @@ async fn command_center_new_checkout_and_worktree_preserve_source_and_default_br
|
||||
home.join("config.toml"),
|
||||
format!(
|
||||
"approvals_reviewer = \"auto_review\"\nsandbox_mode = \"workspace-write\"\napproval_policy = \"on-request\"\nwindows.sandbox = \"unelevated\"\n[projects.{:?}]\ntrust_level = \"trusted\"\n",
|
||||
source.canonicalize()?.display().to_string(),
|
||||
codex_config::loader::project_trust_key(&source),
|
||||
),
|
||||
)?;
|
||||
let manager =
|
||||
|
||||
@@ -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??;
|
||||
|
||||
@@ -739,7 +739,7 @@ impl App {
|
||||
cwd,
|
||||
approval_policy,
|
||||
approvals_reviewer,
|
||||
active_permission_profile,
|
||||
active_permission_profile: _,
|
||||
model,
|
||||
effort,
|
||||
summary,
|
||||
@@ -830,10 +830,6 @@ impl App {
|
||||
let selected_profile = self.pending_server_profiles.get(&thread_id);
|
||||
let selected_active = selected_profile
|
||||
.map(|profile| ActivePermissionProfile::new(profile.profile_id.clone()));
|
||||
let confirmed_active = (self.app_server_target.thread_params_mode()
|
||||
== crate::app_server_session::ThreadParamsMode::Remote)
|
||||
.then(|| config.permissions.active_permission_profile())
|
||||
.flatten();
|
||||
let (turn_approval_policy, turn_approvals_reviewer) =
|
||||
if let Some(profile) = selected_profile {
|
||||
(
|
||||
@@ -857,14 +853,21 @@ impl App {
|
||||
),
|
||||
)
|
||||
};
|
||||
// Only explicit choices may override the saved server profile.
|
||||
let explicit_profile = self
|
||||
.runtime_permission_profile_override
|
||||
.as_ref()
|
||||
.filter(|profile| {
|
||||
profile.turn_override
|
||||
== RuntimePermissionProfileTurnOverride::LegacySandbox
|
||||
});
|
||||
let permissions_override = Self::turn_permissions_override_from_config(
|
||||
config,
|
||||
selected_active
|
||||
.as_ref()
|
||||
.or(confirmed_active.as_ref())
|
||||
.or(active_permission_profile.as_ref()),
|
||||
self.runtime_permission_profile_override
|
||||
.as_ref()
|
||||
selected_active.as_ref().or_else(|| {
|
||||
explicit_profile
|
||||
.and_then(|profile| profile.active_permission_profile.as_ref())
|
||||
}),
|
||||
explicit_profile
|
||||
.and_then(RuntimePermissionProfileOverride::turn_permission_profile),
|
||||
);
|
||||
let response = app_server
|
||||
@@ -1186,7 +1189,7 @@ impl App {
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let mut permission_change_confirmed = false;
|
||||
let mut confirmed_profile = None;
|
||||
if let ServerNotification::ThreadSettingsUpdated(notification) = ¬ification {
|
||||
self.apply_thread_settings_to_cached_session(thread_id, ¬ification.thread_settings)
|
||||
.await;
|
||||
@@ -1207,8 +1210,7 @@ impl App {
|
||||
})
|
||||
})
|
||||
{
|
||||
self.pending_server_profiles.remove(&thread_id);
|
||||
permission_change_confirmed = true;
|
||||
confirmed_profile = self.pending_server_profiles.remove(&thread_id);
|
||||
}
|
||||
}
|
||||
let inferred_session = if let ServerNotification::ThreadStarted(started) = ¬ification
|
||||
@@ -1296,7 +1298,13 @@ impl App {
|
||||
.on_thread_settings_updated(settings.clone());
|
||||
notification = None;
|
||||
}
|
||||
if permission_change_confirmed {
|
||||
if let Some(selected) = confirmed_profile {
|
||||
if self.chat_widget.thread_id() == Some(thread_id) {
|
||||
if selected.approvals_reviewer.is_some() {
|
||||
self.runtime_approvals_reviewer_override = None;
|
||||
}
|
||||
self.runtime_permission_profile_override = None;
|
||||
}
|
||||
if self.chat_widget.thread_id() == Some(thread_id)
|
||||
&& let Some(profile) = self
|
||||
.chat_widget
|
||||
@@ -1322,6 +1330,7 @@ impl App {
|
||||
Some(RuntimeApprovalPolicyOverride::Explicit(
|
||||
self.config.permissions.approval_policy.value().into(),
|
||||
));
|
||||
self.runtime_approvals_reviewer_override = Some(self.config.approvals_reviewer);
|
||||
self.runtime_permission_profile_override =
|
||||
Some(RuntimePermissionProfileOverride::from_config(&self.config));
|
||||
}
|
||||
@@ -1554,6 +1563,14 @@ impl App {
|
||||
tracing::warn!(%err, "failed to sync app permissions from thread session");
|
||||
}
|
||||
self.config.approvals_reviewer = session.approvals_reviewer;
|
||||
if !self.app_server_target.uses_remote_workspace() {
|
||||
self.config
|
||||
.workspace_roots
|
||||
.clone_from(&session.runtime_workspace_roots);
|
||||
self.config
|
||||
.permissions
|
||||
.set_workspace_roots(session.runtime_workspace_roots.clone());
|
||||
}
|
||||
|
||||
let thread_id = session.thread_id;
|
||||
self.pending_server_profiles.remove(&thread_id);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+13
@@ -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
|
||||
|
||||
@@ -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>(),
|
||||
|
||||
@@ -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
@@ -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()?;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
+18
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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?
|
||||
|
||||
+12
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user