From 534c70863c107e2bdedfa25b72bc1b035cb3208f Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 14 Oct 2026 17:21:59 +0000 Subject: [PATCH] Defer folder trust checks until starting a task from agents overview (#54048) ## Why The Agent Command Center does not open a workspace, so viewing it should not require consent for the launch directory. ## What changed Skip startup folder consent when opening the agents overview, while preserving initialization for the embedded fallback. Check folder trust before starting the first task, including through `/new` after dismissing the overview. On Windows, retain the sandbox setup prompt after folder trust is granted. ## Testing Add CLI tests for starting a task from an untrusted folder through the overview and through `/new` after dismissing it. A terminal snapshot verifies that the overview appears before folder consent. GitOrigin-RevId: 129be14a354b8abde2e8430c81599e2064ccac87 --- codex-rs/cli/tests/daemon_startup.rs | 59 ++++++++++++++++++- ...agents_overview_before_folder_consent.snap | 58 ++++++++++++++++++ codex-rs/tui/src/app/resume_config.rs | 4 ++ codex-rs/tui/src/app/session_lifecycle.rs | 22 +++++++ .../tui/src/app/tests/new_session_tests.rs | 5 ++ codex-rs/tui/src/lib.rs | 11 +++- 6 files changed, 154 insertions(+), 5 deletions(-) create mode 100644 codex-rs/cli/tests/snapshots/daemon_startup__agents_overview_before_folder_consent.snap diff --git a/codex-rs/cli/tests/daemon_startup.rs b/codex-rs/cli/tests/daemon_startup.rs index 8b7cea4cc8..a78093e73a 100644 --- a/codex-rs/cli/tests/daemon_startup.rs +++ b/codex-rs/cli/tests/daemon_startup.rs @@ -20,6 +20,18 @@ async fn agents_can_offer_restart_for_managed_daemon() -> Result<()> { daemon_startup("agents").await } +#[tokio::test] +#[cfg(unix)] +async fn agents_defers_launch_directory_trust_until_starting_a_task() -> Result<()> { + daemon_startup("agents-untrusted").await +} + +#[tokio::test] +#[cfg(unix)] +async fn agents_checks_trust_when_starting_after_dismissing_overview() -> Result<()> { + daemon_startup("agents-untrusted-new").await +} + #[tokio::test] #[cfg(unix)] async fn discovered_managed_daemon_can_restart_with_auto_start_disabled() -> Result<()> { @@ -173,13 +185,18 @@ async fn daemon_startup(command: &str) -> Result<()> { tui.disable_paste_burst = true\n\ {}\n\ notice.model_migrations.\"gpt-5.6-terra\" = \"gpt-6-sol\"\n\ - [projects.{}]\ntrust_level = \"trusted\"\n", + [projects.{}]\ntrust_level = \"{}\"\n", if command == "discovered" { "tui.status_line=[\"model\",\"thread-id\",\"thread-name\"]" } else { "" }, serde_json::to_string(&workspace_path)?, + if command.starts_with("agents-untrusted") { + "untrusted" + } else { + "trusted" + }, ), )?; let mismatch = command.starts_with("mismatch-"); @@ -216,7 +233,13 @@ async fn daemon_startup(command: &str) -> Result<()> { let mut steps: VecDeque<(&str, &[u8])> = VecDeque::new(); if matches!( command, - "start" | "agents" | "discovered" | "bedrock-running" | "restrictive-job" + "start" + | "agents" + | "agents-untrusted" + | "agents-untrusted-new" + | "discovered" + | "bedrock-running" + | "restrictive-job" ) || mismatch { // A selected package with a stopped daemon avoids installing a release. @@ -273,6 +296,15 @@ async fn daemon_startup(command: &str) -> Result<()> { steps.push_back(("GPT-5.6-Terra", b"/daemon\r")); steps.push_back(("Restartbackgroundserver", b"\x1b[F\r")); "Restartbackgroundserver?" + } else if command.starts_with("agents-untrusted") { + args.push("agents".into()); + if command == "agents-untrusted-new" { + steps.push_back(("Notasksyet", b"\x1b")); + steps.push_back(("GPT-5.6-Terra", b"/new\r")); + } else { + steps.push_back(("Notasksyet", b"n")); + } + "entercontinue·escback" } else if command == "discovered" { args.extend(["--disable".into(), "daemon_auto_start".into()]); // Empty drafts need not survive a daemon restart. Renaming materializes the @@ -382,6 +414,7 @@ async fn daemon_startup(command: &str) -> Result<()> { let writer = session.writer_sender(); let mut stdout = spawned.stdout_rx; let mut output = String::new(); + let mut initial_agents_screen = String::new(); let mut screen = vt100::Parser::new( /*rows*/ 40, /*cols*/ 120, /*scrollback_len*/ 0, ); @@ -422,6 +455,12 @@ async fn daemon_startup(command: &str) -> Result<()> { if let Some((ready, input)) = steps.front() && text.contains(ready) { + if command == "agents-untrusted" && *ready == "Notasksyet" { + if !text.contains("enteropennnew") { + continue; + } + initial_agents_screen = screen.screen().contents(); + } if command == "discovered" { if *ready == "Restartbackgroundserver?" { existing_daemon = Some(fs::read(&pid_file)?); @@ -456,6 +495,22 @@ async fn daemon_startup(command: &str) -> Result<()> { steps.pop_front(); output.clear(); } else if steps.is_empty() && text.contains(expected) { + if command == "agents-untrusted" { + let transition = format!( + "{initial_agents_screen}\n\n--- Start a task ---\n\n{}", + screen.screen().contents() + ) + .replace(&*workspace_path.to_string_lossy(), "[WORKSPACE]") + .replace(&*home.path().to_string_lossy(), "[CODEX_HOME]") + .replace(" tab/⇧tab filter", "tab/shift+tab filter"); + let transition = transition.lines().map(str::trim_end).collect::>().join("\n"); + let snapshot = codex_utils_cargo_bin::find_resource!( + "tests/snapshots/daemon_startup__agents_overview_before_folder_consent.snap" + )?; + insta::with_settings!({snapshot_path => snapshot.parent().context("snapshot has no parent")?}, { + insta::assert_snapshot!("agents_overview_before_folder_consent", transition); + }); + } if mismatch { let previous_pid = existing_daemon.as_ref().context("missing original daemon PID")?; ensure!((fs::read(&pid_file)? != *previous_pid) == restart); diff --git a/codex-rs/cli/tests/snapshots/daemon_startup__agents_overview_before_folder_consent.snap b/codex-rs/cli/tests/snapshots/daemon_startup__agents_overview_before_folder_consent.snap new file mode 100644 index 0000000000..5273276df5 --- /dev/null +++ b/codex-rs/cli/tests/snapshots/daemon_startup__agents_overview_before_folder_consent.snap @@ -0,0 +1,58 @@ +--- +source: cli/tests/daemon_startup.rs +expression: transition +--- + Agent command center Group: Project g + All 0 Needs you 0 Working 0 Ready 0 Inactive 0 tab/shift+tab filter + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + No tasks yet │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + ? help esc back ↑/↓ move enter open n new + +--- Start a task --- + + + Folder access + [WORKSPACE] + + Config, hooks, and exec policies from untrusted folders stay disabled. Trusted project folders can still contribute + settings. Skills still load, and tools follow your permission settings. Opening will not change saved trust. + +› 1. Open restricted + 2. Back to Agent Command Center + + enter continue · esc back diff --git a/codex-rs/tui/src/app/resume_config.rs b/codex-rs/tui/src/app/resume_config.rs index 1cfe074c07..80feda2d99 100644 --- a/codex-rs/tui/src/app/resume_config.rs +++ b/codex-rs/tui/src/app/resume_config.rs @@ -229,6 +229,10 @@ impl App { )); AppRunControl::Continue })?; + #[cfg(target_os = "windows")] + if result.directory_trust_persisted && self.current_displayed_thread_id().is_none() { + self.windows_sandbox.prompt_after_trust = true; + } // Directory changes review hooks once, after attaching the destination. if result.directory_trust_persisted && options.resumed_thread.is_none() diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index d6e030c9a8..27059f87ea 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -987,6 +987,28 @@ impl App { } return; } + // Dismissing the initial agents overview can expose /new before any folder consent. + // Resolve trust before loading defaults and applying explicit permission choices. + if self.current_displayed_thread_id().is_none() { + let mut config = self.chat_widget.config_ref().clone(); + let cwd = config.cwd.to_path_buf(); + if let Err(control) = self + .confirm_directory_trust( + tui, + app_server, + &mut config, + &cwd, + crate::onboarding::DirectoryTrustOptions::default(), + /*startup_draft*/ None, + ) + .await + { + if matches!(control, AppRunControl::Exit(_)) { + self.app_event_tx.send(AppEvent::Exit(ExitMode::Immediate)); + } + return; + } + } // Start a fresh in-memory session while preserving resumability via persisted rollout // history. If an initial message is provided, `enqueue_primary_thread_session` suppresses it // until the new session is configured and any replayed turns have been rendered. diff --git a/codex-rs/tui/src/app/tests/new_session_tests.rs b/codex-rs/tui/src/app/tests/new_session_tests.rs index 9f03f668a2..6e567ecdc5 100644 --- a/codex-rs/tui/src/app/tests/new_session_tests.rs +++ b/codex-rs/tui/src/app/tests/new_session_tests.rs @@ -409,6 +409,11 @@ async fn replacement_preserves_remote_launch_paths_and_older_servers() -> Result home.path().join("config.toml"), "model = \"configured-model\"\n", )?; + app.enqueue_primary_thread_session( + test_thread_session(ThreadId::new(), app.config.cwd.to_path_buf()), + Vec::new(), + ) + .await?; let (server, requests, proxy) = start_recording_app_server_with_history( &app.config, capabilities, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 7a12cc9767..7aa9aa80f0 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -1848,9 +1848,14 @@ async fn run_ratatui_app( } } - // Remote startup keeps its existing explicit --cd trust check. Resolving other - // remote folders requires authoritative project-root information from the server. - if !uses_remote_workspace || remote_cwd_override.is_some() { + // The shared overview does not open a workspace; session selection checks trust when needed. + // Embedded fallback cannot show the overview and still needs normal startup initialization. + if (!matches!( + session_selection, + resume_picker::SessionSelection::AgentsOverview + ) || matches!(app_server_target, AppServerTarget::Embedded)) + && (!uses_remote_workspace || remote_cwd_override.is_some()) + { let resumed_thread = if matches!(app_server_target, AppServerTarget::LocalDaemon { .. }) && let resume_picker::SessionSelection::Resume(target) = &session_selection {