Check folder consent before creating or resuming TUI tasks (#44755)

## What changed

- Check directory trust when dispatching tasks from Agent Command Center, resuming sessions, and opening loaded tasks.
- Return connected users to Agent Command Center when they cancel folder consent. Clear launch prompts and images on startup cancellation, and restore the composer draft when task dispatch is canceled.
- Reload local configuration after saving folder trust and review startup hooks before creating a new task.
- Label connected consent screens with “Back to Agent Command Center”; retain quit behavior for embedded sessions.

## Testing

Add a terminal integration test covering unknown and untrusted folders, server-authoritative trust, cancellation and retry, resumed tasks with changed directories, and hook review before task creation. Update consent-screen snapshots.

GitOrigin-RevId: db88d2113fd47bc4c57369cc20ebf3042e6fb7ab
This commit is contained in:
Eric Traut
2026-09-11 07:13:19 +00:00
committed by copyberry
parent b9934480bf
commit 02a8f038b8
20 changed files with 544 additions and 42 deletions
+31 -3
View File
@@ -406,7 +406,7 @@ impl App {
target_thread.status,
codex_app_server_protocol::ThreadStatus::NotLoaded
);
let (mut resume_config, local_settings) = if unloaded {
let (mut resume_config, mut local_settings) = if unloaded {
let target_session = SessionTarget {
path: target_thread.path.clone(),
thread_id: root_thread_id,
@@ -440,8 +440,20 @@ impl App {
}
}
};
if unloaded && self.reject_remote_resume_permission_override(&resume_config) {
return Ok(AppRunControl::Continue);
if !unloaded {
if let Err(control) = self
.confirm_directory_trust(
tui,
app_server,
&mut resume_config,
target_thread.cwd.as_path(),
Some(&target_thread),
)
.await
{
return Ok(control);
}
local_settings = crate::local_settings::LocalSettings::from(&resume_config);
}
let baseline_approval = resume_config.permissions.approval_policy.value();
let baseline_permissions =
@@ -660,6 +672,7 @@ impl App {
pub(super) async fn dispatch_agents_overview_task(
&mut self,
tui: &mut tui::Tui,
app_server: &mut AppServerSession,
prompt: UserMessage,
cwd: Option<AbsolutePathBuf>,
@@ -687,6 +700,21 @@ impl App {
},
None => self.fresh_session_config(),
};
let trust_cwd = config.cwd.to_path_buf();
if self
.confirm_directory_trust(
tui,
app_server,
&mut config,
&trust_cwd,
/*resumed_thread*/ None,
)
.await
.is_err()
{
self.restore_agents_overview_prompt(prompt);
return;
}
if let Some(profile) = self.runtime_permission_profile_override.as_ref()
&& profile.active_permission_profile.is_some()
&& (!profile.matches_config(&config)
@@ -401,8 +401,13 @@ async fn overview_composer_preserves_editing_and_routes_focus() {
.await
.unwrap();
app.cli_kv_overrides = vec![("model".into(), toml::Value::Integer(1))];
app.dispatch_agents_overview_task(&mut server, "retry me".into(), Some(app.config.cwd.clone()))
.await;
app.dispatch_agents_overview_task(
&mut crate::tui::test_support::make_test_tui().expect("test tui"),
&mut server,
"retry me".into(),
Some(app.config.cwd.clone()),
)
.await;
view.handle_paste(" later".into());
app.submit_agents_overview_prompt(&server, thread_id, "older failure".into(), Vec::new())
.await;
@@ -1722,6 +1727,7 @@ async fn failed_root_switch_keeps_background_requests_on_the_active_session() ->
#[tokio::test]
async fn root_switch_preserves_vim_line_yank() -> Result<()> {
let mut app = make_test_app().await;
trust_fixture_folders(&mut app);
std::fs::write(
app.local_settings.user_config_path.as_path(),
"[tui]\nresume_cwd = \"session\"\n",
+1 -1
View File
@@ -2745,7 +2745,7 @@ impl App {
}
}
AppEvent::DispatchAgentsOverviewTask { prompt, cwd } => {
self.dispatch_agents_overview_task(app_server, prompt, cwd)
self.dispatch_agents_overview_task(tui, app_server, prompt, cwd)
.await;
}
AppEvent::RenameAgentsOverviewThread { thread_id, name } => {
+5 -1
View File
@@ -287,7 +287,11 @@ impl App {
cwd: None,
history_mode: None,
};
let _ = self.resume_target_session(tui, app_server, target).await;
if let Ok(AppRunControl::Exit(_)) =
self.resume_target_session(tui, app_server, target).await
{
self.app_event_tx.send(AppEvent::Exit(ExitMode::Immediate));
}
return;
}
}
+122 -2
View File
@@ -1,8 +1,13 @@
//! Shared configuration and working-directory resolution for ordinary and overview cold resumes.
//! Folder-entry consent and configuration for command-center dispatch and resume.
//! Keeps CLI/runtime cwd precedence, remote-workspace checks, and interactive prompts aligned.
//! Carries local preferences alongside the resolved configuration for session replacement.
use super::agents_overview_view::AgentsOverviewFocus;
use super::*;
use crate::onboarding::onboarding_screen::check_directory_trust;
use crate::startup_hooks_review::StartupHooksReviewOutcome;
use crate::startup_hooks_review::load_startup_hooks_review_entry;
use crate::startup_hooks_review::maybe_run_startup_hooks_review;
use codex_config::types::ResumeCwdMode;
impl App {
@@ -93,7 +98,7 @@ impl App {
} else {
(current_cwd, resume_cwd)
};
let resume_config = match self
let mut resume_config = match self
.rebuild_config_for_resume_or_fallback(&config_current_cwd, config_resume_cwd)
.await
{
@@ -105,6 +110,121 @@ impl App {
return Err(AppRunControl::Continue);
}
};
if self.reject_remote_resume_permission_override(&resume_config.0) {
return Err(AppRunControl::Continue);
}
let resumed_thread =
if matches!(self.app_server_target, AppServerTarget::LocalDaemon { .. }) {
Some(
app_server
.thread_read(target_session.thread_id, /*include_turns*/ false)
.await
.map_err(|error| {
self.add_session_picker_error(format!(
"Unable to check resumed folder: {error}"
));
AppRunControl::Continue
})?,
)
} else {
None
};
let trust_cwd = resume_config.0.cwd.to_path_buf();
self.confirm_directory_trust(
tui,
app_server,
&mut resume_config.0,
&trust_cwd,
resumed_thread.as_ref(),
)
.await?;
resume_config.1 = crate::local_settings::LocalSettings::from(&resume_config.0);
Ok(resume_config)
}
pub(super) async fn confirm_directory_trust(
&mut self,
tui: &mut tui::Tui,
app_server: &mut AppServerSession,
config: &mut Config,
cwd: &Path,
resumed_thread: Option<&codex_app_server_protocol::Thread>,
) -> std::result::Result<(), AppRunControl> {
// Keep the existing explicit remote --cd gate, including retries after cancellation.
// Other remote destinations await authoritative trust-root metadata.
let cwd = if self.app_server_target.uses_remote_workspace() {
let Some(cwd) = app_server.remote_cwd_override() else {
return Ok(());
};
cwd
} else {
cwd
};
let result = check_directory_trust(
tui,
app_server,
config,
&self.app_server_target,
cwd,
resumed_thread,
/*startup_draft*/ None,
)
.await
.map_err(|error| {
self.add_session_picker_error(format!("Unable to check folder trust: {error}"));
AppRunControl::Continue
})?;
if result.should_exit {
if matches!(self.app_server_target, AppServerTarget::Embedded) {
return Err(AppRunControl::Exit(ExitReason::UserRequested));
}
if self
.chat_widget
.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID)
.is_none()
{
self.open_agents_overview(app_server, AgentsOverviewFocus::List);
}
return Err(AppRunControl::Continue);
}
if result.directory_trust_persisted && !app_server.uses_remote_workspace() {
*config = self
.rebuild_config_for_cwd(config.cwd.to_path_buf())
.await
.map_err(|error| {
self.add_session_picker_error(format!(
"Failed to reload trusted folder settings: {error}"
));
AppRunControl::Continue
})?;
if resumed_thread.is_none() {
let hooks = load_startup_hooks_review_entry(
app_server.request_handle(),
config.cwd.to_path_buf(),
)
.await;
match maybe_run_startup_hooks_review(
app_server,
tui,
config,
config.bypass_hook_trust,
hooks,
)
.await
.map_err(|error| {
self.add_session_picker_error(format!(
"Unable to review folder hooks: {error}"
));
AppRunControl::Continue
})? {
StartupHooksReviewOutcome::Continue => {}
StartupHooksReviewOutcome::OpenHooksBrowser(hooks) => {
self.chat_widget.open_hooks_browser(hooks);
return Err(AppRunControl::Continue);
}
}
}
}
Ok(())
}
}
@@ -1209,9 +1209,6 @@ impl App {
Ok(config) => config,
Err(control) => return Ok(control),
};
if self.reject_remote_resume_permission_override(&resume_config) {
return Ok(AppRunControl::Continue);
}
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);
+3 -4
View File
@@ -7629,9 +7629,8 @@ async fn remote_resume_keeps_server_only_cwd_out_of_local_config() -> Result<()>
auth_token: None,
},
};
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config))
.await?
.with_remote_cwd_override(Some(remote_cwd.clone()));
app.harness_overrides.cwd = Some(remote_cwd.clone());
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
app_server
.resume_thread(
&crate::local_settings::LocalSettings::from(&app.config),
@@ -7666,7 +7665,7 @@ async fn remote_resume_keeps_server_only_cwd_out_of_local_config() -> Result<()>
.await?;
assert!(matches!(control, AppRunControl::Continue));
assert_eq!(app_server.remote_cwd_override(), Some(remote_cwd.as_path()));
assert_eq!(app.harness_overrides.cwd, Some(remote_cwd));
assert!(!crate::session_resume::cwds_differ(
app.config.cwd.as_path(),
&local_cwd,
@@ -11,15 +11,30 @@ use crate::chatwidget::UserMessage;
use crate::chatwidget::tests::helpers::render_bottom_popup;
use crate::model_catalog::ModelCatalog;
use crate::test_support::PathBufExt;
use crate::tui::test_support::make_test_tui;
use codex_app_server_protocol::UserInput;
use codex_protocol::openai_models::InputModality;
use codex_state::SqliteConfig;
use crossterm::event::KeyCode;
use pretty_assertions::assert_eq;
fn trust_launch_folder(app: &mut App) {
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"},
});
app.cli_kv_overrides.push((
"projects".into(),
TomlValue::try_from(projects).expect("trust fixture"),
));
app.config.active_project.trust_level = Some(codex_protocol::config_types::TrustLevel::Trusted);
}
#[tokio::test]
async fn background_task_sends_pasted_image_with_first_prompt() -> Result<()> {
let mut tui = make_test_tui()?;
let (mut app, mut events, _) = make_test_app_with_channels().await;
trust_launch_folder(&mut app);
let image_dir = tempdir()?;
let image_path = image_dir.path().join("pasted.png");
image::RgbImage::new(1, 1).save(&image_path)?;
@@ -58,7 +73,7 @@ async fn background_task_sends_pasted_image_with_first_prompt() -> Result<()> {
LoaderOverrides::default(),
)
.await?;
app.dispatch_agents_overview_task(&mut server, prompt.clone(), /*cwd*/ None)
app.dispatch_agents_overview_task(&mut tui, &mut server, prompt.clone(), /*cwd*/ None)
.await;
let turns = recorded_params(&requests, "turn/start");
assert_eq!(turns.len(), 1);
@@ -82,8 +97,13 @@ async fn background_task_sends_pasted_image_with_first_prompt() -> Result<()> {
);
app.cli_kv_overrides = vec![("model".into(), toml::Value::Integer(1))];
app.dispatch_agents_overview_task(&mut server, prompt.clone(), Some(app.config.cwd.clone()))
.await;
app.dispatch_agents_overview_task(
&mut tui,
&mut server,
prompt.clone(),
Some(app.config.cwd.clone()),
)
.await;
let restored = app
.agents_overview
.view_state
@@ -103,8 +123,13 @@ async fn background_task_sends_pasted_image_with_first_prompt() -> Result<()> {
);
view.handle_key_event(KeyCode::Enter.into());
assert!(view.handle_paste("A newer draft".into()));
app.dispatch_agents_overview_task(&mut server, prompt.clone(), Some(app.config.cwd.clone()))
.await;
app.dispatch_agents_overview_task(
&mut tui,
&mut server,
prompt.clone(),
Some(app.config.cwd.clone()),
)
.await;
let current = app
.agents_overview
.view_state
@@ -132,7 +157,9 @@ async fn background_task_sends_pasted_image_with_first_prompt() -> Result<()> {
#[tokio::test]
async fn remote_background_task_sends_clipboard_image_bytes() -> Result<()> {
let mut tui = make_test_tui()?;
let mut app = make_test_app_with_channels().await.0;
trust_launch_folder(&mut app);
let image_dir = tempdir()?;
let image_path = image_dir.path().join("pasted.png");
image::RgbImage::new(1, 1).save(&image_path)?;
@@ -150,7 +177,7 @@ async fn remote_background_task_sends_clipboard_image_bytes() -> Result<()> {
LoaderOverrides::default(),
)
.await?;
app.dispatch_agents_overview_task(&mut server, prompt, /*cwd*/ None)
app.dispatch_agents_overview_task(&mut tui, &mut server, prompt, /*cwd*/ None)
.await;
let turns = recorded_params(&requests, "turn/start");
assert_eq!(turns.len(), 1);
@@ -169,7 +196,9 @@ async fn remote_background_task_sends_clipboard_image_bytes() -> Result<()> {
#[tokio::test]
async fn background_task_rejects_images_for_text_only_model() -> Result<()> {
let mut tui = make_test_tui()?;
let (mut app, mut events, _) = make_test_app_with_channels().await;
trust_launch_folder(&mut app);
let mut preset = app.model_catalog.models[0].clone();
preset.model = "text-only-test-model".into();
preset.input_modalities = vec![InputModality::Text];
@@ -191,7 +220,7 @@ async fn background_task_rejects_images_for_text_only_model() -> Result<()> {
LoaderOverrides::default(),
)
.await?;
app.dispatch_agents_overview_task(&mut server, prompt.clone(), /*cwd*/ None)
app.dispatch_agents_overview_task(&mut tui, &mut server, prompt.clone(), /*cwd*/ None)
.await;
assert!(recorded_params(&requests, "thread/start").is_empty());
let draft = app
@@ -221,6 +250,7 @@ async fn background_task_rejects_images_for_text_only_model() -> Result<()> {
#[tokio::test]
async fn background_task_reads_server_defaults_for_actual_destination() -> Result<()> {
let mut tui = make_test_tui()?;
for (mode, explicit_cwd, launch_override, expected_cwd, expected_model) in [
("local", false, false, "launch", "server-model"),
("local", true, false, "destination", "destination-model"),
@@ -288,6 +318,7 @@ async fn background_task_reads_server_defaults_for_actual_destination() -> Resul
.map_err(|error| color_eyre::eyre::eyre!(error.to_string()))?;
}
let mut app = make_test_app_with_channels().await.0;
trust_launch_folder(&mut app);
if mode == "local" && explicit_cwd {
app.harness_overrides.model_provider = Some("openai".into());
}
@@ -315,6 +346,7 @@ async fn background_task_reads_server_defaults_for_actual_destination() -> Resul
ThreadId::new(),
launch.path().to_path_buf(),
));
trust_launch_folder(&mut app);
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());
@@ -354,6 +386,7 @@ async fn background_task_reads_server_defaults_for_actual_destination() -> Resul
expected_model.to_string()
};
app.dispatch_agents_overview_task(
&mut tui,
&mut server,
"background prompt".into(),
explicit_cwd.then(|| destination.path().to_path_buf().abs()),
@@ -412,6 +445,7 @@ async fn background_task_reads_server_defaults_for_actual_destination() -> Resul
#[tokio::test]
async fn background_task_preserves_explicit_choices_and_managed_defaults() -> Result<()> {
let mut tui = make_test_tui()?;
for (choice, expected_model, expected_effort) in [
("saved", "server-model", "high"),
("cli_effort", "server-model", "low"),
@@ -436,6 +470,7 @@ async fn background_task_preserves_explicit_choices_and_managed_defaults() -> Re
)?;
}
let mut app = make_test_app_with_channels().await.0;
trust_launch_folder(&mut app);
match choice {
"cli_effort" => app.cli_kv_overrides.push((
"model_reasoning_effort".into(),
@@ -463,6 +498,7 @@ async fn background_task_preserves_explicit_choices_and_managed_defaults() -> Re
.harness_overrides(app.harness_overrides.clone())
.build()
.await?;
trust_launch_folder(&mut app);
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());
@@ -481,6 +517,7 @@ async fn background_task_preserves_explicit_choices_and_managed_defaults() -> Re
.await?;
server.bootstrap(&app.config).await?;
app.dispatch_agents_overview_task(
&mut tui,
&mut server,
"background prompt".into(),
/*cwd*/ None,
@@ -512,12 +549,14 @@ async fn background_task_preserves_explicit_choices_and_managed_defaults() -> Re
#[tokio::test]
async fn background_task_read_failure_keeps_prompt_and_does_not_start() -> Result<()> {
let mut tui = make_test_tui()?;
for capability in [
HistoryCapabilities::ConfigReadFails,
HistoryCapabilities::ConfigReadUnsupported(-32600),
HistoryCapabilities::ConfigReadUnsupported(-32601),
] {
let (mut app, mut events, _) = make_test_app_with_channels().await;
trust_launch_folder(&mut app);
app.harness_overrides.model = Some("local-model".into());
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
app.chat_widget.show_bottom_pane_view(Box::new(view));
@@ -531,6 +570,7 @@ async fn background_task_read_failure_keeps_prompt_and_does_not_start() -> Resul
)
.await?;
app.dispatch_agents_overview_task(
&mut tui,
&mut server,
"retry background prompt".into(),
/*cwd*/ None,
@@ -275,6 +275,14 @@ stream_max_retries = 0
crate::app_server_session::ResumeModelSettings::PreserveExistingThread,
)
.await?;
for cwd in [&app.config.cwd, &other.session.cwd] {
crate::legacy_core::config::set_project_trust_level(
app.config.codex_home.as_path(),
cwd.as_path(),
codex_protocol::config_types::TrustLevel::Trusted,
)
.map_err(std::io::Error::other)?;
}
app.thread_event_channels.insert(
other_id,
ThreadEventChannel::new_with_session(
@@ -111,6 +111,12 @@ async fn same_thread_retry_keeps_subscription_and_restores_draft() -> Result<()>
app.ensure_thread_channel(thread_id).mark_external_writer();
app.chat_widget.insert_str("Retained draft");
app.chat_widget.show_external_writer_thread();
crate::legacy_core::config::set_project_trust_level(
app.config.codex_home.as_path(),
app.config.cwd.as_path(),
codex_protocol::config_types::TrustLevel::Trusted,
)
.map_err(std::io::Error::other)?;
app.harness_overrides.cwd = Some(app.config.cwd.to_path_buf());
requests.lock().expect("request recorder lock").clear();
let mut tui = crate::tui::test_support::make_test_tui()?;
@@ -4224,6 +4230,12 @@ fn session_lifecycle_avoids_redundant_subagent_metadata_reads() -> Result<()> {
.count();
assert!(loaded_threads.contains(&child_thread_id.to_string()));
take_backfill_counts(&requests);
crate::legacy_core::config::set_project_trust_level(
app.config.codex_home.as_path(),
app.config.cwd.as_path(),
codex_protocol::config_types::TrustLevel::Trusted,
)
.map_err(std::io::Error::other)?;
app.harness_overrides.cwd = Some(app.config.cwd.to_path_buf());
let control = app
+29 -11
View File
@@ -1035,7 +1035,7 @@ pub async fn run_main(
#[allow(clippy::too_many_arguments)]
async fn run_ratatui_app(
cli: Cli,
mut cli: Cli,
arg0_paths: Arg0DispatchPaths,
loader_overrides: LoaderOverrides,
strict_config: bool,
@@ -1328,7 +1328,7 @@ async fn run_ratatui_app(
};
let use_fork = cli.fork_picker || cli.fork_last || cli.fork_session_id.is_some();
let session_selection = if cli.agents_overview {
let mut session_selection = if cli.agents_overview {
resume_picker::SessionSelection::AgentsOverview
} else if use_fork {
if let Some(id_str) = cli.fork_session_id.as_deref() {
@@ -1753,15 +1753,33 @@ async fn run_ratatui_app(
trust_decision_was_made = true;
}
} else if consent.should_exit {
shutdown_startup_session(Some(app_server), &mut terminal_restore_guard).await;
return Ok(AppExitInfo {
token_usage: crate::token_usage::TokenUsage::default(),
thread_id: None,
resume_hint: None,
disconnect_info: None,
update_action: None,
exit_reason: ExitReason::UserRequested,
});
if matches!(app_server_target, AppServerTarget::Embedded) {
shutdown_startup_session(Some(app_server), &mut terminal_restore_guard).await;
return Ok(AppExitInfo {
token_usage: crate::token_usage::TokenUsage::default(),
thread_id: None,
resume_hint: None,
disconnect_info: None,
update_action: None,
exit_reason: ExitReason::UserRequested,
});
}
if !uses_remote_workspace {
config = load_config_or_exit_with_fallback_cwd(
cli_kv_overrides.clone(),
overrides.clone(),
loader_overrides.clone(),
cloud_config_bundle.clone(),
strict_config,
Some(current_cwd.to_path_buf()),
managed_worktree.as_ref(),
)
.await;
}
session_selection = resume_picker::SessionSelection::AgentsOverview;
cli.prompt = None;
cli.images.clear();
startup_draft.update_session_selection(&mut tui, &session_selection)?;
}
}
startup_draft.apply_config(&config);
@@ -12,6 +12,7 @@ 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;
use crate::onboarding::trust_directory::TrustDirectorySelection;
use crate::onboarding::trust_directory::TrustDirectoryWidget;
use crate::startup_draft::StartupDraftPump;
@@ -104,6 +105,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 {
TrustCancelAction::AgentsOverview
} else {
TrustCancelAction::Quit
},
cwd: project.cwd,
trust_target: project.trust_target,
show_windows_create_sandbox_hint: false,
@@ -186,6 +186,7 @@ impl OnboardingScreen {
steps.push(Step::TrustDirectory(TrustDirectoryWidget {
restricted: false,
existing_task: false,
cancel: super::trust_directory::TrustCancelAction::Quit,
cwd,
trust_target,
show_windows_create_sandbox_hint,
@@ -803,6 +804,7 @@ mod tests {
steps: vec![Step::TrustDirectory(TrustDirectoryWidget {
restricted: false,
existing_task: false,
cancel: super::super::trust_directory::TrustCancelAction::Quit,
cwd: PathBuf::from("/workspace/project"),
trust_target: PathBuf::from("/workspace/project"),
show_windows_create_sandbox_hint: false,
@@ -869,6 +871,7 @@ mod tests {
steps: vec![Step::TrustDirectory(TrustDirectoryWidget {
restricted: true,
existing_task: false,
cancel: super::super::trust_directory::TrustCancelAction::AgentsOverview,
cwd: PathBuf::from("/workspace/project"),
trust_target: PathBuf::from("/workspace/project"),
show_windows_create_sandbox_hint: false,
@@ -899,6 +902,7 @@ mod tests {
steps: vec![Step::TrustDirectory(TrustDirectoryWidget {
restricted: false,
existing_task: false,
cancel: super::super::trust_directory::TrustCancelAction::Quit,
cwd: PathBuf::from("/workspace/project"),
trust_target: PathBuf::from("/workspace/project"),
show_windows_create_sandbox_hint: false,
@@ -11,6 +11,6 @@ expression: terminal.backend()
will not change.
› 1. Open existing task
2. Quit
2. Back to Agent Command Center
Press enter to continue; esc to quit
Press enter to continue; esc to go back
@@ -11,6 +11,6 @@ expression: terminal.backend()
Opening will not change saved trust.
› 1. Open restricted
2. Quit
2. Back to Agent Command Center
Press enter to continue; esc to quit
Press enter to continue; esc to go back
@@ -14,6 +14,6 @@ expression: "terminal.backend().to_string().lines().map(str::trim_end).collect::
trust these files. Your trust decision will be saved.
› 1. Trust and continue
2. Quit
2. Back to Agent Command Center
Press enter to continue; esc to quit
Press enter to continue; esc to go back
+23 -2
View File
@@ -26,6 +26,7 @@ use super::onboarding_screen::StepState;
pub(crate) struct TrustDirectoryWidget {
pub restricted: bool,
pub existing_task: bool,
pub cancel: TrustCancelAction,
pub cwd: PathBuf,
pub trust_target: PathBuf,
pub show_windows_create_sandbox_hint: bool,
@@ -35,6 +36,12 @@ pub(crate) struct TrustDirectoryWidget {
pub error: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum TrustCancelAction {
Quit,
AgentsOverview,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TrustDirectorySelection {
Trust,
@@ -102,7 +109,13 @@ impl WidgetRef for &TrustDirectoryWidget {
},
TrustDirectorySelection::Trust,
),
("Quit", TrustDirectorySelection::Quit),
(
match self.cancel {
TrustCancelAction::Quit => "Quit",
TrustCancelAction::AgentsOverview => "Back to Agent Command Center",
},
TrustDirectorySelection::Quit,
),
];
for (idx, (text, selection)) in options.iter().enumerate() {
@@ -134,7 +147,11 @@ impl WidgetRef for &TrustDirectoryWidget {
if self.show_windows_create_sandbox_hint && !self.restricted {
" to continue and create a sandbox...".dim()
} else {
" to continue; esc to quit".dim()
match self.cancel {
TrustCancelAction::Quit => " to continue; esc to quit",
TrustCancelAction::AgentsOverview => " to continue; esc to go back",
}
.dim()
},
])
.inset(Insets::tlbr(
@@ -218,6 +235,7 @@ mod tests {
TrustDirectoryWidget {
restricted: false,
existing_task: false,
cancel: TrustCancelAction::Quit,
cwd: PathBuf::from("/workspace/project"),
trust_target: PathBuf::from("/workspace/project"),
show_windows_create_sandbox_hint: false,
@@ -233,6 +251,7 @@ mod tests {
let mut widget = TrustDirectoryWidget {
restricted: false,
existing_task: false,
cancel: TrustCancelAction::Quit,
cwd: PathBuf::from("."),
trust_target: PathBuf::from("."),
show_windows_create_sandbox_hint: false,
@@ -295,6 +314,7 @@ mod tests {
let widget = TrustDirectoryWidget {
restricted: false,
existing_task: false,
cancel: TrustCancelAction::AgentsOverview,
cwd: PathBuf::from("/srv/remote/project/nested"),
trust_target: PathBuf::from("/srv/remote/project"),
..widget(/*error*/ None)
@@ -323,6 +343,7 @@ mod tests {
let widget = TrustDirectoryWidget {
restricted: true,
existing_task,
cancel: TrustCancelAction::AgentsOverview,
..widget(/*error*/ None)
};
let mut terminal =
+236
View File
@@ -0,0 +1,236 @@
//! Exercises folder consent against a connected server through the real terminal event loop.
use super::focus_palette::PtyCodex;
use super::focus_palette::write_test_config;
use anyhow::Result;
use codex_app_server_protocol::JSONRPCMessage;
use futures::SinkExt;
use futures::StreamExt;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use std::time::Duration;
use tokio::net::UnixListener;
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 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)?;
let config_path = codex_home.path().join("config.toml");
let config = std::fs::read_to_string(&config_path)?;
std::fs::write(
config_path,
format!("{config}\n[tui]\nresume_cwd = \"current\"\n"),
)?;
let socket = codex_app_server_client::app_server_control_socket_path(codex_home.path())?;
std::fs::create_dir_all(socket.parent().unwrap())?;
let listener = UnixListener::bind(socket.as_path())?;
let cwd = repo_root.clone();
let methods = Arc::new(Mutex::new(Vec::new()));
let requests = Arc::clone(&methods);
let server = tokio::spawn(async move {
let mut clients = tokio::task::JoinSet::new();
let trust_reads = Arc::new(AtomicUsize::new(0));
loop {
let (stream, _) = listener
.accept()
.await
.expect("accept fake app-server client");
let cwd = cwd.clone();
let methods = Arc::clone(&requests);
let trust_reads = Arc::clone(&trust_reads);
clients.spawn(async move {
let Ok(mut socket) = tokio_tungstenite::accept_async(stream).await else {
return Ok::<_, anyhow::Error>(());
};
let other_cwd = cwd.join("other-folder");
let id = "00000000-0000-0000-0000-000000000001";
let thread = json!({
"id": id, "sessionId": id, "preview": "Untrusted saved task", "ephemeral": false,
"modelProvider": "openai", "createdAt": 1, "updatedAt": 2,
"status": if trust_level.is_some() { json!({"type": "active", "activeFlags": []}) } else { json!({"type": "notLoaded"}) }, "cwd": other_cwd,
"cliVersion": "0.0.0", "source": "cli", "turns": []
});
let mut launch_thread = thread.clone();
launch_thread["id"] = json!("00000000-0000-0000-0000-000000000002");
launch_thread["sessionId"] = launch_thread["id"].clone();
launch_thread["cwd"] = json!(cwd);
launch_thread["preview"] = json!("Launch-folder task");
launch_thread["updatedAt"] = json!(1);
while let Some(Ok(Message::Text(text))) = socket.next().await {
let JSONRPCMessage::Request(request) = serde_json::from_str(&text)? else {
continue;
};
methods.lock().unwrap().push(request.method.clone());
let result = match request.method.as_str() {
"initialize" => json!({"userAgent": "trust-pty"}),
"account/read" => {
json!({"account": {"type": "apiKey"}, "requiresOpenaiAuth": false})
}
"config/read" => {
if request
.params
.as_ref()
.is_some_and(|params| params["includeLayers"] == true)
{
trust_reads.fetch_add(1, Ordering::SeqCst);
}
json!({"config": {"model": "gpt-5.6-terra", "projects": {
cwd.to_string_lossy(): {"trust_level": if trust_reads.load(Ordering::SeqCst) == 4 { Some("trusted") } else { trust_level }},
other_cwd.to_string_lossy(): {"trust_level": "untrusted"},
cwd.join("moved-folder").to_string_lossy(): {"trust_level": "untrusted"}
}}, "origins": {}, "layers": []})
}
"config/batchWrite" => json!({"status": "ok", "version": "1", "filePath": cwd.join("config.toml"), "overriddenMetadata": null}),
"hooks/list" => json!({"data": if trust_level.is_none() && methods.lock().unwrap().iter().any(|method| method == "config/batchWrite") {
vec![json!({"cwd": request.params.as_ref().unwrap()["cwds"][0], "hooks": [{
"key": "project:test", "eventName": "sessionStart", "handlerType": "command",
"command": "echo hook", "async": false, "matcher": null, "timeoutSec": 30,
"statusMessage": null, "additionalContextLimit": null,
"sourcePath": cwd.join(".codex/hooks.json"), "source": "project", "pluginId": null,
"displayOrder": 0, "enabled": false, "isManaged": false,
"currentHash": "sha256:test", "trustStatus": "untrusted"
}], "warnings": [], "errors": []})]
} else { vec![] }}),
"configRequirements/read" => json!({"requirements": null}),
"model/list" => json!({"data": [], "nextCursor": null}),
"thread/list" => {
json!({"data": vec![thread.clone(), launch_thread.clone()], "nextCursor": null})
}
"thread/read" => {
let mut thread = if request.params.as_ref().unwrap()["threadId"] == launch_thread["id"] {
launch_thread.clone()
} else {
thread.clone()
};
if thread["id"] == id && trust_reads.load(Ordering::SeqCst) >= 5 {
thread["cwd"] = json!(cwd.join("moved-folder"));
}
json!({"thread": thread})
} ,
"skills/list" | "thread/loaded/list" => json!({"data": []}),
_ => {
socket
.send(Message::Text(
json!({"id": request.id, "error": {
"code": if request.method == "thread/start" { -32000 } else { -32601 }, "message": if request.method == "thread/start" { "Consent accepted" } else { "method not found" }
}})
.to_string()
.into(),
))
.await?;
continue;
}
};
socket
.send(Message::Text(
json!({"id": request.id, "result": result})
.to_string()
.into(),
))
.await?;
}
Ok(())
});
}
});
let mut terminal = PtyCodex::start(
&repo_root,
codex_home,
&["do not submit this launch prompt"],
)?;
let prompt = if trust_level.is_some() {
"Open restricted"
} else {
"Trust and continue"
};
for (expected, input) in [
(prompt, b"\x1b".as_slice()),
("create task", b"\x1b"),
("Launch-folder task", b"\x1b[B\x0e"),
("create task", b"new task"),
("› new task", b"\r"),
(prompt, b"\x1b"),
("› new task", b"\r"),
("You are in", b"\x1b"),
("› new task", b"\x1b"),
("ctrl+o resume", b"\x0f"),
("Resume a previous session", b"\x1b[C"),
("Untrusted saved task", b"\r"),
("Open existing task", b"\r"),
("moved-folder", b"\x1b"),
("ctrl+o resume", b"\x0e"),
("› new task", b"\r"),
(prompt, b"\r"),
] {
let is_consent = expected == prompt
|| matches!(
expected,
"Open existing task" | "moved-folder" | "You are in"
);
terminal.wait_for_screen(expected)?;
if expected == "You are in" {
assert_eq!(
terminal
.screen_contents()
.lines()
.find(|line| line.contains("You are in")),
Some(format!("> You are in {}", repo_root.display()).as_str())
);
}
if is_consent {
terminal.wait_for_screen("Back to Agent Command Center")?;
}
// Confirmations must be fresh input after the protected-screen transition.
tokio::time::sleep(Duration::from_millis(/*millis*/ 200)).await;
terminal.write_input(input)?;
terminal.read_output(Duration::from_millis(/*millis*/ 100))?;
}
if trust_level.is_none() {
terminal.wait_for_screen("Hooks need review")?;
assert!(
!methods
.lock()
.unwrap()
.iter()
.any(|method| method == "thread/start")
);
tokio::time::sleep(Duration::from_millis(/*millis*/ 200)).await;
terminal.write_input(b"3")?;
}
terminal.wait_for_screen("Consent accepted")?;
drop(terminal);
server.abort();
let methods = methods.lock().unwrap();
let mutations: Vec<_> = methods
.iter()
.map(String::as_str)
.filter(|method| {
matches!(
*method,
"thread/start"
| "thread/resume"
| "thread/fork"
| "turn/start"
| "config/batchWrite"
)
})
.collect();
assert_eq!(
mutations,
if trust_level.is_some() {
vec!["thread/start"]
} else {
vec!["config/batchWrite", "thread/start"]
}
);
}
Ok(())
}
@@ -349,6 +349,7 @@ impl PtyCodex {
let deadline = Instant::now() + STARTUP_TIMEOUT;
while Instant::now() < deadline {
self.read_output(Duration::from_millis(/*millis*/ 50))?;
self.answer_startup_queries()?;
if self.screen_contains(text) {
return Ok(());
}
+2
View File
@@ -1,5 +1,7 @@
// Aggregates all former standalone integration tests as modules.
#[cfg(unix)]
mod directory_trust;
#[cfg(unix)]
mod focus_palette;
#[cfg(unix)]
mod reconnect;