mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
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:
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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,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;
|
||||
|
||||
@@ -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"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+15
@@ -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
|
||||
+19
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user