mirror of
https://github.com/openai/codex.git
synced 2026-11-02 19:43:24 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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 } => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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(());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user