#![deny(clippy::print_stdout, clippy::print_stderr)]
#![deny(clippy::disallowed_methods)]
use crate::tui_internal::legacy_core::config::Config;
use crate::tui_internal::legacy_core::config::ConfigBuilder;
use crate::tui_internal::legacy_core::config::ConfigOverrides;
use crate::tui_internal::legacy_core::config::ConfigTomlLoadResult;
use crate::tui_internal::legacy_core::config::load_config_toml_with_layer_stack;
use crate::tui_internal::legacy_core::config::resolve_bootstrap_auth_keyring_backend_kind;
use crate::tui_internal::legacy_core::config::resolve_bootstrap_auth_route_config;
use crate::tui_internal::legacy_core::config::resolve_oss_provider;
use crate::tui_internal::legacy_core::config::resolve_profile_v2_config_path;
use crate::tui_internal::session_resume::ResolveCwdOutcome;
use crate::tui_internal::session_resume::ResumeCwdContext;
use crate::tui_internal::session_resume::effective_resume_cwd_mode;
use crate::tui_internal::session_resume::resolve_cwd_for_resume_or_fork;
pub use crate::tui_internal::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 lemurclaw_server::app_server_client::AppServerClient;
use lemurclaw_server::app_server_client::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY;
use lemurclaw_server::app_server_client::InProcessAppServerClient;
use lemurclaw_server::app_server_client::InProcessClientStartArgs;
use lemurclaw_server::app_server_client::RemoteAppServerClient;
use lemurclaw_server::app_server_client::RemoteAppServerConnectArgs;
pub use lemurclaw_server::app_server_client::RemoteAppServerEndpoint;
use lemurclaw_core::app_server_protocol::Account as AppServerAccount;
use lemurclaw_core::app_server_protocol::AskForApproval;
use lemurclaw_core::app_server_protocol::ConfigWarningNotification;
use lemurclaw_core::app_server_protocol::Thread as AppServerThread;
use lemurclaw_core::app_server_protocol::ThreadListCwdFilter;
use lemurclaw_core::app_server_protocol::ThreadListParams;
use lemurclaw_core::app_server_protocol::ThreadSortKey as AppServerThreadSortKey;
use lemurclaw_core::app_server_protocol::ThreadSourceKind;
use lemurclaw_server::cloud_config::cloud_config_bundle_loader_for_storage;
use lemurclaw_core::config::CloudConfigBundleLoader;
use lemurclaw_core::config::ConfigLoadError;
use lemurclaw_core::config::LoaderOverrides;
use lemurclaw_core::config::format_config_error_with_source;
use lemurclaw_core::config::types::ResumeCwdMode;
use lemurclaw_core::exec_server::EnvironmentManager;
use lemurclaw_core::exec_server::ExecServerRuntimePaths;
use lemurclaw_core::login::AuthConfig;
use lemurclaw_core::login::default_client::originator;
use lemurclaw_core::login::default_client::set_default_client_residency_requirement;
use lemurclaw_core::login::enforce_login_restrictions;
use lemurclaw_core::protocol::ThreadId;
use lemurclaw_core::protocol::auth::AuthMode;
use lemurclaw_core::protocol::config_types::AltScreenMode;
use lemurclaw_core::protocol::config_types::SandboxMode;
#[cfg(target_os = "windows")]
use lemurclaw_core::protocol::config_types::WindowsSandboxLevel;
use lemurclaw_core::rollout::StateDbHandle;
use lemurclaw_core::rollout::state_db;
use lemurclaw_core::state::log_db;
use lemurclaw_core::utils_absolute_path::AbsolutePathBuf;
use lemurclaw_core::utils_absolute_path::canonicalize_existing_preserving_symlinks;
use lemurclaw_core::utils_home_dir::find_codex_home;
use lemurclaw_core::utils_oss::ensure_oss_provider_ready;
use lemurclaw_core::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 lemurclaw_server::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_info;
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_agent_config_migration_source;
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;
mod inline_visualization;
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 managed_new_thread_defaults;
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::tui_internal::onboarding::onboarding_screen::OnboardingScreenArgs;
use crate::tui_internal::onboarding::onboarding_screen::run_onboarding_app;
use crate::tui_internal::startup_hooks_review::StartupHooksReviewOutcome;
use crate::tui_internal::startup_hooks_review::load_startup_hooks_review_entry;
use crate::tui_internal::startup_hooks_review::maybe_run_startup_hooks_review;
use crate::tui_internal::tui::Tui;
pub use cli::Cli;
use lemurclaw_server::arg0::Arg0DispatchPaths;
pub use markdown_render::render_markdown_text;
pub use public_widgets::composer_input::ComposerAction;
pub use public_widgets::composer_input::ComposerInput;
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: lemurclaw_core::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 = lemurclaw_core::state::runtime_db_path_for_corruption_error(&err)
.unwrap_or_else(|| lemurclaw_core::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)
}
}
}
fn remove_legacy_tui_log_file(codex_home: &Path) {
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")?;
lemurclaw_server::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 = lemurclaw_server::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: lemurclaw_core::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(),
false,
CloudConfigBundleLoader::default(),
lemurclaw_core::feedback::CodexFeedback::new(),
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: lemurclaw_core::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 {
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,
ancestor_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, 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,
ancestor_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 uses_remote_workspace_or_environment(app_server_target, environment_manager) {
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 uses_remote_workspace_or_environment(
app_server_target: &AppServerTarget,
environment_manager: &EnvironmentManager,
) -> bool {
app_server_target.uses_remote_workspace()
|| environment_manager
.default_environment()
.is_some_and(|environment| environment.is_remote())
}
async fn resolve_startup_resume_or_fork_cwd(
tui: &mut Tui,
config: &Config,
state_db: Option<&lemurclaw_core::state::StateRuntime>,
session_selection: &resume_picker::SessionSelection,
cwd_override: Option<&Path>,
uses_remote_workspace: bool,
uses_remote_workspace_or_environment: bool,
) -> color_eyre::Result<ResolveCwdOutcome> {
let Some((action, target_session)) = (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,
}) else {
return Ok(ResolveCwdOutcome::Continue(None));
};
let resume_cwd_mode = effective_resume_cwd_mode(config.tui_resume_cwd, cwd_override);
if uses_remote_workspace_or_environment
&& cwd_override.is_none()
&& matches!(resume_cwd_mode, Some(ResumeCwdMode::Current))
{
color_eyre::eyre::bail!(
"`tui.resume_cwd = \"current\"` requires `--cd` when using a remote workspace"
);
}
if uses_remote_workspace {
return Ok(ResolveCwdOutcome::Continue(Some(config.cwd.to_path_buf())));
}
resolve_cwd_for_resume_or_fork(
tui,
config,
state_db,
target_session,
action,
ResumeCwdContext {
current_cwd: config.cwd.as_path(),
remembered_current_cwd: config.cwd.as_path(),
allow_remember_current: !uses_remote_workspace_or_environment || cwd_override.is_some(),
mode: resume_cwd_mode,
},
)
.await
}
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 {
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),
)
};
if cli.web_search {
cli.config_overrides
.raw_overrides
.push("web_search=\"live\"".to_string());
}
let raw_overrides = cli.config_overrides.raw_overrides.clone();
let overrides_cli = lemurclaw_core::utils_cli::CliConfigOverrides { raw_overrides };
let cli_kv_overrides = match overrides_cli.parse_overrides() {
Ok(v) => v,
#[allow(clippy::print_stderr)]
Err(e) => {
eprintln!("Error parsing -c overrides: {e}");
std::process::exit(1);
}
};
#[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(),
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() {
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 {
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
};
let model = if let Some(model) = &cli.model {
Some(model.clone())
} else if cli.oss {
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 };
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 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(|| {
lemurclaw_server::app_server_client::build_otel_provider(
&config,
env!("CARGO_PKG_VERSION"),
None,
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(lemurclaw_core::otel::OtelProvider::metrics) {
let _ = lemurclaw_core::otel::record_process_start_once(metrics, otel_originator.as_str());
let telemetry =
lemurclaw_core::rollout::sqlite_telemetry_recorder(metrics.clone(), otel_originator.as_str());
let _ = lemurclaw_core::state::install_process_db_telemetry(telemetry);
}
let state_db = init_state_db_for_app_server_target(&config, &app_server_target).await?;
let config_toml_log_dir_configured = config
.config_layer_stack
.effective_config()
.as_table()
.is_some_and(|table| table.contains_key("log_dir"));
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);
#[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 = lemurclaw_core::feedback::CodexFeedback::new();
let feedback_layer = feedback.logger_layer();
let feedback_metadata_layer = feedback.metadata_layer();
if cli.oss && model_provider_override.is_some() {
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: lemurclaw_core::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();
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::tui_internal::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::tui_internal::token_usage::TokenUsage::default(),
thread_id: None,
resume_hint: None,
update_action: Some(action),
exit_reason: ExitReason::UserRequested,
});
}
}
}
}
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::tui_internal::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 show_login_screen && !uses_remote_workspace {
cloud_config_bundle = cloud_config_bundle_loader_for_storage(
initial_config.codex_home.to_path_buf(),
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 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::tui_internal::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, 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::tui_internal::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::tui_internal::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 fallback_cwd = match resolve_startup_resume_or_fork_cwd(
&mut tui,
&config,
state_db.as_deref(),
&session_selection,
cli.cwd.as_deref(),
uses_remote_workspace,
uses_remote_workspace_or_environment(&app_server_target, &environment_manager),
)
.await
{
Ok(ResolveCwdOutcome::Continue(cwd)) => cwd,
Ok(ResolveCwdOutcome::Exit) => {
terminal_restore_guard.restore_silently();
session_log::log_session_end();
return Ok(AppExitInfo {
token_usage: crate::tui_internal::token_usage::TokenUsage::default(),
thread_id: None,
resume_hint: None,
update_action: None,
exit_reason: ExitReason::UserRequested,
});
}
Err(err) => {
terminal_restore_guard.restore_silently();
session_log::log_session_end();
return Err(err);
}
};
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,
};
if let Some(w) = crate::tui_internal::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::tui_internal::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::tui_internal::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);
}
},
};
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,
current_cwd.to_path_buf(),
cli_kv_overrides.clone(),
overrides.clone(),
loader_overrides.clone(),
cloud_config_bundle,
prompt,
images,
session_selection,
feedback,
should_show_trust_screen, 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();
session_log::log_session_end();
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_internal::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();
}
}
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(AuthMode),
NotAuthenticated,
}
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(AuthMode::ApiKey),
Some(AppServerAccount::Chatgpt { .. }) => LoginStatus::AuthMode(AuthMode::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,
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, lemurclaw_core::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,
lemurclaw_core::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);
}
}
}
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 {
if !config.model_provider.requires_openai_auth {
return false;
}
login_status == LoginStatus::NotAuthenticated
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui_internal::legacy_core::config::ConfigBuilder;
use crate::tui_internal::legacy_core::config::ConfigOverrides;
use lemurclaw_core::app_server_protocol::AskForApproval;
use lemurclaw_core::app_server_protocol::ClientRequest;
use lemurclaw_core::app_server_protocol::RequestId;
use lemurclaw_core::app_server_protocol::ThreadStartParams;
use lemurclaw_core::app_server_protocol::ThreadStartResponse;
use lemurclaw_core::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 = lemurclaw_core::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: lemurclaw_core::protocol::protocol::SessionSource::Cli,
model_provider: Some(model_provider.to_string()),
..Default::default()
};
let session_meta = serde_json::to_value(lemurclaw_core::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(),
false,
CloudConfigBundleLoader::default(),
lemurclaw_core::feedback::CodexFeedback::new(),
None,
state_db,
Arc::new(EnvironmentManager::default_for_tests()),
)
.await
}
#[tokio::test]
async fn startup_resume_and_fork_use_configured_or_explicit_cwd() -> color_eyre::Result<()> {
for (action, configured_mode, has_explicit_cwd, expected_directory) in [
(CwdPromptAction::Resume, "current", false, "launch"),
(CwdPromptAction::Resume, "session", false, "session"),
(CwdPromptAction::Resume, "session", true, "explicit"),
(CwdPromptAction::Fork, "current", false, "launch"),
(CwdPromptAction::Fork, "session", false, "session"),
(CwdPromptAction::Fork, "session", true, "explicit"),
] {
let temp_dir = TempDir::new()?;
let codex_home = temp_dir.path().join("codex-home");
let launch_cwd = temp_dir.path().join("launch");
let session_cwd = temp_dir.path().join("session");
let explicit_cwd = temp_dir.path().join("explicit");
std::fs::create_dir_all(&codex_home)?;
std::fs::create_dir_all(&launch_cwd)?;
std::fs::create_dir_all(&session_cwd)?;
std::fs::create_dir_all(&explicit_cwd)?;
std::fs::write(
codex_home.join("config.toml"),
format!("[tui]\nresume_cwd = \"{configured_mode}\"\n"),
)?;
let cwd_override = has_explicit_cwd.then_some(explicit_cwd.as_path());
let config = ConfigBuilder::default()
.codex_home(codex_home.clone())
.loader_overrides(LoaderOverrides::without_managed_config_for_tests())
.harness_overrides(ConfigOverrides {
cwd: Some(cwd_override.unwrap_or(launch_cwd.as_path()).to_path_buf()),
..Default::default()
})
.build()
.await?;
let filename_timestamp = "2025-01-05T12-00-00";
let thread_id = write_session_rollout(
&codex_home,
filename_timestamp,
"2025-01-05T12:00:00Z",
"Saved user message",
&config.model_provider_id,
&session_cwd,
)?;
let rollout_path = codex_home
.join("sessions/2025/01/05")
.join(format!("rollout-{filename_timestamp}-{thread_id}.jsonl"));
let state_db =
init_state_db_for_app_server_target(&config, &AppServerTarget::Embedded).await?;
let target_session = resume_picker::SessionTarget {
path: Some(rollout_path),
thread_id,
};
let session_selection = match action {
CwdPromptAction::Resume => resume_picker::SessionSelection::Resume(target_session),
CwdPromptAction::Fork => resume_picker::SessionSelection::Fork(target_session),
};
let mut tui = tui::test_support::make_test_tui()?;
let fallback_cwd = match resolve_startup_resume_or_fork_cwd(
&mut tui,
&config,
state_db.as_deref(),
&session_selection,
cwd_override,
false,
false,
)
.await?
{
ResolveCwdOutcome::Continue(cwd) => cwd,
ResolveCwdOutcome::Exit => panic!("configured cwd should not exit startup"),
};
let final_config = ConfigBuilder::default()
.codex_home(codex_home)
.loader_overrides(LoaderOverrides::without_managed_config_for_tests())
.harness_overrides(ConfigOverrides {
cwd: cwd_override.map(Path::to_path_buf),
..Default::default()
})
.fallback_cwd(fallback_cwd)
.build()
.await?;
let expected_cwd = temp_dir.path().join(expected_directory);
assert!(!session_resume::cwds_differ(
final_config.cwd.as_path(),
&expected_cwd,
));
let mut app_server = start_app_server_for_picker(
&final_config,
&AppServerTarget::Embedded,
state_db,
Arc::new(EnvironmentManager::default_for_tests()),
)
.await?;
let started = match action {
CwdPromptAction::Resume => {
app_server
.resume_thread(
final_config,
thread_id,
app_server_session::ResumeModelSettings::RestoreFromThread,
)
.await?
}
CwdPromptAction::Fork => app_server.fork_thread(final_config, thread_id).await?,
};
assert!(!session_resume::cwds_differ(
started.session.cwd.as_path(),
&expected_cwd,
));
app_server.shutdown().await?;
}
Ok(())
}
#[tokio::test]
async fn startup_remote_current_cwd_without_override_is_rejected() -> color_eyre::Result<()> {
let temp_dir = TempDir::new()?;
std::fs::write(
temp_dir.path().join("config.toml"),
"[tui]\nresume_cwd = \"current\"\n",
)?;
let config = ConfigBuilder::default()
.codex_home(temp_dir.path().to_path_buf())
.loader_overrides(LoaderOverrides::without_managed_config_for_tests())
.build()
.await?;
let mut tui = tui::test_support::make_test_tui()?;
let error = resolve_startup_resume_or_fork_cwd(
&mut tui,
&config,
None,
&resume_picker::SessionSelection::Resume(resume_picker::SessionTarget {
path: None,
thread_id: ThreadId::new(),
}),
None,
false,
true,
)
.await
.expect_err("remote current cwd should require an explicit override");
assert_eq!(
error.to_string(),
"`tui.resume_cwd = \"current\"` requires `--cd` when using a remote workspace"
);
Ok(())
}
#[tokio::test]
async fn startup_session_cwd_without_metadata_is_rejected() -> color_eyre::Result<()> {
let temp_dir = TempDir::new()?;
std::fs::write(
temp_dir.path().join("config.toml"),
"[tui]\nresume_cwd = \"session\"\n",
)?;
let config = ConfigBuilder::default()
.codex_home(temp_dir.path().to_path_buf())
.loader_overrides(LoaderOverrides::without_managed_config_for_tests())
.build()
.await?;
let mut tui = tui::test_support::make_test_tui()?;
let error = resolve_startup_resume_or_fork_cwd(
&mut tui,
&config,
None,
&resume_picker::SessionSelection::Resume(resume_picker::SessionTarget {
path: None,
thread_id: ThreadId::new(),
}),
None,
false,
false,
)
.await
.expect_err("session cwd should require saved metadata");
assert_eq!(
error.to_string(),
"failed to determine the working directory recorded for the selected session"
);
Ok(())
}
#[test]
fn alternate_screen_auto_uses_alt_screen() {
assert!(determine_alt_screen_mode(
false,
AltScreenMode::Auto,
));
assert!(determine_alt_screen_mode(
false,
AltScreenMode::Always,
));
assert!(!determine_alt_screen_mode(
false,
AltScreenMode::Never,
));
assert!(!determine_alt_screen_mode(
true,
AltScreenMode::Auto,
));
}
#[test]
fn session_target_display_label_falls_back_to_thread_id() {
let thread_id = ThreadId::new();
let target = crate::tui_internal::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: lemurclaw_server::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 =
lemurclaw_server::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(
None,
Some(socket_path.clone()),
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")?),
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(
None,
Some(socket_path),
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(),
false,
false,
));
assert!(!can_reuse_implicit_local_daemon(
&cli_kv_overrides,
&LoaderOverrides::default(),
false,
false,
));
loader_overrides.ignore_user_config = true;
assert!(!can_reuse_implicit_local_daemon(
&[],
&loader_overrides,
false,
false,
));
assert!(!can_reuse_implicit_local_daemon(
&[],
&LoaderOverrides::default(),
true,
false,
));
assert!(!can_reuse_implicit_local_daemon(
&[],
&LoaderOverrides::default(),
false,
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(
false,
&config,
Some(cwd.as_path()),
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(
false,
&config,
Some(cwd.as_path()),
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()),
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(
true,
&config,
None,
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(
true,
&config,
None,
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(
true,
&config,
Some(cwd),
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(
false, None, &config,
false,
);
let show_all_filter = latest_session_cwd_filter(
false, None, &config,
true,
);
let remote_filter = latest_session_cwd_filter(
true,
Some(remote_cwd),
&config,
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(
lemurclaw_server::app_server_client::AppServerClient::InProcess(
start_test_embedded_app_server(config.clone()).await?,
),
ThreadParamsMode::Embedded,
);
let filter_cwd = latest_session_cwd_filter(
false, None, &config,
false,
);
let scoped_target = lookup_latest_session_target_with_app_server(
&mut app_server,
&config,
filter_cwd,
false,
)
.await?
.expect("expected project-scoped fork --last target");
let show_all_filter_cwd = latest_session_cwd_filter(
false, None, &config,
true,
);
let show_all_target = lookup_latest_session_target_with_app_server(
&mut app_server,
&config,
show_all_filter_cwd,
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(
lemurclaw_server::app_server_client::AppServerClient::InProcess(
start_test_embedded_app_server(config.clone()).await?,
),
ThreadParamsMode::Embedded,
);
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()),
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"),
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( 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 = lemurclaw_core::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( 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 = lemurclaw_core::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(
lemurclaw_server::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(),
false,
CloudConfigBundleLoader::default(),
lemurclaw_core::feedback::CodexFeedback::new(),
None,
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(),
lemurclaw_core::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 = lemurclaw_core::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!(
lemurclaw_core::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( 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 lemurclaw_core::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)?;
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(())
}
#[tokio::test]
async fn theme_warning_uses_final_config() -> std::io::Result<()> {
use crate::tui_internal::render::highlight::validate_theme_name;
let temp_dir = TempDir::new()?;
let initial_config = build_config(&temp_dir).await?;
assert!(initial_config.tui_theme.is_none());
let mut config = build_config(&temp_dir).await?;
config.tui_theme = Some("bogus-theme".into());
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(())
}
}