Prompt before trusting local project directories (#36960)

## Why

Trusting a directory enables project-local config, hooks, and exec policies, which can increase exposure to prompt injection. Require an explicit decision instead of automatically trusting projects whose trust level is unset.

## What changed

- Add a directory-trust step to TUI onboarding, with options to trust and continue or quit.
- Apply trust to the Git repository root when starting from a subdirectory, persist the decision, and reload config before continuing.
- Keep the prompt active and show the config error when trust cannot be persisted. Skip the prompt for remote workspaces and projects with an explicit trust level.

## Testing

- Cover trust-screen rendering, keyboard selection, persistence failures, and prompt visibility for explicit trust and Windows sandbox states.

GitOrigin-RevId: 850e6f54aec84d584cd161348ecdb1da89aaabd4
This commit is contained in:
Eric Traut
2026-08-04 19:48:21 +00:00
committed by copyberry
parent 92689b6b7b
commit 17801b4206
12 changed files with 690 additions and 388 deletions
-3
View File
@@ -293,7 +293,6 @@ pub async fn load_config_layers_state(
}
let mut startup_warnings = None;
let mut project_root = None;
if let Some(cwd) = cwd {
let mut merged_so_far = TomlValue::Table(toml::map::Map::new());
for layer in &layers {
@@ -351,7 +350,6 @@ pub async fn load_config_layers_state(
strict_config,
)
.await?;
project_root = Some(project_trust_context.project_root.clone());
layers.extend(project_layers.layers);
startup_warnings = Some(project_layers.startup_warnings);
}
@@ -434,7 +432,6 @@ pub async fn load_config_layers_state(
config_requirements_toml.clone().try_into()?,
config_requirements_toml.into_toml(),
)?
.with_project_root(project_root)
.with_user_and_project_exec_policy_rules_ignored(ignore_user_and_project_exec_policy_rules);
Ok(match startup_warnings {
Some(startup_warnings) => config_layer_stack.with_startup_warnings(startup_warnings),
-17
View File
@@ -256,9 +256,6 @@ pub struct ConfigLayerStack {
/// `None` means the loader did not check for stack-level warnings, while
/// `Some(vec![])` means it checked and found nothing to report.
startup_warnings: Option<Vec<String>>,
/// Project root resolved from the non-project config layers.
project_root: Option<AbsolutePathBuf>,
}
impl ConfigLayerStack {
@@ -275,21 +272,9 @@ impl ConfigLayerStack {
requirements_toml,
ignore_user_and_project_exec_policy_rules: false,
startup_warnings: None,
project_root: None,
})
}
/// Records the project root resolved while loading this stack.
pub fn with_project_root(mut self, project_root: Option<AbsolutePathBuf>) -> Self {
self.project_root = project_root;
self
}
/// Returns the project root resolved from the non-project layers.
pub fn project_root(&self) -> Option<&AbsolutePathBuf> {
self.project_root.as_ref()
}
pub fn with_user_and_project_exec_policy_rules_ignored(
mut self,
ignore_user_and_project_exec_policy_rules: bool,
@@ -416,7 +401,6 @@ impl ConfigLayerStack {
ignore_user_and_project_exec_policy_rules: self
.ignore_user_and_project_exec_policy_rules,
startup_warnings: self.startup_warnings.clone(),
project_root: self.project_root.clone(),
})
}
@@ -451,7 +435,6 @@ impl ConfigLayerStack {
ignore_user_and_project_exec_policy_rules: self
.ignore_user_and_project_exec_policy_rules,
startup_warnings: self.startup_warnings.clone(),
project_root: self.project_root.clone(),
}
}
+1 -2
View File
@@ -219,8 +219,7 @@ mod reload {
layers,
config.config_layer_stack.requirements().clone(),
config.config_layer_stack.requirements_toml().clone(),
)?
.with_project_root(config.config_layer_stack.project_root().cloned()))
)?)
}
fn deserialize_effective_config(
-5
View File
@@ -159,7 +159,6 @@ async fn apply_role_preserves_unspecified_keys() {
TomlValue::String("base-model".to_string()),
)])
.await;
let project_root = config.config_layer_stack.project_root().cloned();
config.codex_linux_sandbox_exe = Some(PathBuf::from("/tmp/codex-linux-sandbox"));
config.main_execve_wrapper_exe = Some(PathBuf::from("/tmp/codex-execve-wrapper"));
let role_path = write_role_config(
@@ -188,10 +187,6 @@ async fn apply_role_preserves_unspecified_keys() {
(config.model.as_deref(), config.model_reasoning_effort),
(Some("spawn-model"), Some(ReasoningEffort::Low)),
);
assert_eq!(
config.config_layer_stack.project_root(),
project_root.as_ref()
);
assert_eq!(
config.codex_linux_sandbox_exe,
Some(PathBuf::from("/tmp/codex-linux-sandbox"))
+2 -47
View File
@@ -9642,41 +9642,6 @@ async fn active_project_does_not_match_configured_alias_for_canonical_cwd() -> a
Ok(())
}
#[tokio::test]
async fn active_project_honors_custom_project_root_markers() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let project_root = codex_home.path().join("project");
let nested = project_root.join("nested");
std::fs::create_dir_all(&nested)?;
std::fs::write(project_root.join(".hg"), "")?;
let project_key = project_root.display().to_string().replace('\\', "\\\\");
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
format!(
r#"project_root_markers = [".hg"]
[projects."{project_key}"]
trust_level = "untrusted"
"#,
),
)?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
cwd: Some(nested),
..Default::default()
})
.build()
.await?;
assert_eq!(
config.active_project.trust_level,
Some(TrustLevel::Untrusted)
);
Ok(())
}
#[test]
fn test_set_default_oss_provider() -> std::io::Result<()> {
let temp_dir = TempDir::new()?;
@@ -10621,10 +10586,6 @@ save_fields_resolved_from_model_catalog = false
#[tokio::test]
async fn debug_config_lockfile_load_path_loads_lock_from_nested_table() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let project_root = codex_home.path().join("project");
let nested = project_root.join("nested");
std::fs::create_dir_all(&nested)?;
std::fs::write(project_root.join(".hg"), "")?;
let lock_path = codex_home.path().join("session.config.lock.toml");
std::fs::write(
&lock_path,
@@ -10640,9 +10601,7 @@ codex_version = "older-version"
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
format!(
r#"project_root_markers = [".hg"]
[debug.config_lockfile]
r#"[debug.config_lockfile]
load_path = '{}'
allow_codex_version_mismatch = true
save_fields_resolved_from_model_catalog = false
@@ -10653,14 +10612,10 @@ save_fields_resolved_from_model_catalog = false
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(nested))
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await?;
assert_eq!(
config.config_layer_stack.project_root(),
Some(&project_root.abs())
);
assert!(config.config_lock_toml.is_some());
assert!(config.config_lock_allow_codex_version_mismatch);
assert!(!config.config_lock_save_fields_resolved_from_model_catalog);
+2 -13
View File
@@ -1488,8 +1488,7 @@ impl ConfigBuilder {
vec![lock_layer],
config_layer_stack.requirements().clone(),
config_layer_stack.requirements_toml().clone(),
)?
.with_project_root(config_layer_stack.project_root().cloned());
)?;
let mut config = Config::load_config_with_layer_stack(
LOCAL_FS.as_ref(),
lock_config_toml,
@@ -1799,7 +1798,6 @@ impl Config {
.requirements_toml()
.clone(),
)?
.with_project_root(refreshed_config.config_layer_stack.project_root().cloned())
.with_user_and_project_exec_policy_rules_ignored(
refreshed_config
.config_layer_stack
@@ -3348,21 +3346,12 @@ impl Config {
.into_iter()
.map(|path| AbsolutePathBuf::resolve_path_against_base(path, resolved_cwd.as_path()))
.collect();
let project_root = config_layer_stack
.project_root()
.cloned()
.unwrap_or_else(|| resolved_cwd.clone());
let repo_root = resolve_root_git_project_for_trust(fs, &resolved_cwd).await;
let active_project = cfg
.get_active_project(
resolved_cwd.as_path(),
Some(project_root.as_path()),
repo_root.as_ref().map(AbsolutePathBuf::as_path),
)
.or_else(|| {
repo_root.as_ref().and_then(|repo_root| {
cfg.get_active_project(repo_root.as_path(), /*repo_root*/ None)
})
})
.unwrap_or(ProjectConfig { trust_level: None });
let permission_config_syntax = resolve_permission_config_syntax(
&config_layer_stack,
+184 -270
View File
@@ -24,7 +24,6 @@ pub use app::ExitReason;
use app_server_session::AppServerSession;
use app_server_session::ThreadParamsMode;
use codex_app_server_client::AppServerClient;
use codex_app_server_client::AppServerRequestHandle;
use codex_app_server_client::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY;
use codex_app_server_client::InProcessAppServerClient;
use codex_app_server_client::InProcessClientStartArgs;
@@ -43,15 +42,10 @@ use codex_cloud_config::cloud_config_bundle_loader_for_storage;
use codex_config::CloudConfigBundleLoader;
use codex_config::ConfigLoadError;
use codex_config::LoaderOverrides;
use codex_config::build_cli_overrides_layer;
use codex_config::format_config_error_with_source;
use codex_config::loader::project_trust_key;
use codex_config::merge_toml_values;
use codex_config::types::ResumeCwdMode;
use codex_exec_server::EnvironmentManager;
use codex_exec_server::ExecServerRuntimePaths;
use codex_exec_server::LOCAL_FS;
use codex_git_utils::resolve_root_git_project_for_trust;
use codex_login::AuthConfig;
use codex_login::AuthRouteConfig;
use codex_login::default_client::originator;
@@ -218,7 +212,8 @@ pub(crate) mod test_backend;
#[cfg(test)]
pub(crate) mod test_support;
use crate::onboarding::onboarding_screen::run_login_onboarding;
use crate::onboarding::onboarding_screen::OnboardingScreenArgs;
use crate::onboarding::onboarding_screen::run_onboarding_app;
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;
@@ -1327,12 +1322,12 @@ async fn run_ratatui_app(
arg0_paths: Arg0DispatchPaths,
loader_overrides: LoaderOverrides,
strict_config: bool,
mut app_server_target: AppServerTarget,
app_server_target: AppServerTarget,
remote_cwd_override: Option<PathBuf>,
initial_config: Config,
manually_selected_oss_provider: Option<String>,
overrides: ConfigOverrides,
mut cli_kv_overrides: Vec<(String, toml::Value)>,
cli_kv_overrides: Vec<(String, toml::Value)>,
mut cloud_config_bundle: CloudConfigBundleLoader,
feedback: codex_feedback::CodexFeedback,
log_db: Option<log_db::LogDbLayer>,
@@ -1388,7 +1383,7 @@ async fn run_ratatui_app(
// Initialize high-fidelity session event logging if enabled.
session_log::maybe_init(&initial_config);
let mut app_server_session = match start_app_server(
let app_server_session = match start_app_server(
&app_server_target,
arg0_paths.clone(),
initial_config.clone(),
@@ -1424,20 +1419,45 @@ async fn run_ratatui_app(
"Failed to persist selected OSS provider preference"
);
}
let mut app_server = Some(app_server_session);
let should_show_trust_screen_flag =
!uses_remote_workspace && should_show_trust_screen(&initial_config);
#[cfg(target_os = "windows")]
let mut trust_decision_was_made = false;
let login_status = if initial_config.model_provider.requires_openai_auth {
get_login_status(&mut app_server_session, &initial_config).await?
let Some(app_server) = app_server.as_mut() else {
unreachable!("app server should exist when auth is required");
};
get_login_status(app_server, &initial_config).await?
} else {
LoginStatus::NotAuthenticated
};
let show_login_screen = initial_config.model_provider.requires_openai_auth
&& login_status == LoginStatus::NotAuthenticated;
let should_show_onboarding =
should_show_onboarding(login_status, &initial_config, should_show_trust_screen_flag);
let mut reload_after_login = false;
if show_login_screen {
let onboarding_should_exit =
run_login_onboarding(initial_config.clone(), &mut app_server_session, &mut tui).await?;
if onboarding_should_exit {
shutdown_app_server_if_present(Some(app_server_session)).await;
let config = if should_show_onboarding {
let show_login_screen = should_show_login_screen(login_status, &initial_config);
let onboarding_result = run_onboarding_app(
OnboardingScreenArgs {
show_login_screen,
show_trust_screen: should_show_trust_screen_flag,
login_status,
app_server_request_handle: app_server
.as_ref()
.map(AppServerSession::request_handle),
config: initial_config.clone(),
},
if show_login_screen {
app_server.as_mut()
} else {
None
},
&mut tui,
)
.await?;
if onboarding_result.should_exit {
shutdown_app_server_if_present(app_server.take()).await;
terminal_restore_guard.restore_silently();
session_log::log_session_end();
let _ = tui.terminal.clear();
@@ -1449,9 +1469,14 @@ async fn run_ratatui_app(
exit_reason: ExitReason::UserRequested,
});
}
if !uses_remote_workspace {
// Always refresh the cloud config bundle and rebuild config after login. This avoids
// missing newly available cloud-managed policy due to login status detection edge cases.
#[cfg(target_os = "windows")]
{
trust_decision_was_made = onboarding_result.directory_trust_persisted;
}
// If this onboarding run included the login step, always refresh the cloud config bundle
// and rebuild config. This avoids missing newly available cloud-managed policy due to login
// status detection edge cases.
if show_login_screen && !uses_remote_workspace {
cloud_config_bundle = cloud_config_bundle_loader_for_storage(
initial_config.codex_home.to_path_buf(),
/*enable_codex_api_key_env*/ false,
@@ -1461,25 +1486,27 @@ async fn run_ratatui_app(
initial_config.auth_route_config(),
)
.await;
reload_after_login = true;
}
}
let config = if reload_after_login {
load_config_or_exit(
cli_kv_overrides.clone(),
overrides.clone(),
loader_overrides.clone(),
cloud_config_bundle.clone(),
strict_config,
)
.await
// If the user made an explicit trust decision, or we showed the login flow, reload config
// so current process state reflects persisted trust/auth changes.
if onboarding_result.directory_trust_persisted
|| (show_login_screen && !uses_remote_workspace)
{
load_config_or_exit(
cli_kv_overrides.clone(),
overrides.clone(),
loader_overrides.clone(),
cloud_config_bundle.clone(),
strict_config,
)
.await
} else {
initial_config
}
} else {
initial_config
};
let uses_remote_workspace_or_environment =
uses_remote_workspace_or_environment(&app_server_target, &environment_manager);
let mut app_server = Some(app_server_session);
let mut missing_session_exit = |id_str: &str, action: &str| {
error!("Error finding conversation path: {id_str}");
@@ -1626,7 +1653,7 @@ async fn run_ratatui_app(
&session_selection,
cli.cwd.as_deref(),
uses_remote_workspace,
uses_remote_workspace_or_environment,
uses_remote_workspace_or_environment(&app_server_target, &environment_manager),
)
.await
{
@@ -1679,11 +1706,52 @@ async fn run_ratatui_app(
_ => config,
};
// Configure syntax highlighting theme from the final config — onboarding
// and resume/fork can both reload config with a different tui_theme, so
// this must happen after the last possible reload.
if let Some(w) = crate::render::highlight::set_theme_override(
config.tui_theme.clone(),
find_codex_home().ok().map(AbsolutePathBuf::into_path_buf),
) {
config.startup_warnings.push(w);
}
set_default_client_residency_requirement(config.enforce_residency.value());
let should_show_trust_screen = should_show_trust_screen(&config);
#[cfg(target_os = "windows")]
let windows_sandbox_level = crate::windows_sandbox::level_from_config(&config);
#[cfg(target_os = "windows")]
let required_elevated_sandbox_needs_setup = windows_sandbox_level
== WindowsSandboxLevel::Elevated
&& config
.config_layer_stack
.requirements()
.windows_sandbox_mode
.source
.is_some()
&& !crate::windows_sandbox::sandbox_setup_is_complete(config.codex_home.as_path());
#[cfg(target_os = "windows")]
let should_prompt_windows_sandbox_nux_at_startup = (trust_decision_was_made
&& windows_sandbox_level == WindowsSandboxLevel::Disabled)
|| required_elevated_sandbox_needs_setup;
#[cfg(not(target_os = "windows"))]
let should_prompt_windows_sandbox_nux_at_startup = false;
let Cli {
prompt,
shared,
no_alt_screen,
..
} = cli;
let images = shared.into_inner().images;
let use_alt_screen = determine_alt_screen_mode(no_alt_screen, config.tui_alternate_screen);
tui.set_alt_screen_enabled(use_alt_screen);
let mut app_server = match app_server {
Some(app_server) => app_server,
None => match start_app_server(
&app_server_target,
arg0_paths.clone(),
arg0_paths,
config.clone(),
cli_kv_overrides.clone(),
loader_overrides.clone(),
@@ -1708,102 +1776,6 @@ async fn run_ratatui_app(
},
};
let project_trust_was_undecided =
!uses_remote_workspace_or_environment && config.active_project.trust_level.is_none();
let project_trust_outcome = persist_trust_for_undecided_project(
uses_remote_workspace_or_environment,
&config,
app_server.request_handle(),
&mut cli_kv_overrides,
)
.await;
let project_trust_was_added = project_trust_outcome != ProjectTrustOutcome::Unchanged;
let project_trust_is_in_memory = project_trust_outcome == ProjectTrustOutcome::InMemory;
config = if project_trust_was_added {
load_config_or_exit_with_fallback_cwd(
cli_kv_overrides.clone(),
overrides.clone(),
loader_overrides.clone(),
cloud_config_bundle.clone(),
strict_config,
Some(config.cwd.to_path_buf()),
)
.await
} else {
config
};
if project_trust_is_in_memory {
shutdown_app_server_if_present(Some(app_server)).await;
app_server_target = AppServerTarget::Embedded;
let fallback_app_server = start_app_server(
&app_server_target,
arg0_paths,
config.clone(),
cli_kv_overrides.clone(),
loader_overrides.clone(),
strict_config,
cloud_config_bundle.clone(),
feedback.clone(),
log_db.clone(),
state_db.clone(),
environment_manager.clone(),
)
.await
.inspect_err(|_err| {
terminal_restore_guard.restore_silently();
session_log::log_session_end();
})?;
app_server =
AppServerSession::new(fallback_app_server, app_server_target.thread_params_mode())
.with_remote_cwd_override(remote_cwd_override.clone());
}
// Configure syntax highlighting theme from the final config — onboarding
// and resume/fork can both reload config with a different tui_theme, so
// this must happen after the last possible reload.
if let Some(w) = crate::render::highlight::set_theme_override(
config.tui_theme.clone(),
find_codex_home().ok().map(AbsolutePathBuf::into_path_buf),
) {
config.startup_warnings.push(w);
}
set_default_client_residency_requirement(config.enforce_residency.value());
let is_first_run = project_trust_was_undecided
&& matches!(
session_selection,
resume_picker::SessionSelection::StartFresh
);
#[cfg(target_os = "windows")]
let windows_sandbox_level = crate::windows_sandbox::level_from_config(&config);
#[cfg(target_os = "windows")]
let required_elevated_sandbox_needs_setup = windows_sandbox_level
== WindowsSandboxLevel::Elevated
&& config
.config_layer_stack
.requirements()
.windows_sandbox_mode
.source
.is_some()
&& !crate::windows_sandbox::sandbox_setup_is_complete(config.codex_home.as_path());
#[cfg(target_os = "windows")]
let should_prompt_windows_sandbox_nux_at_startup = (project_trust_was_added
&& windows_sandbox_level == WindowsSandboxLevel::Disabled)
|| required_elevated_sandbox_needs_setup;
#[cfg(not(target_os = "windows"))]
let should_prompt_windows_sandbox_nux_at_startup = false;
let Cli {
prompt,
shared,
no_alt_screen,
..
} = cli;
let images = shared.into_inner().images;
let use_alt_screen = determine_alt_screen_mode(no_alt_screen, config.tui_alternate_screen);
tui.set_alt_screen_enabled(use_alt_screen);
// Persistent app-server resumes may attach to an already-running thread,
// where resume config overrides are ignored.
let is_persistent_resume = !matches!(&app_server_target, AppServerTarget::Embedded)
@@ -1847,7 +1819,7 @@ async fn run_ratatui_app(
images,
session_selection,
feedback,
is_first_run,
should_show_trust_screen, // Proxy to: is it a first run in this directory?
should_prompt_windows_sandbox_nux_at_startup,
app_server_target,
state_db,
@@ -2035,53 +2007,31 @@ async fn load_bootstrap_config_or_exit(
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ProjectTrustOutcome {
Unchanged,
Persisted,
InMemory,
/// Determine if the user has decided whether to trust the current directory.
fn should_show_trust_screen(config: &Config) -> bool {
config.active_project.trust_level.is_none()
}
async fn persist_trust_for_undecided_project(
uses_remote_workspace_or_environment: bool,
fn should_show_onboarding(
login_status: LoginStatus,
config: &Config,
request_handle: AppServerRequestHandle,
cli_overrides: &mut Vec<(String, toml::Value)>,
) -> ProjectTrustOutcome {
if uses_remote_workspace_or_environment || config.active_project.trust_level.is_some() {
return ProjectTrustOutcome::Unchanged;
show_trust_screen: bool,
) -> bool {
if show_trust_screen {
return true;
}
let trust_target = resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &config.cwd)
.await
.or_else(|| config.config_layer_stack.project_root().cloned())
.unwrap_or_else(|| config.cwd.clone());
if let Err(err) = config_update::write_trusted_project(request_handle, &trust_target).await {
let error = config_update::format_config_error(&err);
warn!(
project = %trust_target.display(),
"failed to persist trusted project state; continuing with in-memory trust: {error}"
);
let project = toml::Value::Table(toml::map::Map::from_iter([(
"trust_level".to_string(),
toml::Value::String("trusted".to_string()),
)]));
let trust_override = toml::Value::Table(toml::map::Map::from_iter([(
"projects".to_string(),
toml::Value::Table(toml::map::Map::from_iter([(
project_trust_key(trust_target.as_path()),
project,
)])),
)]));
let mut merged_overrides = build_cli_overrides_layer(cli_overrides);
merge_toml_values(&mut merged_overrides, &trust_override);
let toml::Value::Table(merged_overrides) = merged_overrides else {
unreachable!("CLI overrides always build a table");
};
*cli_overrides = merged_overrides.into_iter().collect();
return ProjectTrustOutcome::InMemory;
should_show_login_screen(login_status, config)
}
fn should_show_login_screen(login_status: LoginStatus, config: &Config) -> bool {
// Only show the login screen for providers that actually require OpenAI auth
// (OpenAI or equivalents). For OSS/other providers, skip login entirely.
if !config.model_provider.requires_openai_auth {
return false;
}
ProjectTrustOutcome::Persisted
login_status == LoginStatus::NotAuthenticated
}
#[cfg(test)]
@@ -2094,8 +2044,10 @@ mod tests {
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_config::config_toml::ProjectConfig;
use codex_utils_absolute_path::test_support::PathExt;
use pretty_assertions::assert_eq;
use serial_test::serial;
use tempfile::TempDir;
async fn build_config(temp_dir: &TempDir) -> std::io::Result<Config> {
@@ -2290,83 +2242,6 @@ mod tests {
.await
}
#[tokio::test]
async fn automatic_project_trust_persists_or_falls_back_in_memory() -> color_eyre::Result<()> {
use codex_protocol::config_types::TrustLevel;
let temp_dir = TempDir::new()?;
let codex_home = temp_dir.path().join("codex-home");
let repo = temp_dir.path().join("repo");
let nested = repo.join("nested");
let sibling = repo.join("sibling");
std::fs::create_dir_all(&codex_home)?;
std::fs::create_dir_all(repo.join(".git"))?;
std::fs::create_dir_all(&nested)?;
std::fs::create_dir_all(&sibling)?;
let loader_overrides = LoaderOverrides::without_managed_config_for_tests();
let config = ConfigBuilder::default()
.codex_home(codex_home.clone())
.loader_overrides(loader_overrides.clone())
.harness_overrides(ConfigOverrides {
cwd: Some(nested),
..Default::default()
})
.build()
.await?;
assert_eq!(config.active_project.trust_level, None);
let app_server = start_test_embedded_app_server(config.clone()).await?;
let mut cli_overrides = Vec::new();
let outcome = persist_trust_for_undecided_project(
/*uses_remote_workspace_or_environment*/ false,
&config,
AppServerRequestHandle::InProcess(app_server.request_handle()),
&mut cli_overrides,
)
.await;
assert_eq!(outcome, ProjectTrustOutcome::Persisted);
let reloaded = ConfigBuilder::default()
.codex_home(codex_home)
.loader_overrides(loader_overrides)
.harness_overrides(ConfigOverrides {
cwd: Some(sibling),
..Default::default()
})
.build()
.await?;
assert_eq!(
reloaded.active_project.trust_level,
Some(TrustLevel::Trusted)
);
let request_handle = AppServerRequestHandle::InProcess(app_server.request_handle());
app_server.shutdown().await?;
let mut fallback_overrides = vec![(
"projects.existing.trust_level".to_string(),
toml::Value::String("untrusted".to_string()),
)];
let outcome = persist_trust_for_undecided_project(
/*uses_remote_workspace_or_environment*/ false,
&config,
request_handle,
&mut fallback_overrides,
)
.await;
assert_eq!(outcome, ProjectTrustOutcome::InMemory);
let fallback_overrides = codex_config::build_cli_overrides_layer(&fallback_overrides);
assert_eq!(
fallback_overrides["projects"][project_trust_key(&repo)]["trust_level"].as_str(),
Some("trusted")
);
assert_eq!(
fallback_overrides["projects"]["existing"]["trust_level"].as_str(),
Some("untrusted")
);
Ok(())
}
#[tokio::test]
async fn startup_resume_and_fork_use_configured_or_explicit_cwd() -> color_eyre::Result<()> {
for (action, configured_mode, has_explicit_cwd, expected_directory) in [
@@ -2439,7 +2314,7 @@ mod tests {
ResolveCwdOutcome::Exit => panic!("configured cwd should not exit startup"),
};
let final_config = ConfigBuilder::default()
.codex_home(codex_home.clone())
.codex_home(codex_home)
.loader_overrides(LoaderOverrides::without_managed_config_for_tests())
.harness_overrides(ConfigOverrides {
cwd: cwd_override.map(Path::to_path_buf),
@@ -2460,22 +2335,6 @@ mod tests {
Arc::new(EnvironmentManager::default_for_tests()),
)
.await?;
let mut cli_overrides = Vec::new();
let outcome = persist_trust_for_undecided_project(
/*uses_remote_workspace_or_environment*/ false,
&final_config,
app_server.request_handle(),
&mut cli_overrides,
)
.await;
assert_eq!(outcome, ProjectTrustOutcome::Persisted);
let config_toml: toml::Value =
toml::from_str(&std::fs::read_to_string(codex_home.join("config.toml"))?)?;
assert!(
config_toml["projects"].as_table().is_some_and(
|projects| projects.contains_key(&project_trust_key(&expected_cwd))
)
);
let started = match action {
CwdPromptAction::Resume => {
app_server
@@ -3218,6 +3077,22 @@ mod tests {
Ok(())
}
#[tokio::test]
#[serial]
async fn windows_shows_trust_prompt_without_sandbox() -> std::io::Result<()> {
let temp_dir = TempDir::new()?;
let mut config = build_config(&temp_dir).await?;
config.active_project = ProjectConfig { trust_level: None };
config.set_windows_sandbox_enabled(/*value*/ false);
let should_show = should_show_trust_screen(&config);
assert!(
should_show,
"Trust prompt should be shown when project trust is undecided"
);
Ok(())
}
#[tokio::test]
async fn embedded_app_server_supports_thread_start_rpc() -> color_eyre::Result<()> {
let temp_dir = TempDir::new()?;
@@ -3516,6 +3391,45 @@ mod tests {
Ok(())
}
#[tokio::test]
#[serial]
async fn windows_shows_trust_prompt_with_sandbox() -> std::io::Result<()> {
let temp_dir = TempDir::new()?;
let mut config = build_config(&temp_dir).await?;
config.active_project = ProjectConfig { trust_level: None };
config.set_windows_sandbox_enabled(/*value*/ true);
let should_show = should_show_trust_screen(&config);
if cfg!(target_os = "windows") {
assert!(
should_show,
"Windows trust prompt should be shown on native Windows with sandbox enabled"
);
} else {
assert!(
should_show,
"Non-Windows should still show trust prompt when project is untrusted"
);
}
Ok(())
}
#[tokio::test]
async fn untrusted_project_skips_trust_prompt() -> std::io::Result<()> {
use codex_protocol::config_types::TrustLevel;
let temp_dir = TempDir::new()?;
let mut config = build_config(&temp_dir).await?;
config.active_project = ProjectConfig {
trust_level: Some(TrustLevel::Untrusted),
};
let should_show = should_show_trust_screen(&config);
assert!(
!should_show,
"Trust prompt should not be shown for projects explicitly marked as untrusted"
);
Ok(())
}
#[tokio::test]
async fn config_rebuild_changes_trust_defaults_with_cwd() -> std::io::Result<()> {
let temp_dir = TempDir::new()?;
+1
View File
@@ -1,6 +1,7 @@
mod auth;
mod keys;
pub(crate) mod onboarding_screen;
mod trust_directory;
pub(crate) use auth::mark_underlined_hyperlink;
pub(crate) use auth::mark_url_hyperlink;
mod welcome;
+208 -31
View File
@@ -1,7 +1,7 @@
//! Onboarding screen orchestration and top-level keyboard routing.
//!
//! The onboarding flow is a small state machine over visible steps
//! (welcome/auth). This module decides which step receives key/paste
//! (welcome/auth/trust). This module decides which step receives key/paste
//! events and enforces flow-level safety rules that cut across individual step
//! widgets.
//!
@@ -13,6 +13,10 @@
use codex_app_server_client::AppServerEvent;
use codex_app_server_client::AppServerRequestHandle;
use codex_app_server_protocol::ServerNotification;
use codex_exec_server::LOCAL_FS;
use codex_git_utils::resolve_root_git_project_for_trust;
#[cfg(target_os = "windows")]
use codex_protocol::config_types::WindowsSandboxLevel;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
@@ -28,12 +32,16 @@ use codex_protocol::config_types::ForcedLoginMethod;
use crate::LoginStatus;
use crate::app_server_session::AppServerSession;
use crate::config_update::format_config_error;
use crate::config_update::write_trusted_project;
use crate::key_hint::KeyBindingListExt;
use crate::legacy_core::config::Config;
use crate::onboarding::auth::AuthModeWidget;
use crate::onboarding::auth::SignInOption;
use crate::onboarding::auth::SignInState;
use crate::onboarding::keys;
use crate::onboarding::trust_directory::TrustDirectorySelection;
use crate::onboarding::trust_directory::TrustDirectoryWidget;
use crate::onboarding::welcome::WelcomeWidget;
use crate::tui::FrameRequester;
use crate::tui::Tui;
@@ -46,6 +54,7 @@ use std::sync::RwLock;
enum Step {
Welcome(WelcomeWidget),
Auth(AuthModeWidget),
TrustDirectory(TrustDirectoryWidget),
}
pub(crate) trait KeyboardHandler {
@@ -71,6 +80,19 @@ pub(crate) struct OnboardingScreen {
should_exit: bool,
}
pub(crate) struct OnboardingScreenArgs {
pub show_trust_screen: bool,
pub show_login_screen: bool,
pub login_status: LoginStatus,
pub app_server_request_handle: Option<AppServerRequestHandle>,
pub config: Config,
}
pub(crate) struct OnboardingResult {
pub directory_trust_persisted: bool,
pub should_exit: bool,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
struct ApiKeyEntryContext {
/// True when onboarding is currently rendering the API-key entry state.
@@ -80,33 +102,64 @@ struct ApiKeyEntryContext {
}
impl OnboardingScreen {
fn new(
tui: &mut Tui,
app_server_request_handle: AppServerRequestHandle,
config: Config,
) -> Self {
pub(crate) async fn new(tui: &mut Tui, args: OnboardingScreenArgs) -> Self {
let OnboardingScreenArgs {
show_trust_screen,
show_login_screen,
login_status,
app_server_request_handle,
config,
} = args;
let cwd = config.cwd.to_path_buf();
let forced_login_method = config.forced_login_method;
let mut steps: Vec<Step> = Vec::new();
steps.push(Step::Welcome(WelcomeWidget::new(
/*is_logged_in*/ false,
!matches!(login_status, LoginStatus::NotAuthenticated),
tui.frame_requester(),
config.animations,
)));
let highlighted_mode = match forced_login_method {
Some(ForcedLoginMethod::Api) => SignInOption::ApiKey,
_ => SignInOption::ChatGpt,
};
steps.push(Step::Auth(AuthModeWidget {
request_frame: tui.frame_requester(),
highlighted_mode,
error: Arc::new(RwLock::new(None)),
sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)),
login_status: LoginStatus::NotAuthenticated,
app_server_request_handle,
forced_login_method,
animations_enabled: config.animations,
animations_suppressed: std::cell::Cell::new(false),
}));
if show_login_screen {
let highlighted_mode = match forced_login_method {
Some(ForcedLoginMethod::Api) => SignInOption::ApiKey,
_ => SignInOption::ChatGpt,
};
if let Some(app_server_request_handle) = app_server_request_handle {
steps.push(Step::Auth(AuthModeWidget {
request_frame: tui.frame_requester(),
highlighted_mode,
error: Arc::new(RwLock::new(None)),
sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)),
login_status,
app_server_request_handle,
forced_login_method,
animations_enabled: config.animations,
animations_suppressed: std::cell::Cell::new(false),
}));
} else {
tracing::warn!("skipping onboarding login step without app-server request handle");
}
}
#[cfg(target_os = "windows")]
let show_windows_create_sandbox_hint =
crate::windows_sandbox::level_from_config(&config) == WindowsSandboxLevel::Disabled;
#[cfg(not(target_os = "windows"))]
let show_windows_create_sandbox_hint = false;
let highlighted = TrustDirectorySelection::Trust;
if show_trust_screen {
let trust_target = resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &config.cwd)
.await
.map(Into::into)
.unwrap_or_else(|| cwd.clone());
steps.push(Step::TrustDirectory(TrustDirectoryWidget {
cwd,
trust_target,
show_windows_create_sandbox_hint,
should_quit: false,
selection: None,
highlighted,
error: None,
}))
}
Self {
request_frame: tui.frame_requester(),
steps,
@@ -150,7 +203,7 @@ impl OnboardingScreen {
// material so terminal selection is not interrupted by redraws.
self.current_steps().into_iter().any(|step| match step {
Step::Auth(widget) => widget.should_suppress_animations(),
Step::Welcome(_) => false,
Step::Welcome(_) | Step::TrustDirectory(_) => false,
})
}
@@ -183,7 +236,7 @@ impl OnboardingScreen {
fn auth_widget_mut(&mut self) -> Option<&mut AuthModeWidget> {
self.steps.iter_mut().find_map(|step| match step {
Step::Auth(widget) => Some(widget),
Step::Welcome(_) => None,
Step::Welcome(_) | Step::TrustDirectory(_) => None,
})
}
@@ -255,6 +308,16 @@ impl KeyboardHandler for OnboardingScreen {
if let Some(active_step) = self.current_steps_mut().into_iter().last() {
active_step.handle_key_event(key_event);
}
if self.steps.iter().any(|step| {
if let Step::TrustDirectory(widget) = step {
widget.should_quit()
} else {
false
}
}) {
self.should_exit = true;
self.is_done = true;
}
}
self.request_frame.schedule_frame();
}
@@ -296,6 +359,7 @@ impl WidgetRef for &OnboardingScreen {
match step {
Step::Welcome(widget) => widget.set_animations_suppressed(suppress_animations),
Step::Auth(widget) => widget.set_animations_suppressed(suppress_animations),
Step::TrustDirectory(_) => {}
}
}
@@ -368,6 +432,7 @@ impl KeyboardHandler for Step {
match self {
Step::Welcome(widget) => widget.handle_key_event(key_event),
Step::Auth(widget) => widget.handle_key_event(key_event),
Step::TrustDirectory(widget) => widget.handle_key_event(key_event),
}
}
@@ -375,6 +440,7 @@ impl KeyboardHandler for Step {
match self {
Step::Welcome(_) => {}
Step::Auth(widget) => widget.handle_paste(pasted),
Step::TrustDirectory(widget) => widget.handle_paste(pasted),
}
}
}
@@ -384,6 +450,7 @@ impl StepStateProvider for Step {
match self {
Step::Welcome(w) => w.get_step_state(),
Step::Auth(w) => w.get_step_state(),
Step::TrustDirectory(w) => w.get_step_state(),
}
}
}
@@ -397,18 +464,23 @@ impl WidgetRef for Step {
Step::Auth(widget) => {
widget.render_ref(area, buf);
}
Step::TrustDirectory(widget) => {
widget.render_ref(area, buf);
}
}
}
}
pub(crate) async fn run_login_onboarding(
config: Config,
app_server: &mut AppServerSession,
pub(crate) async fn run_onboarding_app(
args: OnboardingScreenArgs,
mut app_server: Option<&mut AppServerSession>,
tui: &mut Tui,
) -> Result<bool> {
) -> Result<OnboardingResult> {
use tokio_stream::StreamExt;
let mut onboarding_screen = OnboardingScreen::new(tui, app_server.request_handle(), config);
let app_server_request_handle = args.app_server_request_handle.clone();
let mut onboarding_screen = OnboardingScreen::new(tui, args).await;
let mut directory_trust_persisted = false;
// One-time guard to fully clear the screen after ChatGPT login success message is shown
let mut did_full_clear_after_success = false;
@@ -427,6 +499,13 @@ pub(crate) async fn run_login_onboarding(
match event {
TuiEvent::Key(key_event) => {
onboarding_screen.handle_key_event(key_event);
if !directory_trust_persisted {
directory_trust_persisted = persist_selected_trust(
&mut onboarding_screen,
app_server_request_handle.clone(),
)
.await;
}
}
TuiEvent::Paste(text) => {
onboarding_screen.handle_paste(text);
@@ -469,7 +548,12 @@ pub(crate) async fn run_login_onboarding(
}
}
}
event = app_server.next_event() => {
event = async {
match app_server.as_mut() {
Some(app_server) => app_server.next_event().await,
None => None,
}
}, if app_server.is_some() => {
if let Some(event) = event {
match event {
AppServerEvent::ServerNotification(notification) => {
@@ -485,16 +569,75 @@ pub(crate) async fn run_login_onboarding(
}
}
}
Ok(onboarding_screen.should_exit())
Ok(OnboardingResult {
directory_trust_persisted,
should_exit: onboarding_screen.should_exit(),
})
}
async fn persist_selected_trust(
onboarding_screen: &mut OnboardingScreen,
request_handle: Option<AppServerRequestHandle>,
) -> bool {
let Some((trust_step_index, trust_target)) = onboarding_screen
.steps
.iter()
.enumerate()
.find_map(|(index, step)| {
if let Step::TrustDirectory(widget) = step
&& widget.selection == Some(TrustDirectorySelection::Trust)
{
return Some((index, widget.trust_target.clone()));
}
None
})
else {
return false;
};
let result = match request_handle {
Some(request_handle) => write_trusted_project(request_handle, &trust_target)
.await
.map(|_| ()),
None => Err(color_eyre::eyre::eyre!("app server unavailable")),
};
match result {
Ok(()) => true,
Err(error) => {
let error = format_config_error(&error);
tracing::error!(
"failed to persist trusted project state for {}: {error}",
trust_target.display()
);
if let Step::TrustDirectory(widget) = &mut onboarding_screen.steps[trust_step_index] {
widget.selection = None;
widget.error = Some(format!(
"Failed to set trust for {}: {error}",
trust_target.display()
));
}
false
}
}
}
#[cfg(test)]
mod tests {
use super::ApiKeyEntryContext;
use super::OnboardingScreen;
use super::Step;
use super::StepStateProvider;
use super::persist_selected_trust;
use super::suppress_quit_while_typing_api_key;
use crate::onboarding::trust_directory::TrustDirectorySelection;
use crate::onboarding::trust_directory::TrustDirectoryWidget;
use crate::tui::FrameRequester;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use pretty_assertions::assert_eq;
use std::path::PathBuf;
#[test]
fn suppresses_printable_quit_key_during_api_key_entry() {
@@ -543,4 +686,38 @@ mod tests {
);
assert!(!suppressed);
}
#[tokio::test]
async fn trust_persistence_failure_keeps_trust_step_in_progress() {
let mut onboarding_screen = OnboardingScreen {
request_frame: FrameRequester::test_dummy(),
steps: vec![Step::TrustDirectory(TrustDirectoryWidget {
cwd: PathBuf::from("/workspace/project"),
trust_target: PathBuf::from("/workspace/project"),
show_windows_create_sandbox_hint: false,
should_quit: false,
selection: Some(TrustDirectorySelection::Trust),
highlighted: TrustDirectorySelection::Trust,
error: None,
})],
is_done: false,
should_exit: false,
};
let persisted =
persist_selected_trust(&mut onboarding_screen, /*request_handle*/ None).await;
assert!(!persisted);
let Step::TrustDirectory(widget) = &onboarding_screen.steps[0] else {
panic!("trust step should remain present");
};
assert_eq!(widget.selection, None);
assert_eq!(widget.get_step_state(), super::StepState::InProgress);
assert!(
widget
.error
.as_deref()
.is_some_and(|error| error.contains("app server unavailable"))
);
}
}
@@ -0,0 +1,15 @@
---
source: tui/src/onboarding/trust_directory.rs
expression: terminal.backend()
---
> You are in /workspace/project
Do you trust the contents of this directory? Working with untrusted
contents comes with higher risk of prompt injection. Trusting the
directory allows project-local config, hooks, and exec policies to
load.
› 1. Yes, continue
2. No, quit
Press enter to continue
@@ -0,0 +1,19 @@
---
source: tui/src/onboarding/trust_directory.rs
expression: terminal.backend()
---
> You are in /workspace/project
Do you trust the contents of this directory? Working with untrusted
contents comes with higher risk of prompt injection. Trusting the
directory allows project-local config, hooks, and exec policies to
load.
› 1. Yes, continue
2. No, quit
Failed to set trust for /workspace/project: config/batchWrite failed
in TUI: Invalid configuration: features.fast_mode=true is not
supported; allowed set [fast_mode=false]
Press enter to continue
@@ -0,0 +1,258 @@
use std::path::PathBuf;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
use crate::key_hint::KeyBindingListExt;
use crate::onboarding::keys;
use crate::onboarding::onboarding_screen::KeyboardHandler;
use crate::onboarding::onboarding_screen::StepStateProvider;
use crate::render::Insets;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use crate::render::renderable::RenderableExt as _;
use crate::selection_list::selection_option_row;
use super::onboarding_screen::StepState;
pub(crate) struct TrustDirectoryWidget {
pub cwd: PathBuf,
pub trust_target: PathBuf,
pub show_windows_create_sandbox_hint: bool,
pub should_quit: bool,
pub selection: Option<TrustDirectorySelection>,
pub highlighted: TrustDirectorySelection,
pub error: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TrustDirectorySelection {
Trust,
Quit,
}
impl WidgetRef for &TrustDirectoryWidget {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let mut column = ColumnRenderable::new();
column.push(Line::from(vec![
"> ".into(),
"You are in ".bold(),
self.cwd.to_string_lossy().to_string().into(),
]));
column.push("");
if self.cwd != self.trust_target {
#[allow(clippy::disallowed_methods)]
let git_root_warning = Paragraph::new(format!(
"Note: You’re in a subdirectory of a Git project. Trusting will apply to the repository root: {}",
self.trust_target.display()
))
.yellow();
column.push(
git_root_warning
.wrap(Wrap { trim: true })
.inset(Insets::tlbr(
/*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0,
)),
);
column.push("");
}
column.push(
Paragraph::new(
"Do you trust the contents of this directory? Working with untrusted \
contents comes with higher risk of prompt injection. Trusting the \
directory allows project-local config, hooks, and exec policies to load."
.to_string(),
)
.wrap(Wrap { trim: true })
.inset(Insets::tlbr(
/*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0,
)),
);
column.push("");
let options: Vec<(&str, TrustDirectorySelection)> = vec![
("Yes, continue", TrustDirectorySelection::Trust),
("No, quit", TrustDirectorySelection::Quit),
];
for (idx, (text, selection)) in options.iter().enumerate() {
column.push(selection_option_row(
idx,
text.to_string(),
self.highlighted == *selection,
));
}
column.push("");
if let Some(error) = &self.error {
column.push(
Paragraph::new(error.to_string())
.red()
.wrap(Wrap { trim: true })
.inset(Insets::tlbr(
/*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0,
)),
);
column.push("");
}
column.push(
Line::from(vec![
"Press ".dim(),
keys::CONFIRM[0].into(),
if self.show_windows_create_sandbox_hint {
" to continue and create a sandbox...".dim()
} else {
" to continue".dim()
},
])
.inset(Insets::tlbr(
/*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0,
)),
);
column.render(area, buf);
}
}
impl KeyboardHandler for TrustDirectoryWidget {
fn handle_key_event(&mut self, key_event: KeyEvent) {
if key_event.kind == KeyEventKind::Release {
return;
}
if keys::MOVE_UP.is_pressed(key_event) {
self.highlighted = TrustDirectorySelection::Trust;
} else if keys::MOVE_DOWN.is_pressed(key_event) {
self.highlighted = TrustDirectorySelection::Quit;
} else if keys::SELECT_FIRST.is_pressed(key_event) {
self.handle_trust();
} else if keys::SELECT_SECOND.is_pressed(key_event)
|| keys::QUIT.is_pressed(key_event)
|| keys::CANCEL.is_pressed(key_event)
{
self.handle_quit();
} else if keys::CONFIRM.is_pressed(key_event) {
match self.highlighted {
TrustDirectorySelection::Trust => self.handle_trust(),
TrustDirectorySelection::Quit => self.handle_quit(),
}
}
}
}
impl StepStateProvider for TrustDirectoryWidget {
fn get_step_state(&self) -> StepState {
if self.selection.is_some() || self.should_quit {
StepState::Complete
} else {
StepState::InProgress
}
}
}
impl TrustDirectoryWidget {
fn handle_trust(&mut self) {
self.highlighted = TrustDirectorySelection::Trust;
self.error = None;
self.selection = Some(TrustDirectorySelection::Trust);
}
fn handle_quit(&mut self) {
self.highlighted = TrustDirectorySelection::Quit;
self.should_quit = true;
}
pub fn should_quit(&self) -> bool {
self.should_quit
}
}
#[cfg(test)]
mod tests {
use crate::test_backend::VT100Backend;
use super::*;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use pretty_assertions::assert_eq;
use ratatui::Terminal;
use std::path::PathBuf;
fn widget(error: Option<String>) -> TrustDirectoryWidget {
TrustDirectoryWidget {
cwd: PathBuf::from("/workspace/project"),
trust_target: PathBuf::from("/workspace/project"),
show_windows_create_sandbox_hint: false,
should_quit: false,
selection: None,
highlighted: TrustDirectorySelection::Trust,
error,
}
}
#[test]
fn release_event_does_not_change_selection() {
let mut widget = TrustDirectoryWidget {
cwd: PathBuf::from("."),
trust_target: PathBuf::from("."),
show_windows_create_sandbox_hint: false,
should_quit: false,
selection: None,
highlighted: TrustDirectorySelection::Quit,
error: None,
};
let release = KeyEvent {
kind: KeyEventKind::Release,
..KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)
};
widget.handle_key_event(release);
assert_eq!(widget.selection, None);
let press = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
widget.handle_key_event(press);
assert!(widget.should_quit);
}
#[test]
fn renders_snapshot_for_git_repo() {
let widget = widget(/*error*/ None);
let mut terminal =
Terminal::new(VT100Backend::new(/*width*/ 70, /*height*/ 14)).expect("terminal");
terminal
.draw(|f| (&widget).render_ref(f.area(), f.buffer_mut()))
.expect("draw");
insta::assert_snapshot!(terminal.backend());
}
#[test]
fn renders_snapshot_for_trust_error() {
let widget = widget(Some(
"Failed to set trust for /workspace/project: config/batchWrite failed in TUI: Invalid configuration: features.fast_mode=true is not supported; allowed set [fast_mode=false]"
.to_string(),
));
let mut terminal =
Terminal::new(VT100Backend::new(/*width*/ 70, /*height*/ 18)).expect("terminal");
terminal
.draw(|f| (&widget).render_ref(f.area(), f.buffer_mut()))
.expect("draw");
insta::assert_snapshot!(terminal.backend());
}
}