mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
1659c4a629
## Summary Stacked on #26706. Adds the shared auth/system-proxy contract that later platform resolver PRs plug into. This PR moves Codex-owned auth and startup HTTP clients through a common route-aware boundary, but does not yet add Windows or macOS system proxy resolution. The default path remains unchanged when `respect_system_proxy` is absent or disabled. ## Implementation - Adds `codex-client/src/outbound_proxy.rs` with the shared route-selection model: - `OutboundProxyConfig`; - `ClientRouteClass`; - `RouteFailureClass`; - `build_reqwest_client_for_route`. - Preserves the existing reqwest/default-client behavior when no route config is supplied. - Uses the fixed MVP routing policy when route config is supplied: platform system/PAC/WPAD discovery, then explicit env proxy variables, then direct connection. - Keeps platform-specific system discovery behind the shared client boundary. This PR provides the contract and fallback behavior; later resolver PRs plug in Windows and macOS discovery. - Adds `login::AuthRouteConfig` so auth call sites depend on a small policy type instead of platform resolver details. - Maps the resolved `Config.respect_system_proxy` boolean into `AuthRouteConfig` for auth-owned clients. - Wires the route config through browser login, device-code login, access-token login, login status, logout/revoke, token refresh, API-key exchange, app-server account login, TUI/app startup, cloud-config bootstrap, cloud tasks, plugin auth, and exec startup config loading. ## End-user behavior - No behavior changes by default. - When `respect_system_proxy = true`, auth-owned clients opt into the shared route-aware client path. - On platforms without a resolver implementation in this PR, system discovery is unavailable and the route-aware path falls back to explicit env proxy handling, then direct connection. - Custom CA handling remains separate from proxy route selection and still runs through the shared client builder. - No proxy URLs, PAC contents, or resolved platform details are exposed through the public config surface introduced here. ## Tests Adds or updates coverage for: - preserving default auth-client fallback behavior when no route config is provided; - injected environment-proxy fallback without mutating process environment; - existing login-server E2E flows using explicit `auth_route_config: None` to guard unchanged default behavior; - updated auth manager, login, logout, cloud-config, startup, and plugin-auth call sites passing route config explicitly.
3096 lines
107 KiB
Rust
3096 lines
107 KiB
Rust
// Forbid accidental stdout/stderr writes in the *library* portion of the TUI.
|
||
// The standalone `codex-tui` binary prints a short help message before the
|
||
// alternate‑screen mode starts; that file opts‑out locally via `allow`.
|
||
#![deny(clippy::print_stdout, clippy::print_stderr)]
|
||
#![deny(clippy::disallowed_methods)]
|
||
use crate::legacy_core::check_execpolicy_for_warnings;
|
||
use crate::legacy_core::config::Config;
|
||
use crate::legacy_core::config::ConfigBuilder;
|
||
use crate::legacy_core::config::ConfigOverrides;
|
||
use crate::legacy_core::config::ConfigTomlLoadResult;
|
||
use crate::legacy_core::config::load_config_toml_with_layer_stack;
|
||
use crate::legacy_core::config::resolve_bootstrap_auth_keyring_backend_kind;
|
||
use crate::legacy_core::config::resolve_bootstrap_auth_route_config;
|
||
use crate::legacy_core::config::resolve_oss_provider;
|
||
use crate::legacy_core::config::resolve_profile_v2_config_path;
|
||
use crate::legacy_core::format_exec_policy_error_with_source;
|
||
use crate::session_resume::ResolveCwdOutcome;
|
||
use crate::session_resume::resolve_cwd_for_resume_or_fork;
|
||
pub use crate::startup_error::LocalStateDbStartupError;
|
||
use additional_dirs::add_dir_warning_message;
|
||
use app::App;
|
||
pub use app::AppExitInfo;
|
||
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::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY;
|
||
use codex_app_server_client::InProcessAppServerClient;
|
||
use codex_app_server_client::InProcessClientStartArgs;
|
||
use codex_app_server_client::RemoteAppServerClient;
|
||
use codex_app_server_client::RemoteAppServerConnectArgs;
|
||
pub use codex_app_server_client::RemoteAppServerEndpoint;
|
||
use codex_app_server_protocol::Account as AppServerAccount;
|
||
use codex_app_server_protocol::AskForApproval;
|
||
use codex_app_server_protocol::AuthMode as AppServerAuthMode;
|
||
use codex_app_server_protocol::ConfigWarningNotification;
|
||
use codex_app_server_protocol::Thread as AppServerThread;
|
||
use codex_app_server_protocol::ThreadListCwdFilter;
|
||
use codex_app_server_protocol::ThreadListParams;
|
||
use codex_app_server_protocol::ThreadSortKey as AppServerThreadSortKey;
|
||
use codex_app_server_protocol::ThreadSourceKind;
|
||
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::format_config_error_with_source;
|
||
use codex_exec_server::EnvironmentManager;
|
||
use codex_exec_server::ExecServerRuntimePaths;
|
||
use codex_login::AuthConfig;
|
||
use codex_login::default_client::originator;
|
||
use codex_login::default_client::set_default_client_residency_requirement;
|
||
use codex_login::enforce_login_restrictions;
|
||
use codex_protocol::ThreadId;
|
||
use codex_protocol::config_types::AltScreenMode;
|
||
use codex_protocol::config_types::SandboxMode;
|
||
#[cfg(target_os = "windows")]
|
||
use codex_protocol::config_types::WindowsSandboxLevel;
|
||
use codex_rollout::StateDbHandle;
|
||
use codex_rollout::state_db;
|
||
use codex_state::log_db;
|
||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||
use codex_utils_absolute_path::canonicalize_existing_preserving_symlinks;
|
||
use codex_utils_home_dir::find_codex_home;
|
||
use codex_utils_oss::ensure_oss_provider_ready;
|
||
use codex_utils_oss::get_default_model_for_oss_provider;
|
||
use color_eyre::eyre::WrapErr;
|
||
use cwd_prompt::CwdPromptAction;
|
||
pub use session_archive_commands::DeleteConfirmation;
|
||
pub use session_archive_commands::SessionArchiveAction;
|
||
pub use session_archive_commands::SessionArchiveCommandOptions;
|
||
pub use session_archive_commands::run_session_archive_command;
|
||
use std::fs::OpenOptions;
|
||
use std::path::Path;
|
||
use std::path::PathBuf;
|
||
use std::sync::Arc;
|
||
use std::time::Instant;
|
||
pub use token_usage::TokenUsage;
|
||
use tracing::error;
|
||
use tracing::warn;
|
||
use tracing_appender::non_blocking;
|
||
use tracing_subscriber::EnvFilter;
|
||
use tracing_subscriber::prelude::*;
|
||
use url::Url;
|
||
use uuid::Uuid;
|
||
|
||
pub(crate) use codex_app_server_client::legacy_core;
|
||
|
||
mod additional_dirs;
|
||
mod app;
|
||
mod app_backtrack;
|
||
mod app_command;
|
||
mod app_event;
|
||
mod app_event_sender;
|
||
mod app_server_approval_conversions;
|
||
mod app_server_session;
|
||
mod approval_events;
|
||
mod ascii_animation;
|
||
mod bottom_pane;
|
||
mod branch_summary;
|
||
mod chatwidget;
|
||
mod cli;
|
||
mod clipboard_copy;
|
||
mod clipboard_paste;
|
||
mod collaboration_modes;
|
||
mod color;
|
||
mod config_update;
|
||
pub(crate) mod custom_terminal;
|
||
mod pets;
|
||
pub use custom_terminal::Terminal;
|
||
mod auto_review_denials;
|
||
mod cwd_prompt;
|
||
mod debug_config;
|
||
mod diff_model;
|
||
mod diff_render;
|
||
mod exec_cell;
|
||
mod exec_command;
|
||
mod external_agent_config_migration;
|
||
mod external_agent_config_migration_flow;
|
||
mod external_agent_config_migration_model;
|
||
mod external_editor;
|
||
mod file_search;
|
||
mod frames;
|
||
mod get_git_diff;
|
||
mod git_action_directives;
|
||
mod goal_display;
|
||
mod goal_files;
|
||
mod history_cell;
|
||
mod hooks_rpc;
|
||
mod ide_context;
|
||
pub(crate) mod insert_history;
|
||
pub use insert_history::insert_history_lines;
|
||
mod key_hint;
|
||
mod keymap;
|
||
mod keymap_setup;
|
||
mod line_truncation;
|
||
pub(crate) mod live_wrap;
|
||
pub use live_wrap::RowBuilder;
|
||
mod local_chatgpt_auth;
|
||
mod markdown;
|
||
mod markdown_render;
|
||
mod markdown_stream;
|
||
mod markdown_text_merge;
|
||
mod mention_codec;
|
||
mod model_catalog;
|
||
mod model_migration;
|
||
mod motion;
|
||
mod multi_agents;
|
||
mod notifications;
|
||
#[cfg(any(not(debug_assertions), test))]
|
||
mod npm_registry;
|
||
pub(crate) mod onboarding;
|
||
mod oss_selection;
|
||
mod pager_overlay;
|
||
mod permission_compat;
|
||
pub(crate) mod public_widgets;
|
||
mod render;
|
||
mod resize_reflow_cap;
|
||
mod resume_picker;
|
||
mod selection_list;
|
||
mod service_tier_resolution;
|
||
mod session_archive_commands;
|
||
mod session_log;
|
||
mod session_resume;
|
||
mod session_state;
|
||
mod shimmer;
|
||
mod skills_helpers;
|
||
mod slash_command;
|
||
mod startup_error;
|
||
mod startup_hooks_review;
|
||
mod status;
|
||
mod status_indicator_widget;
|
||
mod streaming;
|
||
mod style;
|
||
mod terminal_hyperlinks;
|
||
mod terminal_palette;
|
||
mod terminal_probe;
|
||
mod terminal_title;
|
||
mod terminal_visualization_instructions;
|
||
mod text_formatting;
|
||
mod theme_picker;
|
||
mod thread_transcript;
|
||
mod token_usage;
|
||
mod tooltips;
|
||
mod transcript_reflow;
|
||
mod tui;
|
||
mod ui_consts;
|
||
pub(crate) mod update_action;
|
||
pub use update_action::UpdateAction;
|
||
#[cfg(not(debug_assertions))]
|
||
pub use update_action::get_update_action;
|
||
mod update_prompt;
|
||
#[cfg(any(not(debug_assertions), test))]
|
||
mod update_versions;
|
||
mod updates;
|
||
#[cfg(any(not(debug_assertions), test))]
|
||
mod updates_cache;
|
||
mod version;
|
||
mod width;
|
||
#[cfg(any(target_os = "windows", test))]
|
||
mod windows_sandbox;
|
||
mod workspace_command;
|
||
mod workspace_messages;
|
||
|
||
mod wrapping;
|
||
|
||
mod table_detect;
|
||
#[cfg(test)]
|
||
pub(crate) mod test_backend;
|
||
#[cfg(test)]
|
||
pub(crate) mod test_support;
|
||
|
||
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;
|
||
use crate::tui::Tui;
|
||
pub use cli::Cli;
|
||
use codex_arg0::Arg0DispatchPaths;
|
||
pub use markdown_render::render_markdown_text;
|
||
pub use public_widgets::composer_input::ComposerAction;
|
||
pub use public_widgets::composer_input::ComposerInput;
|
||
// (tests access modules directly within the crate)
|
||
|
||
const TUI_LOG_FILE_NAME: &str = "codex-tui.log";
|
||
|
||
#[cfg(unix)]
|
||
const AUTO_CONNECT_DAEMON_CONNECT_TIMEOUT: std::time::Duration =
|
||
std::time::Duration::from_millis(50);
|
||
|
||
#[allow(clippy::too_many_arguments)]
|
||
async fn start_embedded_app_server(
|
||
arg0_paths: Arg0DispatchPaths,
|
||
config: Config,
|
||
cli_kv_overrides: Vec<(String, toml::Value)>,
|
||
loader_overrides: LoaderOverrides,
|
||
strict_config: bool,
|
||
cloud_config_bundle: CloudConfigBundleLoader,
|
||
feedback: codex_feedback::CodexFeedback,
|
||
log_db: Option<log_db::LogDbLayer>,
|
||
state_db: Option<StateDbHandle>,
|
||
environment_manager: Arc<EnvironmentManager>,
|
||
) -> color_eyre::Result<InProcessAppServerClient> {
|
||
start_embedded_app_server_with(
|
||
arg0_paths,
|
||
config,
|
||
cli_kv_overrides,
|
||
loader_overrides,
|
||
strict_config,
|
||
cloud_config_bundle,
|
||
feedback,
|
||
log_db,
|
||
state_db,
|
||
environment_manager,
|
||
InProcessAppServerClient::start,
|
||
)
|
||
.await
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub(crate) enum AppServerTarget {
|
||
Embedded,
|
||
LocalDaemon { endpoint: RemoteAppServerEndpoint },
|
||
Remote { endpoint: RemoteAppServerEndpoint },
|
||
}
|
||
|
||
impl AppServerTarget {
|
||
pub(crate) fn uses_remote_workspace(&self) -> bool {
|
||
matches!(self, Self::Remote { .. })
|
||
}
|
||
|
||
fn thread_params_mode(&self) -> ThreadParamsMode {
|
||
if self.uses_remote_workspace() {
|
||
ThreadParamsMode::Remote
|
||
} else {
|
||
ThreadParamsMode::Embedded
|
||
}
|
||
}
|
||
}
|
||
|
||
async fn init_state_db_for_app_server_target(
|
||
config: &Config,
|
||
app_server_target: &AppServerTarget,
|
||
) -> std::io::Result<Option<StateDbHandle>> {
|
||
match app_server_target {
|
||
AppServerTarget::Embedded => state_db::try_init(config).await.map(Some).map_err(|err| {
|
||
let database_path = codex_state::runtime_db_path_for_corruption_error(&err)
|
||
.unwrap_or_else(|| codex_state::state_db_path(config.sqlite_home.as_path()));
|
||
std::io::Error::other(LocalStateDbStartupError::new(
|
||
database_path,
|
||
format!("{err:#}"),
|
||
))
|
||
}),
|
||
AppServerTarget::LocalDaemon { .. } | AppServerTarget::Remote { .. } => {
|
||
Ok(state_db::get_state_db(config).await)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TODO(jif) delete after 22/11/2026.
|
||
fn remove_legacy_tui_log_file(codex_home: &Path) {
|
||
// Shared append-only TUI logs could grow without bound. Existing processes
|
||
// may still hold the file open, so startup cleanup is best effort.
|
||
let _ = std::fs::remove_file(codex_home.join("log").join(TUI_LOG_FILE_NAME));
|
||
}
|
||
|
||
fn remote_addr_has_explicit_port(addr: &str, parsed: &Url) -> bool {
|
||
let Some(host) = parsed.host_str() else {
|
||
return false;
|
||
};
|
||
if parsed.port().is_some() {
|
||
return true;
|
||
}
|
||
|
||
let Some((_, rest)) = addr.split_once("://") else {
|
||
return false;
|
||
};
|
||
let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
|
||
let authority = &rest[..authority_end];
|
||
let host_and_port = authority
|
||
.rsplit_once('@')
|
||
.map_or(authority, |(_, host_and_port)| host_and_port);
|
||
let explicit_default_port = match parsed.scheme() {
|
||
"ws" => 80,
|
||
"wss" => 443,
|
||
_ => return false,
|
||
};
|
||
let expected_host = if host.contains(':') {
|
||
format!("[{host}]")
|
||
} else {
|
||
host.to_string()
|
||
};
|
||
host_and_port == format!("{expected_host}:{explicit_default_port}")
|
||
}
|
||
|
||
fn websocket_url_supports_auth_token(parsed: &Url) -> bool {
|
||
match (parsed.scheme(), parsed.host()) {
|
||
("wss", Some(_)) => true,
|
||
("ws", Some(url::Host::Domain(domain))) => domain.eq_ignore_ascii_case("localhost"),
|
||
("ws", Some(url::Host::Ipv4(addr))) => addr.is_loopback(),
|
||
("ws", Some(url::Host::Ipv6(addr))) => addr.is_loopback(),
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
pub fn resolve_remote_addr(addr: &str) -> color_eyre::Result<RemoteAppServerEndpoint> {
|
||
if let Some(socket_path) = addr.strip_prefix("unix://") {
|
||
let socket_path = if socket_path.is_empty() {
|
||
let codex_home = find_codex_home().wrap_err("failed to resolve CODEX_HOME")?;
|
||
codex_app_server_client::app_server_control_socket_path(&codex_home)
|
||
.map_err(color_eyre::Report::new)?
|
||
} else {
|
||
AbsolutePathBuf::relative_to_current_dir(socket_path)
|
||
.map_err(color_eyre::Report::new)?
|
||
};
|
||
return Ok(RemoteAppServerEndpoint::UnixSocket { socket_path });
|
||
}
|
||
|
||
let parsed = match Url::parse(addr) {
|
||
Ok(parsed) => parsed,
|
||
Err(_) => {
|
||
color_eyre::eyre::bail!(
|
||
"invalid remote address `{addr}`; expected `ws://host:port`, `wss://host:port`, `unix://`, or `unix://PATH`"
|
||
);
|
||
}
|
||
};
|
||
if matches!(parsed.scheme(), "ws" | "wss")
|
||
&& parsed.host_str().is_some()
|
||
&& remote_addr_has_explicit_port(addr, &parsed)
|
||
&& parsed.path() == "/"
|
||
&& parsed.query().is_none()
|
||
&& parsed.fragment().is_none()
|
||
{
|
||
return Ok(RemoteAppServerEndpoint::WebSocket {
|
||
websocket_url: parsed.to_string(),
|
||
auth_token: None,
|
||
});
|
||
}
|
||
|
||
color_eyre::eyre::bail!(
|
||
"invalid remote address `{addr}`; expected `ws://host:port`, `wss://host:port`, `unix://`, or `unix://PATH`"
|
||
);
|
||
}
|
||
|
||
pub fn remote_addr_supports_auth_token(endpoint: &RemoteAppServerEndpoint) -> bool {
|
||
match endpoint {
|
||
RemoteAppServerEndpoint::WebSocket { websocket_url, .. } => {
|
||
Url::parse(websocket_url).is_ok_and(|parsed| websocket_url_supports_auth_token(&parsed))
|
||
}
|
||
RemoteAppServerEndpoint::UnixSocket { .. } => false,
|
||
}
|
||
}
|
||
|
||
async fn connect_remote_app_server(
|
||
endpoint: RemoteAppServerEndpoint,
|
||
) -> color_eyre::Result<AppServerClient> {
|
||
let app_server = RemoteAppServerClient::connect(RemoteAppServerConnectArgs {
|
||
endpoint,
|
||
client_name: "codex-tui".to_string(),
|
||
client_version: env!("CARGO_PKG_VERSION").to_string(),
|
||
experimental_api: true,
|
||
mcp_server_openai_form_elicitation: false,
|
||
opt_out_notification_methods: Vec::new(),
|
||
channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY,
|
||
})
|
||
.await
|
||
.wrap_err("failed to connect to remote app server")?;
|
||
Ok(AppServerClient::Remote(app_server))
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
async fn maybe_probe_default_daemon_socket(codex_home: &Path) -> Option<AbsolutePathBuf> {
|
||
let socket_path = codex_app_server_client::app_server_control_socket_path(codex_home).ok()?;
|
||
if !socket_path.as_path().try_exists().unwrap_or(false) {
|
||
return None;
|
||
}
|
||
|
||
match tokio::time::timeout(
|
||
AUTO_CONNECT_DAEMON_CONNECT_TIMEOUT,
|
||
tokio::net::UnixStream::connect(socket_path.as_path()),
|
||
)
|
||
.await
|
||
{
|
||
Ok(Ok(_stream)) => Some(socket_path),
|
||
Ok(Err(err)) => {
|
||
tracing::debug!(%err, socket_path = %socket_path.display(), "skipping default app-server daemon socket");
|
||
None
|
||
}
|
||
Err(_) => {
|
||
tracing::debug!(
|
||
socket_path = %socket_path.display(),
|
||
timeout_ms = AUTO_CONNECT_DAEMON_CONNECT_TIMEOUT.as_millis(),
|
||
"timed out probing default app-server daemon socket"
|
||
);
|
||
None
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(not(unix))]
|
||
async fn maybe_probe_default_daemon_socket(_codex_home: &Path) -> Option<AbsolutePathBuf> {
|
||
None
|
||
}
|
||
|
||
#[allow(clippy::too_many_arguments)]
|
||
async fn start_app_server(
|
||
target: &AppServerTarget,
|
||
arg0_paths: Arg0DispatchPaths,
|
||
config: Config,
|
||
cli_kv_overrides: Vec<(String, toml::Value)>,
|
||
loader_overrides: LoaderOverrides,
|
||
strict_config: bool,
|
||
cloud_config_bundle: CloudConfigBundleLoader,
|
||
feedback: codex_feedback::CodexFeedback,
|
||
log_db: Option<log_db::LogDbLayer>,
|
||
state_db: Option<StateDbHandle>,
|
||
environment_manager: Arc<EnvironmentManager>,
|
||
) -> color_eyre::Result<AppServerClient> {
|
||
match target {
|
||
AppServerTarget::Embedded => start_embedded_app_server(
|
||
arg0_paths,
|
||
config,
|
||
cli_kv_overrides,
|
||
loader_overrides,
|
||
strict_config,
|
||
cloud_config_bundle,
|
||
feedback,
|
||
log_db,
|
||
state_db,
|
||
environment_manager,
|
||
)
|
||
.await
|
||
.map(AppServerClient::InProcess),
|
||
AppServerTarget::LocalDaemon { endpoint } | AppServerTarget::Remote { endpoint } => {
|
||
connect_remote_app_server(endpoint.clone()).await
|
||
}
|
||
}
|
||
}
|
||
|
||
pub(crate) async fn start_app_server_for_picker(
|
||
config: &Config,
|
||
target: &AppServerTarget,
|
||
state_db: Option<StateDbHandle>,
|
||
environment_manager: Arc<EnvironmentManager>,
|
||
) -> color_eyre::Result<AppServerSession> {
|
||
let app_server = start_app_server(
|
||
target,
|
||
Arg0DispatchPaths::default(),
|
||
config.clone(),
|
||
Vec::new(),
|
||
LoaderOverrides::default(),
|
||
/*strict_config*/ false,
|
||
CloudConfigBundleLoader::default(),
|
||
codex_feedback::CodexFeedback::new(),
|
||
/*log_db*/ None,
|
||
state_db,
|
||
environment_manager,
|
||
)
|
||
.await?;
|
||
Ok(AppServerSession::new(
|
||
app_server,
|
||
target.thread_params_mode(),
|
||
))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
pub(crate) async fn start_embedded_app_server_for_picker(
|
||
config: &Config,
|
||
) -> color_eyre::Result<AppServerSession> {
|
||
let state_db = init_state_db_for_app_server_target(config, &AppServerTarget::Embedded).await?;
|
||
start_app_server_for_picker(
|
||
config,
|
||
&AppServerTarget::Embedded,
|
||
state_db,
|
||
Arc::new(EnvironmentManager::default_for_tests()),
|
||
)
|
||
.await
|
||
}
|
||
|
||
#[allow(clippy::too_many_arguments)]
|
||
async fn start_embedded_app_server_with<F, Fut>(
|
||
arg0_paths: Arg0DispatchPaths,
|
||
config: Config,
|
||
cli_kv_overrides: Vec<(String, toml::Value)>,
|
||
loader_overrides: LoaderOverrides,
|
||
strict_config: bool,
|
||
cloud_config_bundle: CloudConfigBundleLoader,
|
||
feedback: codex_feedback::CodexFeedback,
|
||
log_db: Option<log_db::LogDbLayer>,
|
||
state_db: Option<StateDbHandle>,
|
||
environment_manager: Arc<EnvironmentManager>,
|
||
start_client: F,
|
||
) -> color_eyre::Result<InProcessAppServerClient>
|
||
where
|
||
F: FnOnce(InProcessClientStartArgs) -> Fut,
|
||
Fut: Future<Output = std::io::Result<InProcessAppServerClient>>,
|
||
{
|
||
let config_warnings = config
|
||
.startup_warnings
|
||
.iter()
|
||
.map(|warning| ConfigWarningNotification {
|
||
summary: warning.clone(),
|
||
details: None,
|
||
path: None,
|
||
range: None,
|
||
})
|
||
.collect();
|
||
let client = start_client(InProcessClientStartArgs {
|
||
arg0_paths,
|
||
config: Arc::new(config),
|
||
cli_overrides: cli_kv_overrides,
|
||
loader_overrides,
|
||
strict_config,
|
||
cloud_config_bundle,
|
||
feedback,
|
||
log_db,
|
||
state_db,
|
||
environment_manager,
|
||
config_warnings,
|
||
session_source: serde_json::from_value(serde_json::json!("cli"))
|
||
.unwrap_or_else(|err| panic!("cli session source should deserialize: {err}")),
|
||
enable_codex_api_key_env: false,
|
||
client_name: "codex-tui".to_string(),
|
||
client_version: env!("CARGO_PKG_VERSION").to_string(),
|
||
experimental_api: true,
|
||
mcp_server_openai_form_elicitation: false,
|
||
opt_out_notification_methods: Vec::new(),
|
||
channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY,
|
||
})
|
||
.await
|
||
.wrap_err("failed to start embedded app server")?;
|
||
Ok(client)
|
||
}
|
||
|
||
async fn shutdown_app_server_if_present(app_server: Option<AppServerSession>) {
|
||
if let Some(app_server) = app_server
|
||
&& let Err(err) = app_server.shutdown().await
|
||
{
|
||
warn!(%err, "Failed to shut down temporary embedded app server");
|
||
}
|
||
}
|
||
|
||
fn session_target_from_app_server_thread(
|
||
thread: AppServerThread,
|
||
) -> Option<resume_picker::SessionTarget> {
|
||
match ThreadId::from_string(&thread.id) {
|
||
Ok(thread_id) => Some(resume_picker::SessionTarget {
|
||
path: thread.path,
|
||
thread_id,
|
||
}),
|
||
Err(err) => {
|
||
warn!(
|
||
thread_id = thread.id,
|
||
%err,
|
||
"Ignoring app-server thread with invalid thread id during TUI session lookup"
|
||
);
|
||
None
|
||
}
|
||
}
|
||
}
|
||
|
||
pub(crate) fn resume_source_kinds(include_non_interactive: bool) -> Vec<ThreadSourceKind> {
|
||
let mut source_kinds = vec![ThreadSourceKind::Cli, ThreadSourceKind::VsCode];
|
||
if include_non_interactive {
|
||
// `thread/list` treats omitted and empty `sourceKinds` as interactive-only,
|
||
// so include-non-interactive has to name the user-resumable non-interactive
|
||
// sources explicitly until the API grows an unfiltered request.
|
||
source_kinds.extend([ThreadSourceKind::Exec, ThreadSourceKind::AppServer]);
|
||
}
|
||
source_kinds
|
||
}
|
||
|
||
async fn lookup_session_target_by_name_with_app_server(
|
||
app_server: &mut AppServerSession,
|
||
name: &str,
|
||
) -> color_eyre::Result<Option<resume_picker::SessionTarget>> {
|
||
let mut cursor = None;
|
||
loop {
|
||
let response = app_server
|
||
.thread_list(ThreadListParams {
|
||
cursor: cursor.clone(),
|
||
limit: Some(100),
|
||
sort_key: Some(AppServerThreadSortKey::UpdatedAt),
|
||
sort_direction: None,
|
||
model_providers: None,
|
||
source_kinds: Some(vec![ThreadSourceKind::Cli, ThreadSourceKind::VsCode]),
|
||
archived: Some(false),
|
||
parent_thread_id: None,
|
||
cwd: None,
|
||
use_state_db_only: false,
|
||
search_term: Some(name.to_string()),
|
||
})
|
||
.await?;
|
||
if let Some(thread) = response
|
||
.data
|
||
.into_iter()
|
||
.find(|thread| thread.name.as_deref() == Some(name))
|
||
{
|
||
return Ok(session_target_from_app_server_thread(thread));
|
||
}
|
||
if response.next_cursor.is_none() {
|
||
return Ok(None);
|
||
}
|
||
cursor = response.next_cursor;
|
||
}
|
||
}
|
||
|
||
async fn lookup_session_target_with_app_server(
|
||
app_server: &mut AppServerSession,
|
||
id_or_name: &str,
|
||
) -> color_eyre::Result<Option<resume_picker::SessionTarget>> {
|
||
if Uuid::parse_str(id_or_name).is_ok() {
|
||
let thread_id = match ThreadId::from_string(id_or_name) {
|
||
Ok(thread_id) => thread_id,
|
||
Err(err) => {
|
||
warn!(
|
||
session = id_or_name,
|
||
%err,
|
||
"Failed to parse session id during TUI lookup"
|
||
);
|
||
return Ok(None);
|
||
}
|
||
};
|
||
return match app_server
|
||
.thread_read(thread_id, /*include_turns*/ false)
|
||
.await
|
||
{
|
||
Ok(thread) => Ok(session_target_from_app_server_thread(thread)),
|
||
Err(err) => {
|
||
warn!(
|
||
session = id_or_name,
|
||
%err,
|
||
"thread/read failed during TUI session lookup"
|
||
);
|
||
Ok(None)
|
||
}
|
||
};
|
||
}
|
||
|
||
lookup_session_target_by_name_with_app_server(app_server, id_or_name).await
|
||
}
|
||
|
||
async fn lookup_latest_session_target_with_app_server(
|
||
app_server: &mut AppServerSession,
|
||
config: &Config,
|
||
cwd_filter: Option<&Path>,
|
||
include_non_interactive: bool,
|
||
) -> color_eyre::Result<Option<resume_picker::SessionTarget>> {
|
||
let uses_remote_workspace = app_server.uses_remote_workspace();
|
||
for lookup_mode in [
|
||
LatestSessionLookupMode::StateDbOnly,
|
||
LatestSessionLookupMode::ScanAndRepair,
|
||
] {
|
||
let response = app_server
|
||
.thread_list(latest_session_lookup_params(
|
||
uses_remote_workspace,
|
||
config,
|
||
cwd_filter,
|
||
include_non_interactive,
|
||
lookup_mode,
|
||
))
|
||
.await?;
|
||
let target = response
|
||
.data
|
||
.into_iter()
|
||
.find_map(session_target_from_app_server_thread);
|
||
if target.as_ref().is_some_and(|target| {
|
||
uses_remote_workspace || target.path.as_deref().is_some_and(std::path::Path::exists)
|
||
}) {
|
||
return Ok(target);
|
||
}
|
||
}
|
||
Ok(None)
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||
enum LatestSessionLookupMode {
|
||
StateDbOnly,
|
||
ScanAndRepair,
|
||
}
|
||
|
||
fn latest_session_lookup_params(
|
||
uses_remote_workspace: bool,
|
||
config: &Config,
|
||
cwd_filter: Option<&Path>,
|
||
include_non_interactive: bool,
|
||
lookup_mode: LatestSessionLookupMode,
|
||
) -> ThreadListParams {
|
||
ThreadListParams {
|
||
cursor: None,
|
||
limit: Some(1),
|
||
sort_key: Some(AppServerThreadSortKey::UpdatedAt),
|
||
sort_direction: None,
|
||
model_providers: if uses_remote_workspace {
|
||
None
|
||
} else {
|
||
Some(vec![config.model_provider_id.clone()])
|
||
},
|
||
source_kinds: Some(resume_source_kinds(include_non_interactive)),
|
||
archived: Some(false),
|
||
parent_thread_id: None,
|
||
cwd: cwd_filter.map(|cwd| ThreadListCwdFilter::One(cwd.to_string_lossy().to_string())),
|
||
use_state_db_only: match lookup_mode {
|
||
LatestSessionLookupMode::StateDbOnly => true,
|
||
LatestSessionLookupMode::ScanAndRepair => false,
|
||
},
|
||
search_term: None,
|
||
}
|
||
}
|
||
|
||
fn config_cwd_for_app_server_target(
|
||
cwd: Option<&Path>,
|
||
app_server_target: &AppServerTarget,
|
||
environment_manager: &EnvironmentManager,
|
||
) -> std::io::Result<Option<AbsolutePathBuf>> {
|
||
if app_server_target.uses_remote_workspace()
|
||
|| environment_manager
|
||
.default_environment()
|
||
.is_some_and(|environment| environment.is_remote())
|
||
{
|
||
return Ok(None);
|
||
}
|
||
|
||
let cwd = match cwd {
|
||
Some(path) => {
|
||
AbsolutePathBuf::from_absolute_path(canonicalize_existing_preserving_symlinks(path)?)
|
||
}
|
||
None => AbsolutePathBuf::current_dir(),
|
||
}?;
|
||
Ok(Some(cwd))
|
||
}
|
||
|
||
fn should_load_configured_environments(
|
||
loader_overrides: &LoaderOverrides,
|
||
app_server_target: &AppServerTarget,
|
||
) -> bool {
|
||
!loader_overrides.ignore_user_config && !app_server_target.uses_remote_workspace()
|
||
}
|
||
|
||
fn latest_session_cwd_filter<'a>(
|
||
uses_remote_workspace: bool,
|
||
remote_cwd_override: Option<&'a Path>,
|
||
config: &'a Config,
|
||
show_all: bool,
|
||
) -> Option<&'a Path> {
|
||
if show_all {
|
||
return None;
|
||
}
|
||
|
||
if uses_remote_workspace {
|
||
remote_cwd_override
|
||
} else {
|
||
Some(config.cwd.as_path())
|
||
}
|
||
}
|
||
|
||
fn app_server_target_for_launch(
|
||
explicit_remote_endpoint: Option<RemoteAppServerEndpoint>,
|
||
default_daemon_socket: Option<AbsolutePathBuf>,
|
||
can_reuse_implicit_local_daemon: bool,
|
||
) -> AppServerTarget {
|
||
match explicit_remote_endpoint {
|
||
Some(endpoint) => AppServerTarget::Remote { endpoint },
|
||
None if can_reuse_implicit_local_daemon => {
|
||
default_daemon_socket.map_or(AppServerTarget::Embedded, |socket_path| {
|
||
AppServerTarget::LocalDaemon {
|
||
endpoint: RemoteAppServerEndpoint::UnixSocket { socket_path },
|
||
}
|
||
})
|
||
}
|
||
None => AppServerTarget::Embedded,
|
||
}
|
||
}
|
||
|
||
fn loader_overrides_are_default(loader_overrides: &LoaderOverrides) -> bool {
|
||
let loader_overrides_are_default = loader_overrides.user_config_path.is_none()
|
||
&& loader_overrides.user_config_profile.is_none()
|
||
&& loader_overrides.managed_config_path.is_none()
|
||
&& loader_overrides.system_config_path.is_none()
|
||
&& loader_overrides.system_requirements_path.is_none()
|
||
&& !loader_overrides.ignore_managed_requirements
|
||
&& !loader_overrides.ignore_user_config
|
||
&& !loader_overrides.ignore_user_and_project_exec_policy_rules
|
||
&& loader_overrides
|
||
.macos_managed_config_requirements_base64
|
||
.is_none();
|
||
#[cfg(target_os = "macos")]
|
||
let loader_overrides_are_default =
|
||
loader_overrides_are_default && loader_overrides.managed_preferences_base64.is_none();
|
||
loader_overrides_are_default
|
||
}
|
||
|
||
fn can_reuse_implicit_local_daemon(
|
||
cli_kv_overrides: &[(String, toml::Value)],
|
||
loader_overrides: &LoaderOverrides,
|
||
strict_config: bool,
|
||
has_non_replayable_launch_overrides: bool,
|
||
) -> bool {
|
||
// A reused daemon cannot adopt this invocation's full launch config state.
|
||
cli_kv_overrides.is_empty()
|
||
&& loader_overrides_are_default(loader_overrides)
|
||
&& !strict_config
|
||
&& !has_non_replayable_launch_overrides
|
||
}
|
||
|
||
pub async fn run_main(
|
||
mut cli: Cli,
|
||
arg0_paths: Arg0DispatchPaths,
|
||
loader_overrides: LoaderOverrides,
|
||
explicit_remote_endpoint: Option<RemoteAppServerEndpoint>,
|
||
) -> std::io::Result<AppExitInfo> {
|
||
let strict_config = cli.strict_config;
|
||
let (sandbox_mode, approval_policy) = if cli.dangerously_bypass_approvals_and_sandbox {
|
||
(
|
||
Some(SandboxMode::DangerFullAccess),
|
||
Some(AskForApproval::Never.to_core()),
|
||
)
|
||
} else {
|
||
(
|
||
cli.sandbox_mode.map(Into::<SandboxMode>::into),
|
||
cli.approval_policy.map(Into::into),
|
||
)
|
||
};
|
||
|
||
// Map the legacy --search flag to the canonical web_search mode.
|
||
if cli.web_search {
|
||
cli.config_overrides
|
||
.raw_overrides
|
||
.push("web_search=\"live\"".to_string());
|
||
}
|
||
|
||
// When using `--oss`, let the bootstrapper pick the model (defaulting to
|
||
// gpt-oss:20b) and ensure it is present locally. Also, force the built‑in
|
||
let raw_overrides = cli.config_overrides.raw_overrides.clone();
|
||
// `oss` model provider.
|
||
let overrides_cli = codex_utils_cli::CliConfigOverrides { raw_overrides };
|
||
let cli_kv_overrides = match overrides_cli.parse_overrides() {
|
||
// Parse `-c` overrides from the CLI.
|
||
Ok(v) => v,
|
||
#[allow(clippy::print_stderr)]
|
||
Err(e) => {
|
||
eprintln!("Error parsing -c overrides: {e}");
|
||
std::process::exit(1);
|
||
}
|
||
};
|
||
|
||
// we load config.toml here to determine project state.
|
||
#[allow(clippy::print_stderr)]
|
||
let codex_home = match find_codex_home() {
|
||
Ok(codex_home) => codex_home.to_path_buf(),
|
||
Err(err) => {
|
||
eprintln!("Error finding codex home: {err}");
|
||
std::process::exit(1);
|
||
}
|
||
};
|
||
|
||
let mut launch_loader_overrides = loader_overrides.clone();
|
||
if let Some(profile_v2) = cli.config_profile_v2.as_ref() {
|
||
let user_config_path = resolve_profile_v2_config_path(&codex_home, profile_v2);
|
||
launch_loader_overrides.user_config_path = Some(user_config_path);
|
||
launch_loader_overrides.user_config_profile = Some(profile_v2.clone());
|
||
}
|
||
let reuse_implicit_local_daemon = can_reuse_implicit_local_daemon(
|
||
&cli_kv_overrides,
|
||
&launch_loader_overrides,
|
||
strict_config,
|
||
cli.bypass_hook_trust,
|
||
);
|
||
let default_daemon = if explicit_remote_endpoint.is_none() && reuse_implicit_local_daemon {
|
||
maybe_probe_default_daemon_socket(&codex_home).await
|
||
} else {
|
||
None
|
||
};
|
||
let app_server_target = app_server_target_for_launch(
|
||
explicit_remote_endpoint,
|
||
default_daemon,
|
||
reuse_implicit_local_daemon,
|
||
);
|
||
let remote_cwd_override = cli
|
||
.cwd
|
||
.clone()
|
||
.filter(|_| app_server_target.uses_remote_workspace());
|
||
|
||
let local_runtime_paths = ExecServerRuntimePaths::from_optional_paths(
|
||
arg0_paths.codex_self_exe.clone(),
|
||
arg0_paths.codex_linux_sandbox_exe.clone(),
|
||
)?;
|
||
let environment_manager =
|
||
if should_load_configured_environments(&loader_overrides, &app_server_target) {
|
||
EnvironmentManager::from_codex_home(codex_home.clone(), Some(local_runtime_paths)).await
|
||
} else {
|
||
EnvironmentManager::from_env(Some(local_runtime_paths)).await
|
||
}
|
||
.map(Arc::new)
|
||
.map_err(std::io::Error::other)?;
|
||
let cwd = cli.cwd.clone();
|
||
let config_cwd =
|
||
config_cwd_for_app_server_target(cwd.as_deref(), &app_server_target, &environment_manager)?;
|
||
let mut loader_overrides = loader_overrides;
|
||
if let Some(profile_v2) = cli.config_profile_v2.as_ref() {
|
||
let user_config_path = resolve_profile_v2_config_path(&codex_home, profile_v2);
|
||
loader_overrides.user_config_path = Some(user_config_path);
|
||
loader_overrides.user_config_profile = Some(profile_v2.clone());
|
||
}
|
||
|
||
let bootstrap_config = load_bootstrap_config_or_exit(
|
||
&codex_home,
|
||
config_cwd.as_ref(),
|
||
cli_kv_overrides.clone(),
|
||
loader_overrides.clone(),
|
||
strict_config,
|
||
CloudConfigBundleLoader::default(),
|
||
)
|
||
.await;
|
||
let bootstrap_config_toml = &bootstrap_config.config_toml;
|
||
|
||
let chatgpt_base_url = bootstrap_config_toml
|
||
.chatgpt_base_url
|
||
.clone()
|
||
.unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string());
|
||
let auth_route_config = resolve_bootstrap_auth_route_config(
|
||
bootstrap_config_toml,
|
||
bootstrap_config
|
||
.config_layer_stack
|
||
.requirements()
|
||
.feature_requirements
|
||
.as_ref(),
|
||
)?;
|
||
let cloud_config_bundle = cloud_config_bundle_loader_for_storage(
|
||
codex_home.to_path_buf(),
|
||
/*enable_codex_api_key_env*/ false,
|
||
bootstrap_config_toml
|
||
.cli_auth_credentials_store
|
||
.unwrap_or_default(),
|
||
resolve_bootstrap_auth_keyring_backend_kind(&bootstrap_config)?,
|
||
chatgpt_base_url,
|
||
auth_route_config,
|
||
)
|
||
.await;
|
||
|
||
let cwd_override = if app_server_target.uses_remote_workspace() {
|
||
None
|
||
} else {
|
||
cwd.clone()
|
||
};
|
||
|
||
let mut manually_selected_oss_provider = None;
|
||
let model_provider_override = if cli.oss {
|
||
let bootstrap_config_with_cloud_config;
|
||
let config_toml_for_oss = if cli.oss_provider.is_none() {
|
||
// The first load intentionally skips cloud config so we can read
|
||
// auth/base-url settings needed to fetch the bundle. If OSS mode
|
||
// needs a default provider from config, reload with the bundle.
|
||
bootstrap_config_with_cloud_config = load_bootstrap_config_or_exit(
|
||
&codex_home,
|
||
config_cwd.as_ref(),
|
||
cli_kv_overrides.clone(),
|
||
loader_overrides.clone(),
|
||
strict_config,
|
||
cloud_config_bundle.clone(),
|
||
)
|
||
.await;
|
||
&bootstrap_config_with_cloud_config.config_toml
|
||
} else {
|
||
bootstrap_config_toml
|
||
};
|
||
|
||
let resolved = resolve_oss_provider(cli.oss_provider.as_deref(), config_toml_for_oss);
|
||
|
||
if let Some(provider) = resolved {
|
||
Some(provider)
|
||
} else {
|
||
// No provider configured, prompt the user
|
||
let selection = oss_selection::select_oss_provider().await?;
|
||
let provider = selection.provider;
|
||
if provider == "__CANCELLED__" {
|
||
return Err(std::io::Error::other(
|
||
"OSS provider selection was cancelled by user",
|
||
));
|
||
}
|
||
if selection.manually_selected {
|
||
manually_selected_oss_provider = Some(provider.clone());
|
||
}
|
||
Some(provider)
|
||
}
|
||
} else {
|
||
None
|
||
};
|
||
|
||
// When using `--oss`, let the bootstrapper pick the model based on selected provider
|
||
let model = if let Some(model) = &cli.model {
|
||
Some(model.clone())
|
||
} else if cli.oss {
|
||
// Use the provider from model_provider_override
|
||
model_provider_override
|
||
.as_ref()
|
||
.and_then(|provider_id| get_default_model_for_oss_provider(provider_id))
|
||
.map(std::borrow::ToOwned::to_owned)
|
||
} else {
|
||
None // No model specified, will use the default.
|
||
};
|
||
|
||
let additional_dirs = cli.add_dir.clone();
|
||
|
||
let overrides = ConfigOverrides {
|
||
model,
|
||
approval_policy,
|
||
sandbox_mode,
|
||
cwd: cwd_override,
|
||
model_provider: model_provider_override.clone(),
|
||
codex_self_exe: arg0_paths.codex_self_exe.clone(),
|
||
codex_linux_sandbox_exe: arg0_paths.codex_linux_sandbox_exe.clone(),
|
||
main_execve_wrapper_exe: arg0_paths.main_execve_wrapper_exe.clone(),
|
||
show_raw_agent_reasoning: cli.oss.then_some(true),
|
||
bypass_hook_trust: cli.bypass_hook_trust.then_some(true),
|
||
additional_writable_roots: additional_dirs,
|
||
..Default::default()
|
||
};
|
||
|
||
let mut config = load_config_or_exit(
|
||
cli_kv_overrides.clone(),
|
||
overrides.clone(),
|
||
loader_overrides.clone(),
|
||
cloud_config_bundle.clone(),
|
||
strict_config,
|
||
)
|
||
.await;
|
||
|
||
remove_legacy_tui_log_file(config.codex_home.as_path());
|
||
|
||
let otel_originator = originator().value;
|
||
let otel = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||
codex_app_server_client::build_otel_provider(
|
||
&config,
|
||
env!("CARGO_PKG_VERSION"),
|
||
/*service_name_override*/ None,
|
||
/*default_analytics_enabled*/ true,
|
||
)
|
||
})) {
|
||
Ok(Ok(otel)) => otel,
|
||
Ok(Err(e)) => {
|
||
#[allow(clippy::print_stderr)]
|
||
{
|
||
eprintln!("Could not create otel exporter: {e}");
|
||
}
|
||
None
|
||
}
|
||
Err(_) => {
|
||
#[allow(clippy::print_stderr)]
|
||
{
|
||
eprintln!("Could not create otel exporter: panicked during initialization");
|
||
}
|
||
None
|
||
}
|
||
};
|
||
if let Some(metrics) = otel.as_ref().and_then(codex_otel::OtelProvider::metrics) {
|
||
let _ = codex_otel::record_process_start_once(metrics, otel_originator.as_str());
|
||
let telemetry =
|
||
codex_rollout::sqlite_telemetry_recorder(metrics.clone(), otel_originator.as_str());
|
||
let _ = codex_state::install_process_db_telemetry(telemetry);
|
||
}
|
||
let state_db = init_state_db_for_app_server_target(&config, &app_server_target).await?;
|
||
|
||
let effective_toml = config.config_layer_stack.effective_config();
|
||
match effective_toml.try_into() {
|
||
Ok(config_toml) => {
|
||
match codex_app_server_client::migrate_personality_if_needed(
|
||
&config.codex_home,
|
||
&config_toml,
|
||
state_db.clone(),
|
||
)
|
||
.await
|
||
{
|
||
Ok(true) => {
|
||
config = load_config_or_exit(
|
||
cli_kv_overrides.clone(),
|
||
overrides.clone(),
|
||
loader_overrides.clone(),
|
||
cloud_config_bundle.clone(),
|
||
strict_config,
|
||
)
|
||
.await;
|
||
}
|
||
Ok(false) => {}
|
||
Err(err) => {
|
||
tracing::warn!(error = %err, "failed to run personality migration");
|
||
}
|
||
}
|
||
}
|
||
Err(err) => {
|
||
tracing::warn!(error = %err, "failed to deserialize config for personality migration");
|
||
}
|
||
}
|
||
let config_toml_log_dir_configured = config
|
||
.config_layer_stack
|
||
.effective_config()
|
||
.as_table()
|
||
.is_some_and(|table| table.contains_key("log_dir"));
|
||
|
||
#[allow(clippy::print_stderr)]
|
||
match check_execpolicy_for_warnings(&config.config_layer_stack).await {
|
||
Ok(None) => {}
|
||
Ok(Some(err)) | Err(err) => {
|
||
eprintln!(
|
||
"Error loading rules:\n{}",
|
||
format_exec_policy_error_with_source(&err)
|
||
);
|
||
std::process::exit(1);
|
||
}
|
||
}
|
||
|
||
set_default_client_residency_requirement(config.enforce_residency.value());
|
||
|
||
if let Some(warning) = add_dir_warning_message(
|
||
&cli.add_dir,
|
||
&config.permissions.effective_permission_profile(),
|
||
config.cwd.as_path(),
|
||
) {
|
||
#[allow(clippy::print_stderr)]
|
||
{
|
||
eprintln!("Error adding directories: {warning}");
|
||
std::process::exit(1);
|
||
}
|
||
}
|
||
|
||
if !app_server_target.uses_remote_workspace() {
|
||
let auth_route_config = config.auth_route_config();
|
||
#[allow(clippy::print_stderr)]
|
||
if let Err(err) = enforce_login_restrictions(&AuthConfig {
|
||
codex_home: config.codex_home.to_path_buf(),
|
||
auth_credentials_store_mode: config.cli_auth_credentials_store_mode,
|
||
keyring_backend_kind: config.auth_keyring_backend_kind(),
|
||
forced_login_method: config.forced_login_method,
|
||
forced_chatgpt_workspace_id: config.forced_chatgpt_workspace_id.clone(),
|
||
chatgpt_base_url: Some(config.chatgpt_base_url.clone()),
|
||
auth_route_config,
|
||
})
|
||
.await
|
||
{
|
||
eprintln!("{err}");
|
||
std::process::exit(1);
|
||
}
|
||
}
|
||
|
||
let (tui_file_layer, _tui_file_log_guard) = if config_toml_log_dir_configured {
|
||
let log_dir = config.log_dir.clone();
|
||
std::fs::create_dir_all(&log_dir)?;
|
||
let mut log_file_opts = OpenOptions::new();
|
||
log_file_opts.create(true).append(true);
|
||
|
||
// Ensure the file is only readable and writable by the current user.
|
||
// Doing the equivalent to `chmod 600` on Windows is quite a bit more
|
||
// code and requires the Windows API crates.
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::fs::OpenOptionsExt;
|
||
log_file_opts.mode(0o600);
|
||
}
|
||
|
||
let log_file = log_file_opts.open(log_dir.join(TUI_LOG_FILE_NAME))?;
|
||
let (non_blocking, guard) = non_blocking(log_file);
|
||
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
||
EnvFilter::new("codex_core=info,codex_tui=info,codex_rmcp_client=info")
|
||
});
|
||
let file_layer = tracing_subscriber::fmt::layer()
|
||
.with_writer(non_blocking)
|
||
.with_target(true)
|
||
.with_ansi(false)
|
||
.with_span_events(
|
||
tracing_subscriber::fmt::format::FmtSpan::NEW
|
||
| tracing_subscriber::fmt::format::FmtSpan::CLOSE,
|
||
)
|
||
.with_filter(env_filter);
|
||
(Some(file_layer), Some(guard))
|
||
} else {
|
||
(None, None)
|
||
};
|
||
|
||
let feedback = codex_feedback::CodexFeedback::new();
|
||
let feedback_layer = feedback.logger_layer();
|
||
let feedback_metadata_layer = feedback.metadata_layer();
|
||
|
||
if cli.oss && model_provider_override.is_some() {
|
||
// We're in the oss section, so provider_id should be Some
|
||
// Let's handle None case gracefully though just in case
|
||
let provider_id = match model_provider_override.as_ref() {
|
||
Some(id) => id,
|
||
None => {
|
||
error!("OSS provider unexpectedly not set when oss flag is used");
|
||
return Err(std::io::Error::other(
|
||
"OSS provider not set but oss flag was used",
|
||
));
|
||
}
|
||
};
|
||
ensure_oss_provider_ready(provider_id, &config).await?;
|
||
}
|
||
|
||
let otel_logger_layer = otel.as_ref().and_then(|o| o.logger_layer());
|
||
|
||
let otel_tracing_layer = otel.as_ref().and_then(|o| o.tracing_layer());
|
||
|
||
let log_db = state_db.clone().map(log_db::start);
|
||
let log_db_layer = log_db
|
||
.clone()
|
||
.map(|layer| layer.with_filter(log_db::default_filter()));
|
||
|
||
let _ = tracing_subscriber::registry()
|
||
.with(tui_file_layer)
|
||
.with(feedback_layer)
|
||
.with(feedback_metadata_layer)
|
||
.with(log_db_layer)
|
||
.with(otel_logger_layer)
|
||
.with(otel_tracing_layer)
|
||
.try_init();
|
||
|
||
run_ratatui_app(
|
||
cli,
|
||
arg0_paths,
|
||
loader_overrides,
|
||
strict_config,
|
||
app_server_target,
|
||
remote_cwd_override,
|
||
config,
|
||
manually_selected_oss_provider,
|
||
overrides,
|
||
cli_kv_overrides,
|
||
cloud_config_bundle,
|
||
feedback,
|
||
log_db,
|
||
state_db,
|
||
environment_manager,
|
||
)
|
||
.await
|
||
.map_err(|err| std::io::Error::other(err.to_string()))
|
||
}
|
||
|
||
#[allow(clippy::too_many_arguments)]
|
||
async fn run_ratatui_app(
|
||
cli: Cli,
|
||
arg0_paths: Arg0DispatchPaths,
|
||
loader_overrides: LoaderOverrides,
|
||
strict_config: bool,
|
||
app_server_target: AppServerTarget,
|
||
remote_cwd_override: Option<PathBuf>,
|
||
initial_config: Config,
|
||
manually_selected_oss_provider: Option<String>,
|
||
overrides: ConfigOverrides,
|
||
cli_kv_overrides: Vec<(String, toml::Value)>,
|
||
mut cloud_config_bundle: CloudConfigBundleLoader,
|
||
feedback: codex_feedback::CodexFeedback,
|
||
log_db: Option<log_db::LogDbLayer>,
|
||
state_db: Option<StateDbHandle>,
|
||
environment_manager: Arc<EnvironmentManager>,
|
||
) -> color_eyre::Result<AppExitInfo> {
|
||
let uses_remote_workspace = app_server_target.uses_remote_workspace();
|
||
color_eyre::install()?;
|
||
|
||
tooltips::announcement::prewarm();
|
||
|
||
// Forward panic reports through tracing so they appear in the UI status
|
||
// line, but do not swallow the default/color-eyre panic handler.
|
||
// Chain to the previous hook so users still get a rich panic report
|
||
// (including backtraces) after we restore the terminal.
|
||
let prev_hook = std::panic::take_hook();
|
||
std::panic::set_hook(Box::new(move |info| {
|
||
tracing::error!("panic: {info}");
|
||
prev_hook(info);
|
||
}));
|
||
let mut initialized_terminal = tui::init()?;
|
||
initialized_terminal.terminal.clear()?;
|
||
|
||
let mut tui = Tui::new(
|
||
initialized_terminal.terminal,
|
||
initialized_terminal.enhanced_keys_supported,
|
||
initialized_terminal.stderr_guard,
|
||
);
|
||
let mut terminal_restore_guard = TerminalRestoreGuard::new();
|
||
|
||
#[cfg(not(debug_assertions))]
|
||
{
|
||
use crate::update_prompt::UpdatePromptOutcome;
|
||
|
||
let skip_update_prompt = cli.prompt.as_ref().is_some_and(|prompt| !prompt.is_empty());
|
||
if !skip_update_prompt {
|
||
match update_prompt::run_update_prompt_if_needed(&mut tui, &initial_config).await? {
|
||
UpdatePromptOutcome::Continue => {}
|
||
UpdatePromptOutcome::RunUpdate(action) => {
|
||
terminal_restore_guard.restore()?;
|
||
return Ok(AppExitInfo {
|
||
token_usage: crate::token_usage::TokenUsage::default(),
|
||
thread_id: None,
|
||
resume_hint: None,
|
||
update_action: Some(action),
|
||
exit_reason: ExitReason::UserRequested,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Initialize high-fidelity session event logging if enabled.
|
||
session_log::maybe_init(&initial_config);
|
||
|
||
let app_server_session = match start_app_server(
|
||
&app_server_target,
|
||
arg0_paths.clone(),
|
||
initial_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
|
||
{
|
||
Ok(app_server) => AppServerSession::new(app_server, app_server_target.thread_params_mode()),
|
||
Err(err) => {
|
||
terminal_restore_guard.restore_silently();
|
||
session_log::log_session_end();
|
||
return Err(err);
|
||
}
|
||
}
|
||
.with_remote_cwd_override(remote_cwd_override.clone());
|
||
if let Some(provider) = manually_selected_oss_provider.as_deref()
|
||
&& let Err(err) = config_update::write_config_batch(
|
||
app_server_session.request_handle(),
|
||
vec![config_update::build_oss_provider_edit(provider)],
|
||
)
|
||
.await
|
||
{
|
||
warn!(
|
||
%err,
|
||
provider,
|
||
"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 {
|
||
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 should_show_onboarding =
|
||
should_show_onboarding(login_status, &initial_config, should_show_trust_screen_flag);
|
||
|
||
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();
|
||
return Ok(AppExitInfo {
|
||
token_usage: crate::token_usage::TokenUsage::default(),
|
||
thread_id: None,
|
||
resume_hint: None,
|
||
update_action: None,
|
||
exit_reason: ExitReason::UserRequested,
|
||
});
|
||
}
|
||
#[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,
|
||
initial_config.cli_auth_credentials_store_mode,
|
||
initial_config.auth_keyring_backend_kind(),
|
||
initial_config.chatgpt_base_url.clone(),
|
||
initial_config.auth_route_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 mut missing_session_exit = |id_str: &str, action: &str| {
|
||
error!("Error finding conversation path: {id_str}");
|
||
terminal_restore_guard.restore_silently();
|
||
session_log::log_session_end();
|
||
let _ = tui.terminal.clear();
|
||
Ok(AppExitInfo {
|
||
token_usage: crate::token_usage::TokenUsage::default(),
|
||
thread_id: None,
|
||
resume_hint: None,
|
||
update_action: None,
|
||
exit_reason: ExitReason::Fatal(format!(
|
||
"No saved session found with ID {id_str}. Run `codex {action}` without an ID to choose from existing sessions."
|
||
)),
|
||
})
|
||
};
|
||
|
||
let use_fork = cli.fork_picker || cli.fork_last || cli.fork_session_id.is_some();
|
||
let session_selection = if use_fork {
|
||
if let Some(id_str) = cli.fork_session_id.as_deref() {
|
||
let Some(startup_app_server) = app_server.as_mut() else {
|
||
unreachable!("app server should be initialized for --fork <id>");
|
||
};
|
||
match lookup_session_target_with_app_server(startup_app_server, id_str).await? {
|
||
Some(target_session) => resume_picker::SessionSelection::Fork(target_session),
|
||
None => {
|
||
shutdown_app_server_if_present(app_server.take()).await;
|
||
return missing_session_exit(id_str, "fork");
|
||
}
|
||
}
|
||
} else if cli.fork_last {
|
||
let filter_cwd = latest_session_cwd_filter(
|
||
uses_remote_workspace,
|
||
remote_cwd_override.as_deref(),
|
||
&config,
|
||
cli.fork_show_all,
|
||
);
|
||
let Some(app_server) = app_server.as_mut() else {
|
||
unreachable!("app server should be initialized for --fork --last");
|
||
};
|
||
match lookup_latest_session_target_with_app_server(
|
||
app_server, &config, filter_cwd, /*include_non_interactive*/ false,
|
||
)
|
||
.await?
|
||
{
|
||
Some(target_session) => resume_picker::SessionSelection::Fork(target_session),
|
||
None => resume_picker::SessionSelection::StartFresh,
|
||
}
|
||
} else if cli.fork_picker {
|
||
let Some(app_server) = app_server.take() else {
|
||
unreachable!("app server should be initialized for --fork picker");
|
||
};
|
||
match resume_picker::run_fork_picker_with_app_server(
|
||
&mut tui,
|
||
&config,
|
||
cli.fork_show_all,
|
||
app_server,
|
||
)
|
||
.await?
|
||
{
|
||
resume_picker::SessionSelection::Exit => {
|
||
terminal_restore_guard.restore_silently();
|
||
session_log::log_session_end();
|
||
return Ok(AppExitInfo {
|
||
token_usage: crate::token_usage::TokenUsage::default(),
|
||
thread_id: None,
|
||
resume_hint: None,
|
||
update_action: None,
|
||
exit_reason: ExitReason::UserRequested,
|
||
});
|
||
}
|
||
other => other,
|
||
}
|
||
} else {
|
||
resume_picker::SessionSelection::StartFresh
|
||
}
|
||
} else if let Some(id_str) = cli.resume_session_id.as_deref() {
|
||
let Some(startup_app_server) = app_server.as_mut() else {
|
||
unreachable!("app server should be initialized for --resume <id>");
|
||
};
|
||
match lookup_session_target_with_app_server(startup_app_server, id_str).await? {
|
||
Some(target_session) => resume_picker::SessionSelection::Resume(target_session),
|
||
None => {
|
||
shutdown_app_server_if_present(app_server.take()).await;
|
||
return missing_session_exit(id_str, "resume");
|
||
}
|
||
}
|
||
} else if cli.resume_last {
|
||
let filter_cwd = latest_session_cwd_filter(
|
||
uses_remote_workspace,
|
||
remote_cwd_override.as_deref(),
|
||
&config,
|
||
cli.resume_show_all,
|
||
);
|
||
let Some(app_server) = app_server.as_mut() else {
|
||
unreachable!("app server should be initialized for --resume --last");
|
||
};
|
||
match lookup_latest_session_target_with_app_server(
|
||
app_server,
|
||
&config,
|
||
filter_cwd,
|
||
cli.resume_include_non_interactive,
|
||
)
|
||
.await?
|
||
{
|
||
Some(target_session) => resume_picker::SessionSelection::Resume(target_session),
|
||
None => resume_picker::SessionSelection::StartFresh,
|
||
}
|
||
} else if cli.resume_picker {
|
||
let Some(app_server) = app_server.take() else {
|
||
unreachable!("app server should be initialized for --resume picker");
|
||
};
|
||
match resume_picker::run_resume_picker_with_app_server(
|
||
&mut tui,
|
||
&config,
|
||
cli.resume_show_all,
|
||
cli.resume_include_non_interactive,
|
||
app_server,
|
||
)
|
||
.await?
|
||
{
|
||
resume_picker::SessionSelection::Exit => {
|
||
terminal_restore_guard.restore_silently();
|
||
session_log::log_session_end();
|
||
return Ok(AppExitInfo {
|
||
token_usage: crate::token_usage::TokenUsage::default(),
|
||
thread_id: None,
|
||
resume_hint: None,
|
||
update_action: None,
|
||
exit_reason: ExitReason::UserRequested,
|
||
});
|
||
}
|
||
other => other,
|
||
}
|
||
} else {
|
||
resume_picker::SessionSelection::StartFresh
|
||
};
|
||
|
||
let current_cwd = config.cwd.clone();
|
||
let allow_prompt = !uses_remote_workspace && cli.cwd.is_none();
|
||
let action_and_target_session_if_resume_or_fork = match &session_selection {
|
||
resume_picker::SessionSelection::Resume(target_session) => {
|
||
Some((CwdPromptAction::Resume, target_session))
|
||
}
|
||
resume_picker::SessionSelection::Fork(target_session) => {
|
||
Some((CwdPromptAction::Fork, target_session))
|
||
}
|
||
_ => None,
|
||
};
|
||
let fallback_cwd = match action_and_target_session_if_resume_or_fork {
|
||
Some((action, target_session)) => {
|
||
if uses_remote_workspace {
|
||
Some(current_cwd.to_path_buf())
|
||
} else {
|
||
match resolve_cwd_for_resume_or_fork(
|
||
&mut tui,
|
||
state_db.as_deref(),
|
||
¤t_cwd,
|
||
target_session.thread_id,
|
||
target_session.path.as_deref(),
|
||
action,
|
||
allow_prompt,
|
||
)
|
||
.await?
|
||
{
|
||
ResolveCwdOutcome::Continue(cwd) => cwd,
|
||
ResolveCwdOutcome::Exit => {
|
||
terminal_restore_guard.restore_silently();
|
||
session_log::log_session_end();
|
||
return Ok(AppExitInfo {
|
||
token_usage: crate::token_usage::TokenUsage::default(),
|
||
thread_id: None,
|
||
resume_hint: None,
|
||
update_action: None,
|
||
exit_reason: ExitReason::UserRequested,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
None => None,
|
||
};
|
||
|
||
let picker_cancelled_without_selection = matches!(
|
||
session_selection,
|
||
resume_picker::SessionSelection::StartFresh
|
||
) && (cli.resume_picker || cli.fork_picker);
|
||
|
||
let mut config = match &session_selection {
|
||
resume_picker::SessionSelection::Resume(_) | resume_picker::SessionSelection::Fork(_) => {
|
||
load_config_or_exit_with_fallback_cwd(
|
||
cli_kv_overrides.clone(),
|
||
overrides.clone(),
|
||
loader_overrides.clone(),
|
||
cloud_config_bundle.clone(),
|
||
strict_config,
|
||
fallback_cwd,
|
||
)
|
||
.await
|
||
}
|
||
resume_picker::SessionSelection::StartFresh if picker_cancelled_without_selection => {
|
||
load_config_or_exit(
|
||
cli_kv_overrides.clone(),
|
||
overrides.clone(),
|
||
loader_overrides.clone(),
|
||
cloud_config_bundle.clone(),
|
||
strict_config,
|
||
)
|
||
.await
|
||
}
|
||
_ => 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,
|
||
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
|
||
{
|
||
Ok(app_server) => {
|
||
AppServerSession::new(app_server, app_server_target.thread_params_mode())
|
||
.with_remote_cwd_override(remote_cwd_override.clone())
|
||
}
|
||
Err(err) => {
|
||
terminal_restore_guard.restore_silently();
|
||
session_log::log_session_end();
|
||
return Err(err);
|
||
}
|
||
},
|
||
};
|
||
|
||
// 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)
|
||
&& matches!(
|
||
&session_selection,
|
||
resume_picker::SessionSelection::Resume(_)
|
||
);
|
||
let bypass_hook_trust_for_startup_review = config.bypass_hook_trust && !is_persistent_resume;
|
||
let hooks_request_handle = app_server.request_handle();
|
||
let hooks_cwd = config.cwd.to_path_buf();
|
||
let startup_prefetch_started_at = Instant::now();
|
||
let (startup_bootstrap, startup_hooks_entry) = tokio::join!(
|
||
app_server.bootstrap(&config),
|
||
load_startup_hooks_review_entry(hooks_request_handle, hooks_cwd),
|
||
);
|
||
let startup_bootstrap = Some(startup_bootstrap?);
|
||
let startup_elapsed_before_app = startup_prefetch_started_at.elapsed();
|
||
let startup_hooks_browser = match maybe_run_startup_hooks_review(
|
||
&mut app_server,
|
||
&mut tui,
|
||
&config,
|
||
bypass_hook_trust_for_startup_review,
|
||
startup_hooks_entry,
|
||
)
|
||
.await?
|
||
{
|
||
StartupHooksReviewOutcome::Continue => None,
|
||
StartupHooksReviewOutcome::OpenHooksBrowser(data) => Some(data),
|
||
};
|
||
|
||
let app_result = App::run(
|
||
&mut tui,
|
||
app_server,
|
||
config,
|
||
cli_kv_overrides.clone(),
|
||
overrides.clone(),
|
||
loader_overrides.clone(),
|
||
cloud_config_bundle,
|
||
prompt,
|
||
images,
|
||
session_selection,
|
||
feedback,
|
||
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,
|
||
environment_manager,
|
||
startup_elapsed_before_app,
|
||
startup_bootstrap,
|
||
startup_hooks_browser,
|
||
)
|
||
.await;
|
||
|
||
terminal_restore_guard.restore_silently();
|
||
// Mark the end of the recorded session.
|
||
session_log::log_session_end();
|
||
// ignore error when collecting usage – report underlying error instead
|
||
app_result
|
||
}
|
||
|
||
#[expect(
|
||
clippy::print_stderr,
|
||
reason = "TUI should no longer be displayed, so we can write to stderr."
|
||
)]
|
||
fn restore() {
|
||
if let Err(err) = tui::restore_after_exit() {
|
||
eprintln!(
|
||
"failed to restore terminal. Run `reset` or restart your terminal to recover: {err}"
|
||
);
|
||
}
|
||
}
|
||
|
||
struct TerminalRestoreGuard {
|
||
active: bool,
|
||
}
|
||
|
||
impl TerminalRestoreGuard {
|
||
fn new() -> Self {
|
||
Self { active: true }
|
||
}
|
||
|
||
#[cfg_attr(debug_assertions, allow(dead_code))]
|
||
fn restore(&mut self) -> color_eyre::Result<()> {
|
||
if self.active {
|
||
crate::tui::restore_after_exit()?;
|
||
self.active = false;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn restore_silently(&mut self) {
|
||
if self.active {
|
||
restore();
|
||
self.active = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Drop for TerminalRestoreGuard {
|
||
fn drop(&mut self) {
|
||
self.restore_silently();
|
||
}
|
||
}
|
||
|
||
/// Determine whether to use the terminal's alternate screen buffer.
|
||
///
|
||
/// - If `--no-alt-screen` is explicitly passed, always disable alternate screen
|
||
/// - Otherwise, respect the `tui.alternate_screen` config setting:
|
||
/// - `always`: Use alternate screen
|
||
/// - `never`: Inline mode only, preserves scrollback
|
||
/// - `auto` (default): Use alternate screen
|
||
fn determine_alt_screen_mode(no_alt_screen: bool, tui_alternate_screen: AltScreenMode) -> bool {
|
||
if no_alt_screen {
|
||
return false;
|
||
}
|
||
|
||
tui_alternate_screen != AltScreenMode::Never
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum LoginStatus {
|
||
AuthMode(AppServerAuthMode),
|
||
NotAuthenticated,
|
||
}
|
||
|
||
/// Determines the user's authentication mode using a lightweight account read
|
||
/// rather than a full `bootstrap`, avoiding the model-list fetch and
|
||
/// rate-limit round-trip that `bootstrap` would trigger.
|
||
async fn get_login_status(
|
||
app_server: &mut AppServerSession,
|
||
config: &Config,
|
||
) -> color_eyre::Result<LoginStatus> {
|
||
if !config.model_provider.requires_openai_auth {
|
||
return Ok(LoginStatus::NotAuthenticated);
|
||
}
|
||
|
||
let account = app_server.read_account().await?;
|
||
Ok(match account.account {
|
||
Some(AppServerAccount::ApiKey {}) => LoginStatus::AuthMode(AppServerAuthMode::ApiKey),
|
||
Some(AppServerAccount::Chatgpt { .. }) => LoginStatus::AuthMode(AppServerAuthMode::Chatgpt),
|
||
Some(AppServerAccount::AmazonBedrock { .. }) => LoginStatus::NotAuthenticated,
|
||
None => LoginStatus::NotAuthenticated,
|
||
})
|
||
}
|
||
|
||
async fn load_config_or_exit(
|
||
cli_kv_overrides: Vec<(String, toml::Value)>,
|
||
overrides: ConfigOverrides,
|
||
loader_overrides: LoaderOverrides,
|
||
cloud_config_bundle: CloudConfigBundleLoader,
|
||
strict_config: bool,
|
||
) -> Config {
|
||
load_config_or_exit_with_fallback_cwd(
|
||
cli_kv_overrides,
|
||
overrides,
|
||
loader_overrides,
|
||
cloud_config_bundle,
|
||
strict_config,
|
||
/*fallback_cwd*/ None,
|
||
)
|
||
.await
|
||
}
|
||
|
||
async fn load_config_or_exit_with_fallback_cwd(
|
||
cli_kv_overrides: Vec<(String, toml::Value)>,
|
||
overrides: ConfigOverrides,
|
||
loader_overrides: LoaderOverrides,
|
||
cloud_config_bundle: CloudConfigBundleLoader,
|
||
strict_config: bool,
|
||
fallback_cwd: Option<PathBuf>,
|
||
) -> Config {
|
||
#[allow(clippy::print_stderr)]
|
||
match ConfigBuilder::default()
|
||
.cli_overrides(cli_kv_overrides)
|
||
.harness_overrides(overrides)
|
||
.loader_overrides(loader_overrides)
|
||
.strict_config(strict_config)
|
||
.cloud_config_bundle(cloud_config_bundle)
|
||
.fallback_cwd(fallback_cwd)
|
||
.build()
|
||
.await
|
||
{
|
||
Ok(config) => config,
|
||
Err(err) => {
|
||
eprintln!("Error loading configuration: {err}");
|
||
std::process::exit(1);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[allow(clippy::print_stderr)]
|
||
async fn load_bootstrap_config_or_exit(
|
||
codex_home: &Path,
|
||
cwd: Option<&AbsolutePathBuf>,
|
||
cli_kv_overrides: Vec<(String, codex_config::TomlValue)>,
|
||
loader_overrides: LoaderOverrides,
|
||
strict_config: bool,
|
||
cloud_config_bundle: CloudConfigBundleLoader,
|
||
) -> ConfigTomlLoadResult {
|
||
match load_config_toml_with_layer_stack(
|
||
codex_home,
|
||
cwd,
|
||
cli_kv_overrides,
|
||
codex_config::ConfigLoadOptions {
|
||
loader_overrides,
|
||
strict_config,
|
||
cloud_config_bundle,
|
||
},
|
||
)
|
||
.await
|
||
{
|
||
Ok(config_toml) => config_toml,
|
||
Err(err) => {
|
||
let config_error = err
|
||
.get_ref()
|
||
.and_then(|err| err.downcast_ref::<ConfigLoadError>())
|
||
.map(ConfigLoadError::config_error);
|
||
if let Some(config_error) = config_error {
|
||
eprintln!(
|
||
"Error loading config.toml:\n{}",
|
||
format_config_error_with_source(config_error)
|
||
);
|
||
} else {
|
||
eprintln!("Error loading config.toml: {err}");
|
||
}
|
||
std::process::exit(1);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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()
|
||
}
|
||
|
||
fn should_show_onboarding(
|
||
login_status: LoginStatus,
|
||
config: &Config,
|
||
show_trust_screen: bool,
|
||
) -> bool {
|
||
if show_trust_screen {
|
||
return true;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
login_status == LoginStatus::NotAuthenticated
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::legacy_core::config::ConfigBuilder;
|
||
use crate::legacy_core::config::ConfigOverrides;
|
||
use codex_app_server_protocol::AskForApproval;
|
||
use codex_app_server_protocol::ClientRequest;
|
||
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 pretty_assertions::assert_eq;
|
||
use serial_test::serial;
|
||
use tempfile::TempDir;
|
||
|
||
async fn build_config(temp_dir: &TempDir) -> std::io::Result<Config> {
|
||
ConfigBuilder::default()
|
||
.codex_home(temp_dir.path().to_path_buf())
|
||
.build()
|
||
.await
|
||
}
|
||
|
||
fn write_session_rollout(
|
||
codex_home: &Path,
|
||
filename_ts: &str,
|
||
meta_rfc3339: &str,
|
||
preview: &str,
|
||
model_provider: &str,
|
||
cwd: &Path,
|
||
) -> color_eyre::Result<ThreadId> {
|
||
let uuid = Uuid::new_v4();
|
||
let uuid_str = uuid.to_string();
|
||
let thread_id = ThreadId::from_string(&uuid_str)?;
|
||
let year = &filename_ts[0..4];
|
||
let month = &filename_ts[5..7];
|
||
let day = &filename_ts[8..10];
|
||
let rollout_path = codex_home
|
||
.join("sessions")
|
||
.join(year)
|
||
.join(month)
|
||
.join(day)
|
||
.join(format!("rollout-{filename_ts}-{uuid_str}.jsonl"));
|
||
let parent = rollout_path
|
||
.parent()
|
||
.ok_or_else(|| color_eyre::eyre::eyre!("rollout path is missing a parent directory"))?;
|
||
std::fs::create_dir_all(parent)?;
|
||
|
||
let session_meta = codex_protocol::protocol::SessionMeta {
|
||
session_id: thread_id.into(),
|
||
id: thread_id,
|
||
timestamp: meta_rfc3339.to_string(),
|
||
cwd: cwd.to_path_buf(),
|
||
originator: "codex".to_string(),
|
||
cli_version: "0.0.0".to_string(),
|
||
source: codex_protocol::protocol::SessionSource::Cli,
|
||
model_provider: Some(model_provider.to_string()),
|
||
..Default::default()
|
||
};
|
||
let session_meta = serde_json::to_value(codex_protocol::protocol::SessionMetaLine {
|
||
meta: session_meta,
|
||
git: None,
|
||
})?;
|
||
let lines = [
|
||
serde_json::json!({
|
||
"timestamp": meta_rfc3339,
|
||
"type": "session_meta",
|
||
"payload": session_meta,
|
||
})
|
||
.to_string(),
|
||
serde_json::json!({
|
||
"timestamp": meta_rfc3339,
|
||
"type": "response_item",
|
||
"payload": {
|
||
"type": "message",
|
||
"role": "user",
|
||
"content": [{"type": "input_text", "text": preview}],
|
||
},
|
||
})
|
||
.to_string(),
|
||
serde_json::json!({
|
||
"timestamp": meta_rfc3339,
|
||
"type": "event_msg",
|
||
"payload": {
|
||
"type": "user_message",
|
||
"message": preview,
|
||
"kind": "plain",
|
||
},
|
||
})
|
||
.to_string(),
|
||
];
|
||
std::fs::write(&rollout_path, lines.join("\n") + "\n")?;
|
||
let updated_at =
|
||
chrono::DateTime::parse_from_rfc3339(meta_rfc3339)?.with_timezone(&chrono::Utc);
|
||
let times = std::fs::FileTimes::new().set_modified(updated_at.into());
|
||
std::fs::OpenOptions::new()
|
||
.append(true)
|
||
.open(rollout_path)?
|
||
.set_times(times)?;
|
||
|
||
Ok(thread_id)
|
||
}
|
||
|
||
#[test]
|
||
fn startup_removes_legacy_tui_log_file() -> std::io::Result<()> {
|
||
let temp_dir = TempDir::new()?;
|
||
let legacy_log_dir = temp_dir.path().join("log");
|
||
std::fs::create_dir_all(&legacy_log_dir)?;
|
||
let legacy_log = legacy_log_dir.join(TUI_LOG_FILE_NAME);
|
||
std::fs::write(&legacy_log, "legacy log")?;
|
||
|
||
remove_legacy_tui_log_file(temp_dir.path());
|
||
|
||
assert!(!legacy_log.exists());
|
||
Ok(())
|
||
}
|
||
|
||
async fn start_test_embedded_app_server(
|
||
config: Config,
|
||
) -> color_eyre::Result<InProcessAppServerClient> {
|
||
let state_db =
|
||
init_state_db_for_app_server_target(&config, &AppServerTarget::Embedded).await?;
|
||
start_embedded_app_server(
|
||
Arg0DispatchPaths::default(),
|
||
config,
|
||
Vec::new(),
|
||
LoaderOverrides::default(),
|
||
/*strict_config*/ false,
|
||
CloudConfigBundleLoader::default(),
|
||
codex_feedback::CodexFeedback::new(),
|
||
/*log_db*/ None,
|
||
state_db,
|
||
Arc::new(EnvironmentManager::default_for_tests()),
|
||
)
|
||
.await
|
||
}
|
||
|
||
#[test]
|
||
fn alternate_screen_auto_uses_alt_screen() {
|
||
assert!(determine_alt_screen_mode(
|
||
/*no_alt_screen*/ false,
|
||
AltScreenMode::Auto,
|
||
));
|
||
assert!(determine_alt_screen_mode(
|
||
/*no_alt_screen*/ false,
|
||
AltScreenMode::Always,
|
||
));
|
||
assert!(!determine_alt_screen_mode(
|
||
/*no_alt_screen*/ false,
|
||
AltScreenMode::Never,
|
||
));
|
||
assert!(!determine_alt_screen_mode(
|
||
/*no_alt_screen*/ true,
|
||
AltScreenMode::Auto,
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn session_target_display_label_falls_back_to_thread_id() {
|
||
let thread_id = ThreadId::new();
|
||
let target = crate::resume_picker::SessionTarget {
|
||
path: None,
|
||
thread_id,
|
||
};
|
||
|
||
assert_eq!(target.display_label(), format!("thread {thread_id}"));
|
||
}
|
||
|
||
#[test]
|
||
fn resolve_remote_addr_accepts_websocket_url() {
|
||
assert_eq!(
|
||
resolve_remote_addr("ws://127.0.0.1:4500").expect("ws URL should normalize"),
|
||
RemoteAppServerEndpoint::WebSocket {
|
||
websocket_url: "ws://127.0.0.1:4500/".to_string(),
|
||
auth_token: None,
|
||
}
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn resolve_remote_addr_accepts_secure_websocket_url() {
|
||
assert_eq!(
|
||
resolve_remote_addr("wss://example.com:443").expect("wss URL should normalize"),
|
||
RemoteAppServerEndpoint::WebSocket {
|
||
websocket_url: "wss://example.com/".to_string(),
|
||
auth_token: None,
|
||
}
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn resolve_remote_addr_accepts_default_socket() -> color_eyre::Result<()> {
|
||
let codex_home = find_codex_home().wrap_err("failed to resolve CODEX_HOME")?;
|
||
assert_eq!(
|
||
resolve_remote_addr("unix://")?,
|
||
RemoteAppServerEndpoint::UnixSocket {
|
||
socket_path: codex_app_server_client::app_server_control_socket_path(&codex_home)?,
|
||
}
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn resolve_remote_addr_accepts_relative_socket_path() -> color_eyre::Result<()> {
|
||
assert_eq!(
|
||
resolve_remote_addr("unix://codex.sock")?,
|
||
RemoteAppServerEndpoint::UnixSocket {
|
||
socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock")?,
|
||
}
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn resolve_remote_addr_accepts_absolute_socket_path() -> color_eyre::Result<()> {
|
||
let temp_dir = TempDir::new()?;
|
||
let socket_path = temp_dir.path().join("codex.sock");
|
||
assert_eq!(
|
||
resolve_remote_addr(&format!("unix://{}", socket_path.display()))?,
|
||
RemoteAppServerEndpoint::UnixSocket {
|
||
socket_path: AbsolutePathBuf::from_absolute_path(&socket_path)?,
|
||
}
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn resolve_remote_addr_rejects_invalid_remote_addresses() {
|
||
for addr in [
|
||
"ws://127.0.0.1",
|
||
"wss://example.com",
|
||
"127.0.0.1:4500",
|
||
"https://127.0.0.1:4500",
|
||
] {
|
||
let err = resolve_remote_addr(addr).expect_err("invalid remote addresses should fail");
|
||
assert!(err.to_string().contains(
|
||
"expected `ws://host:port`, `wss://host:port`, `unix://`, or `unix://PATH`"
|
||
));
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn default_daemon_auto_connect_skips_missing_socket() -> color_eyre::Result<()> {
|
||
let codex_home = TempDir::new()?;
|
||
assert!(
|
||
maybe_probe_default_daemon_socket(codex_home.path())
|
||
.await
|
||
.is_none()
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[tokio::test]
|
||
async fn default_daemon_auto_connect_probes_socket_only() -> color_eyre::Result<()> {
|
||
let codex_home = TempDir::new()?;
|
||
let socket_path =
|
||
codex_app_server_client::app_server_control_socket_path(codex_home.path())?;
|
||
std::fs::create_dir_all(socket_path.as_path().parent().expect("socket parent"))?;
|
||
let _listener = tokio::net::UnixListener::bind(socket_path.as_path())?;
|
||
|
||
assert_eq!(
|
||
maybe_probe_default_daemon_socket(codex_home.path()).await,
|
||
Some(socket_path)
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn app_server_target_for_launch_uses_local_daemon_for_default_socket() -> color_eyre::Result<()>
|
||
{
|
||
let socket_path = AbsolutePathBuf::relative_to_current_dir("codex.sock")?;
|
||
let target = app_server_target_for_launch(
|
||
/*explicit_remote_endpoint*/ None,
|
||
Some(socket_path.clone()),
|
||
/*can_reuse_implicit_local_daemon*/ true,
|
||
);
|
||
|
||
assert_eq!(
|
||
target,
|
||
AppServerTarget::LocalDaemon {
|
||
endpoint: RemoteAppServerEndpoint::UnixSocket { socket_path },
|
||
}
|
||
);
|
||
assert!(!target.uses_remote_workspace());
|
||
assert_eq!(target.thread_params_mode(), ThreadParamsMode::Embedded);
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn app_server_target_for_launch_prefers_explicit_remote_endpoint() -> color_eyre::Result<()> {
|
||
let explicit_endpoint = RemoteAppServerEndpoint::UnixSocket {
|
||
socket_path: AbsolutePathBuf::relative_to_current_dir("explicit.sock")?,
|
||
};
|
||
let target = app_server_target_for_launch(
|
||
Some(explicit_endpoint.clone()),
|
||
Some(AbsolutePathBuf::relative_to_current_dir("default.sock")?),
|
||
/*can_reuse_implicit_local_daemon*/ false,
|
||
);
|
||
|
||
assert_eq!(
|
||
target,
|
||
AppServerTarget::Remote {
|
||
endpoint: explicit_endpoint,
|
||
}
|
||
);
|
||
assert!(target.uses_remote_workspace());
|
||
assert_eq!(target.thread_params_mode(), ThreadParamsMode::Remote);
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn app_server_target_for_launch_skips_local_daemon_when_launch_config_is_not_replayable()
|
||
-> color_eyre::Result<()> {
|
||
let socket_path = AbsolutePathBuf::relative_to_current_dir("codex.sock")?;
|
||
let target = app_server_target_for_launch(
|
||
/*explicit_remote_endpoint*/ None,
|
||
Some(socket_path),
|
||
/*can_reuse_implicit_local_daemon*/ false,
|
||
);
|
||
|
||
assert_eq!(target, AppServerTarget::Embedded);
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn can_reuse_implicit_local_daemon_requires_default_launch_config() -> color_eyre::Result<()> {
|
||
let mut loader_overrides = LoaderOverrides::default();
|
||
let cli_kv_overrides = vec![("web_search".to_string(), toml::Value::String("live".into()))];
|
||
|
||
assert!(can_reuse_implicit_local_daemon(
|
||
&[],
|
||
&LoaderOverrides::default(),
|
||
/*strict_config*/ false,
|
||
/*has_non_replayable_launch_overrides*/ false,
|
||
));
|
||
assert!(!can_reuse_implicit_local_daemon(
|
||
&cli_kv_overrides,
|
||
&LoaderOverrides::default(),
|
||
/*strict_config*/ false,
|
||
/*has_non_replayable_launch_overrides*/ false,
|
||
));
|
||
loader_overrides.ignore_user_config = true;
|
||
assert!(!can_reuse_implicit_local_daemon(
|
||
&[],
|
||
&loader_overrides,
|
||
/*strict_config*/ false,
|
||
/*has_non_replayable_launch_overrides*/ false,
|
||
));
|
||
assert!(!can_reuse_implicit_local_daemon(
|
||
&[],
|
||
&LoaderOverrides::default(),
|
||
/*strict_config*/ true,
|
||
/*has_non_replayable_launch_overrides*/ false,
|
||
));
|
||
assert!(!can_reuse_implicit_local_daemon(
|
||
&[],
|
||
&LoaderOverrides::default(),
|
||
/*strict_config*/ false,
|
||
/*has_non_replayable_launch_overrides*/ true,
|
||
));
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn should_load_configured_environments_for_local_daemon() -> color_eyre::Result<()> {
|
||
let target = AppServerTarget::LocalDaemon {
|
||
endpoint: RemoteAppServerEndpoint::UnixSocket {
|
||
socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock")?,
|
||
},
|
||
};
|
||
|
||
assert!(should_load_configured_environments(
|
||
&LoaderOverrides::default(),
|
||
&target,
|
||
));
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn latest_session_lookup_params_keep_local_filters_for_embedded_sessions()
|
||
-> std::io::Result<()> {
|
||
let temp_dir = TempDir::new()?;
|
||
let config = build_config(&temp_dir).await?;
|
||
let cwd = temp_dir.path().join("project");
|
||
|
||
let params = latest_session_lookup_params(
|
||
/*uses_remote_workspace*/ false,
|
||
&config,
|
||
Some(cwd.as_path()),
|
||
/*include_non_interactive*/ false,
|
||
LatestSessionLookupMode::StateDbOnly,
|
||
);
|
||
|
||
assert_eq!(
|
||
params.model_providers,
|
||
Some(vec![config.model_provider_id.clone()])
|
||
);
|
||
assert_eq!(
|
||
params.cwd,
|
||
Some(ThreadListCwdFilter::One(cwd.to_string_lossy().to_string()))
|
||
);
|
||
assert!(params.use_state_db_only);
|
||
|
||
let scan_params = latest_session_lookup_params(
|
||
/*uses_remote_workspace*/ false,
|
||
&config,
|
||
Some(cwd.as_path()),
|
||
/*include_non_interactive*/ false,
|
||
LatestSessionLookupMode::ScanAndRepair,
|
||
);
|
||
assert!(!scan_params.use_state_db_only);
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn latest_session_lookup_params_keep_local_filters_for_local_daemon_sessions()
|
||
-> color_eyre::Result<()> {
|
||
let temp_dir = TempDir::new()?;
|
||
let config = build_config(&temp_dir).await?;
|
||
let cwd = temp_dir.path().join("project");
|
||
let target = AppServerTarget::LocalDaemon {
|
||
endpoint: RemoteAppServerEndpoint::UnixSocket {
|
||
socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock")?,
|
||
},
|
||
};
|
||
|
||
let params = latest_session_lookup_params(
|
||
target.uses_remote_workspace(),
|
||
&config,
|
||
Some(cwd.as_path()),
|
||
/*include_non_interactive*/ false,
|
||
LatestSessionLookupMode::StateDbOnly,
|
||
);
|
||
|
||
assert_eq!(params.model_providers, Some(vec![config.model_provider_id]));
|
||
assert_eq!(
|
||
params.cwd,
|
||
Some(ThreadListCwdFilter::One(cwd.to_string_lossy().to_string()))
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn latest_session_lookup_params_omit_local_filters_for_remote_sessions()
|
||
-> std::io::Result<()> {
|
||
let temp_dir = TempDir::new()?;
|
||
let config = build_config(&temp_dir).await?;
|
||
|
||
let params = latest_session_lookup_params(
|
||
/*uses_remote_workspace*/ true,
|
||
&config,
|
||
/*cwd_filter*/ None,
|
||
/*include_non_interactive*/ false,
|
||
LatestSessionLookupMode::StateDbOnly,
|
||
);
|
||
|
||
assert_eq!(params.model_providers, None);
|
||
assert_eq!(params.cwd, None);
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn latest_session_lookup_params_can_include_non_interactive_sources()
|
||
-> std::io::Result<()> {
|
||
let temp_dir = TempDir::new()?;
|
||
let config = build_config(&temp_dir).await?;
|
||
|
||
let params = latest_session_lookup_params(
|
||
/*uses_remote_workspace*/ true,
|
||
&config,
|
||
/*cwd_filter*/ None,
|
||
/*include_non_interactive*/ true,
|
||
LatestSessionLookupMode::StateDbOnly,
|
||
);
|
||
|
||
assert_eq!(
|
||
params.source_kinds,
|
||
Some(vec![
|
||
ThreadSourceKind::Cli,
|
||
ThreadSourceKind::VsCode,
|
||
ThreadSourceKind::Exec,
|
||
ThreadSourceKind::AppServer,
|
||
])
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn latest_session_lookup_params_keep_explicit_cwd_filter_for_remote_sessions()
|
||
-> std::io::Result<()> {
|
||
let temp_dir = TempDir::new()?;
|
||
let config = build_config(&temp_dir).await?;
|
||
let cwd = Path::new("repo/on/server");
|
||
|
||
let params = latest_session_lookup_params(
|
||
/*uses_remote_workspace*/ true,
|
||
&config,
|
||
Some(cwd),
|
||
/*include_non_interactive*/ false,
|
||
LatestSessionLookupMode::StateDbOnly,
|
||
);
|
||
|
||
assert_eq!(params.model_providers, None);
|
||
assert_eq!(
|
||
params.cwd,
|
||
Some(ThreadListCwdFilter::One(String::from("repo/on/server")))
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn latest_session_cwd_filter_respects_scope_options() -> std::io::Result<()> {
|
||
let temp_dir = TempDir::new()?;
|
||
let config = build_config(&temp_dir).await?;
|
||
let remote_cwd = Path::new("repo/on/server");
|
||
|
||
let local_filter = latest_session_cwd_filter(
|
||
/*uses_remote_workspace*/ false, /*remote_cwd_override*/ None, &config,
|
||
/*show_all*/ false,
|
||
);
|
||
let show_all_filter = latest_session_cwd_filter(
|
||
/*uses_remote_workspace*/ false, /*remote_cwd_override*/ None, &config,
|
||
/*show_all*/ true,
|
||
);
|
||
let remote_filter = latest_session_cwd_filter(
|
||
/*uses_remote_workspace*/ true,
|
||
Some(remote_cwd),
|
||
&config,
|
||
/*show_all*/ false,
|
||
);
|
||
|
||
assert_eq!(local_filter, Some(config.cwd.as_path()));
|
||
assert_eq!(show_all_filter, None);
|
||
assert_eq!(remote_filter, Some(remote_cwd));
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn fork_last_filters_latest_session_by_cwd_unless_show_all() -> color_eyre::Result<()> {
|
||
let temp_dir = TempDir::new()?;
|
||
let project_cwd = temp_dir.path().join("project");
|
||
let other_cwd = temp_dir.path().join("other-project");
|
||
std::fs::create_dir_all(&project_cwd)?;
|
||
std::fs::create_dir_all(&other_cwd)?;
|
||
|
||
let config = ConfigBuilder::default()
|
||
.codex_home(temp_dir.path().to_path_buf())
|
||
.harness_overrides(ConfigOverrides {
|
||
cwd: Some(project_cwd.clone()),
|
||
..Default::default()
|
||
})
|
||
.build()
|
||
.await?;
|
||
let model_provider = config.model_provider_id.as_str();
|
||
let project_thread_id = write_session_rollout(
|
||
temp_dir.path(),
|
||
"2025-01-02T10-00-00",
|
||
"2025-01-02T10:00:00Z",
|
||
"older project session",
|
||
model_provider,
|
||
&project_cwd,
|
||
)?;
|
||
let other_thread_id = write_session_rollout(
|
||
temp_dir.path(),
|
||
"2025-01-02T12-00-00",
|
||
"2025-01-02T12:00:00Z",
|
||
"newer other project session",
|
||
model_provider,
|
||
&other_cwd,
|
||
)?;
|
||
|
||
let mut app_server = AppServerSession::new(
|
||
codex_app_server_client::AppServerClient::InProcess(
|
||
start_test_embedded_app_server(config.clone()).await?,
|
||
),
|
||
ThreadParamsMode::Embedded,
|
||
);
|
||
let filter_cwd = latest_session_cwd_filter(
|
||
/*uses_remote_workspace*/ false, /*remote_cwd_override*/ None, &config,
|
||
/*show_all*/ false,
|
||
);
|
||
let scoped_target = lookup_latest_session_target_with_app_server(
|
||
&mut app_server,
|
||
&config,
|
||
filter_cwd,
|
||
/*include_non_interactive*/ false,
|
||
)
|
||
.await?
|
||
.expect("expected project-scoped fork --last target");
|
||
let show_all_filter_cwd = latest_session_cwd_filter(
|
||
/*uses_remote_workspace*/ false, /*remote_cwd_override*/ None, &config,
|
||
/*show_all*/ true,
|
||
);
|
||
let show_all_target = lookup_latest_session_target_with_app_server(
|
||
&mut app_server,
|
||
&config,
|
||
show_all_filter_cwd,
|
||
/*include_non_interactive*/ false,
|
||
)
|
||
.await?
|
||
.expect("expected global fork --last target");
|
||
app_server.shutdown().await?;
|
||
|
||
assert_eq!(scoped_target.thread_id, project_thread_id);
|
||
assert_eq!(show_all_target.thread_id, other_thread_id);
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn latest_session_lookup_falls_back_for_rollout_missing_from_state_db()
|
||
-> color_eyre::Result<()> {
|
||
let temp_dir = TempDir::new()?;
|
||
let project_cwd = temp_dir.path().join("project");
|
||
std::fs::create_dir_all(&project_cwd)?;
|
||
let config = ConfigBuilder::default()
|
||
.codex_home(temp_dir.path().to_path_buf())
|
||
.harness_overrides(ConfigOverrides {
|
||
cwd: Some(project_cwd.clone()),
|
||
..Default::default()
|
||
})
|
||
.build()
|
||
.await?;
|
||
let mut app_server = AppServerSession::new(
|
||
codex_app_server_client::AppServerClient::InProcess(
|
||
start_test_embedded_app_server(config.clone()).await?,
|
||
),
|
||
ThreadParamsMode::Embedded,
|
||
);
|
||
|
||
// Simulate a legacy writer creating a rollout after the state DB backfill completed.
|
||
let thread_id = write_session_rollout(
|
||
temp_dir.path(),
|
||
"2025-01-02T10-00-00",
|
||
"2025-01-02T10:00:00Z",
|
||
"legacy writer session",
|
||
config.model_provider_id.as_str(),
|
||
&project_cwd,
|
||
)?;
|
||
|
||
let target = lookup_latest_session_target_with_app_server(
|
||
&mut app_server,
|
||
&config,
|
||
Some(project_cwd.as_path()),
|
||
/*include_non_interactive*/ false,
|
||
)
|
||
.await?
|
||
.expect("expected scan-and-repair fallback to find the rollout");
|
||
app_server.shutdown().await?;
|
||
|
||
assert_eq!(target.thread_id, thread_id);
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn config_cwd_for_app_server_target_omits_cwd_for_remote_sessions() -> std::io::Result<()>
|
||
{
|
||
let remote_only_cwd = if cfg!(windows) {
|
||
Path::new(r"C:\definitely\not\local\to\this\test")
|
||
} else {
|
||
Path::new("/definitely/not/local/to/this/test")
|
||
};
|
||
let target = AppServerTarget::Remote {
|
||
endpoint: RemoteAppServerEndpoint::UnixSocket {
|
||
socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock")?,
|
||
},
|
||
};
|
||
let environment_manager = EnvironmentManager::default_for_tests();
|
||
|
||
let config_cwd =
|
||
config_cwd_for_app_server_target(Some(remote_only_cwd), &target, &environment_manager)?;
|
||
|
||
assert_eq!(config_cwd, None);
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn config_cwd_for_app_server_target_canonicalizes_embedded_cli_cwd() -> std::io::Result<()>
|
||
{
|
||
let temp_dir = TempDir::new()?;
|
||
let target = AppServerTarget::Embedded;
|
||
let environment_manager = EnvironmentManager::default_for_tests();
|
||
|
||
let config_cwd =
|
||
config_cwd_for_app_server_target(Some(temp_dir.path()), &target, &environment_manager)?;
|
||
|
||
assert_eq!(
|
||
config_cwd,
|
||
Some(AbsolutePathBuf::from_absolute_path(dunce::canonicalize(
|
||
temp_dir.path()
|
||
)?)?)
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn config_cwd_for_app_server_target_canonicalizes_local_daemon_cli_cwd()
|
||
-> std::io::Result<()> {
|
||
let temp_dir = TempDir::new()?;
|
||
let target = AppServerTarget::LocalDaemon {
|
||
endpoint: RemoteAppServerEndpoint::UnixSocket {
|
||
socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock")?,
|
||
},
|
||
};
|
||
let environment_manager = EnvironmentManager::default_for_tests();
|
||
|
||
let config_cwd =
|
||
config_cwd_for_app_server_target(Some(temp_dir.path()), &target, &environment_manager)?;
|
||
|
||
assert_eq!(
|
||
config_cwd,
|
||
Some(AbsolutePathBuf::from_absolute_path(dunce::canonicalize(
|
||
temp_dir.path()
|
||
)?)?)
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn config_cwd_for_app_server_target_errors_for_missing_embedded_cli_cwd()
|
||
-> std::io::Result<()> {
|
||
let temp_dir = TempDir::new()?;
|
||
let missing = temp_dir.path().join("missing");
|
||
let target = AppServerTarget::Embedded;
|
||
let environment_manager = EnvironmentManager::default_for_tests();
|
||
|
||
let err = config_cwd_for_app_server_target(Some(&missing), &target, &environment_manager)
|
||
.expect_err("missing embedded cwd should fail");
|
||
|
||
assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn config_cwd_for_app_server_target_omits_cwd_for_remote_exec_server()
|
||
-> std::io::Result<()> {
|
||
let remote_only_cwd = if cfg!(windows) {
|
||
Path::new(r"C:\definitely\not\local\to\this\test")
|
||
} else {
|
||
Path::new("/definitely/not/local/to/this/test")
|
||
};
|
||
let target = AppServerTarget::Embedded;
|
||
let environment_manager = EnvironmentManager::create_for_tests(
|
||
Some("ws://127.0.0.1:8765".to_string()),
|
||
Some(ExecServerRuntimePaths::new(
|
||
std::env::current_exe().expect("current exe"),
|
||
/*codex_linux_sandbox_exe*/ None,
|
||
)?),
|
||
)
|
||
.await;
|
||
|
||
let config_cwd =
|
||
config_cwd_for_app_server_target(Some(remote_only_cwd), &target, &environment_manager)?;
|
||
|
||
assert_eq!(config_cwd, None);
|
||
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()?;
|
||
let config = build_config(&temp_dir).await?;
|
||
let app_server = start_test_embedded_app_server(config).await?;
|
||
let response: ThreadStartResponse = app_server
|
||
.request_typed(ClientRequest::ThreadStart {
|
||
request_id: RequestId::Integer(1),
|
||
params: ThreadStartParams {
|
||
ephemeral: Some(true),
|
||
..ThreadStartParams::default()
|
||
},
|
||
})
|
||
.await
|
||
.expect("thread/start should succeed");
|
||
assert!(!response.thread.id.is_empty());
|
||
|
||
app_server.shutdown().await?;
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn lookup_session_target_by_name_uses_backend_title_search() -> color_eyre::Result<()> {
|
||
Box::pin(async {
|
||
let temp_dir = TempDir::new()?;
|
||
let config = build_config(&temp_dir).await?;
|
||
let thread_id = ThreadId::new();
|
||
let rollout_path = temp_dir
|
||
.path()
|
||
.join("sessions/2025/02/01")
|
||
.join(format!("rollout-2025-02-01T10-00-00-{thread_id}.jsonl"));
|
||
let rollout_dir = rollout_path.parent().expect("rollout parent");
|
||
std::fs::create_dir_all(rollout_dir)?;
|
||
std::fs::write(&rollout_path, "")?;
|
||
|
||
let state_runtime = codex_state::StateRuntime::init(
|
||
config.codex_home.to_path_buf(),
|
||
config.model_provider_id.clone(),
|
||
)
|
||
.await
|
||
.map_err(std::io::Error::other)?;
|
||
state_runtime
|
||
.mark_backfill_complete(/*last_watermark*/ None)
|
||
.await
|
||
.map_err(std::io::Error::other)?;
|
||
|
||
let session_cwd = temp_dir.path().join("project");
|
||
std::fs::create_dir_all(&session_cwd)?;
|
||
let created_at = chrono::DateTime::parse_from_rfc3339("2025-02-01T10:00:00Z")
|
||
.expect("timestamp should parse")
|
||
.with_timezone(&chrono::Utc);
|
||
let mut builder = codex_state::ThreadMetadataBuilder::new(
|
||
thread_id,
|
||
rollout_path.clone(),
|
||
created_at,
|
||
serde_json::from_value(serde_json::json!("cli"))
|
||
.expect("cli session source should deserialize"),
|
||
);
|
||
builder.cwd = session_cwd;
|
||
let mut metadata = builder.build(config.model_provider_id.as_str());
|
||
metadata.title = "saved-session".to_string();
|
||
metadata.first_user_message = Some("preview text".to_string());
|
||
state_runtime
|
||
.upsert_thread(&metadata)
|
||
.await
|
||
.map_err(std::io::Error::other)?;
|
||
|
||
let mut app_server = AppServerSession::new(
|
||
codex_app_server_client::AppServerClient::InProcess(
|
||
start_test_embedded_app_server(config).await?,
|
||
),
|
||
ThreadParamsMode::Embedded,
|
||
);
|
||
let target =
|
||
lookup_session_target_by_name_with_app_server(&mut app_server, "saved-session")
|
||
.await?;
|
||
let target = target.expect("name lookup should find the saved thread");
|
||
assert_eq!(target.path, Some(rollout_path));
|
||
assert_eq!(target.thread_id, thread_id);
|
||
|
||
app_server.shutdown().await?;
|
||
Ok(())
|
||
})
|
||
.await
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn embedded_app_server_start_failure_is_returned() -> color_eyre::Result<()> {
|
||
let temp_dir = TempDir::new()?;
|
||
let config = build_config(&temp_dir).await?;
|
||
let result = start_embedded_app_server_with(
|
||
Arg0DispatchPaths::default(),
|
||
config,
|
||
Vec::new(),
|
||
LoaderOverrides::default(),
|
||
/*strict_config*/ false,
|
||
CloudConfigBundleLoader::default(),
|
||
codex_feedback::CodexFeedback::new(),
|
||
/*log_db*/ None,
|
||
/*state_db*/ None,
|
||
Arc::new(EnvironmentManager::default_for_tests()),
|
||
|_args| async { Err(std::io::Error::other("boom")) },
|
||
)
|
||
.await;
|
||
let err = match result {
|
||
Ok(_) => panic!("startup failure should be returned"),
|
||
Err(err) => err,
|
||
};
|
||
|
||
assert!(
|
||
err.to_string()
|
||
.contains("failed to start embedded app server"),
|
||
"error should preserve the embedded app server startup context"
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn embedded_state_db_failure_is_typed_for_cli_recovery() -> color_eyre::Result<()> {
|
||
let temp_dir = TempDir::new()?;
|
||
let mut config = build_config(&temp_dir).await?;
|
||
let occupied_sqlite_home = temp_dir.path().join("sqlite-home");
|
||
std::fs::write(&occupied_sqlite_home, "occupied")?;
|
||
config.sqlite_home = occupied_sqlite_home.clone();
|
||
|
||
let err =
|
||
match init_state_db_for_app_server_target(&config, &AppServerTarget::Embedded).await {
|
||
Ok(_) => panic!("embedded startup should surface state db init failures"),
|
||
Err(err) => err,
|
||
};
|
||
let startup_error = err
|
||
.get_ref()
|
||
.and_then(|err| err.downcast_ref::<LocalStateDbStartupError>())
|
||
.expect("state db startup failure should retain its typed context");
|
||
|
||
assert_eq!(
|
||
startup_error.state_db_path(),
|
||
codex_state::state_db_path(occupied_sqlite_home.as_path()).as_path()
|
||
);
|
||
assert!(
|
||
startup_error
|
||
.detail()
|
||
.contains("failed to initialize state runtime"),
|
||
"startup error should preserve the underlying state db failure"
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn embedded_state_db_corruption_preserves_failed_database_for_cli_recovery()
|
||
-> color_eyre::Result<()> {
|
||
let temp_dir = TempDir::new()?;
|
||
let mut config = build_config(&temp_dir).await?;
|
||
let sqlite_home = temp_dir.path().join("sqlite-home");
|
||
std::fs::create_dir_all(&sqlite_home)?;
|
||
let logs_db_path = codex_state::logs_db_path(&sqlite_home);
|
||
std::fs::write(&logs_db_path, "not a sqlite database")?;
|
||
config.sqlite_home = sqlite_home;
|
||
|
||
let err =
|
||
match init_state_db_for_app_server_target(&config, &AppServerTarget::Embedded).await {
|
||
Ok(_) => panic!("embedded startup should surface state db init failures"),
|
||
Err(err) => err,
|
||
};
|
||
let startup_error = err
|
||
.get_ref()
|
||
.and_then(|err| err.downcast_ref::<LocalStateDbStartupError>())
|
||
.expect("state db startup failure should retain its typed context");
|
||
|
||
assert_eq!(startup_error.database_path(), logs_db_path.as_path());
|
||
assert!(
|
||
codex_state::sqlite_error_detail_is_corruption(startup_error.detail()),
|
||
"startup error should preserve the SQLite corruption cause, got: {}",
|
||
startup_error.detail()
|
||
);
|
||
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()?;
|
||
let codex_home = temp_dir.path().to_path_buf();
|
||
let trusted = temp_dir.path().join("trusted");
|
||
let untrusted = temp_dir.path().join("untrusted");
|
||
std::fs::create_dir_all(&trusted)?;
|
||
std::fs::create_dir_all(&untrusted)?;
|
||
|
||
// TOML keys need escaped backslashes on Windows paths.
|
||
let trusted_display = trusted.display().to_string().replace('\\', "\\\\");
|
||
let untrusted_display = untrusted.display().to_string().replace('\\', "\\\\");
|
||
let config_toml = format!(
|
||
r#"[projects."{trusted_display}"]
|
||
trust_level = "trusted"
|
||
|
||
[projects."{untrusted_display}"]
|
||
trust_level = "untrusted"
|
||
"#
|
||
);
|
||
std::fs::write(temp_dir.path().join("config.toml"), config_toml)?;
|
||
|
||
let trusted_overrides = ConfigOverrides {
|
||
cwd: Some(trusted.clone()),
|
||
..Default::default()
|
||
};
|
||
let trusted_config = ConfigBuilder::default()
|
||
.loader_overrides(LoaderOverrides::without_managed_config_for_tests())
|
||
.codex_home(codex_home.clone())
|
||
.harness_overrides(trusted_overrides.clone())
|
||
.build()
|
||
.await?;
|
||
assert_eq!(
|
||
AskForApproval::from(trusted_config.permissions.approval_policy.value()),
|
||
AskForApproval::OnRequest
|
||
);
|
||
|
||
let untrusted_overrides = ConfigOverrides {
|
||
cwd: Some(untrusted),
|
||
..trusted_overrides
|
||
};
|
||
let untrusted_config = ConfigBuilder::default()
|
||
.loader_overrides(LoaderOverrides::without_managed_config_for_tests())
|
||
.codex_home(codex_home)
|
||
.harness_overrides(untrusted_overrides)
|
||
.build()
|
||
.await?;
|
||
assert_eq!(
|
||
AskForApproval::from(untrusted_config.permissions.approval_policy.value()),
|
||
AskForApproval::UnlessTrusted
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
/// Regression: theme must be configured from the *final* config.
|
||
///
|
||
/// `run_ratatui_app` can reload config during onboarding and again
|
||
/// during session resume/fork. The syntax theme override (stored in
|
||
/// a `OnceLock`) must use the final config's `tui_theme`, not the
|
||
/// initial one — otherwise users resuming a thread in a project with
|
||
/// a different theme get the wrong highlighting.
|
||
///
|
||
/// We verify the invariant indirectly: `validate_theme_name` (the
|
||
/// pure validation core of `set_theme_override`) must be called with
|
||
/// the *final* config's theme, and its warning must land in the
|
||
/// final config's `startup_warnings`.
|
||
#[tokio::test]
|
||
async fn theme_warning_uses_final_config() -> std::io::Result<()> {
|
||
use crate::render::highlight::validate_theme_name;
|
||
|
||
let temp_dir = TempDir::new()?;
|
||
|
||
// initial_config has a valid theme — no warning.
|
||
let initial_config = build_config(&temp_dir).await?;
|
||
assert!(initial_config.tui_theme.is_none());
|
||
|
||
// Simulate resume/fork reload: the final config has an invalid theme.
|
||
let mut config = build_config(&temp_dir).await?;
|
||
config.tui_theme = Some("bogus-theme".into());
|
||
|
||
// Theme override must use the final config (not initial_config).
|
||
// This mirrors the real call site in run_ratatui_app.
|
||
if let Some(w) = validate_theme_name(config.tui_theme.as_deref(), Some(temp_dir.path())) {
|
||
config.startup_warnings.push(w);
|
||
}
|
||
|
||
assert_eq!(
|
||
config.startup_warnings.len(),
|
||
1,
|
||
"warning from final config's invalid theme should be present"
|
||
);
|
||
assert!(
|
||
config.startup_warnings[0].contains("bogus-theme"),
|
||
"warning should reference the final config's theme name"
|
||
);
|
||
Ok(())
|
||
}
|
||
}
|