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
This commit is contained in:
Eric Traut
2026-10-14 17:35:13 +00:00
committed by copyberry
parent 764a70fc11
commit 534c70863c
6 changed files with 154 additions and 5 deletions
+57 -2
View File
@@ -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::<Vec<_>>().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);
@@ -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
+4
View File
@@ -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()
+22
View File
@@ -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.
@@ -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,
+8 -3
View File
@@ -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
{