From 504aeb0e09bbc40771d4f23fc89c8571c27289cd Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Wed, 25 Mar 2026 09:02:22 -0700 Subject: [PATCH] Use AbsolutePathBuf for cwd state (#15710) Migrate `cwd` and related session/config state to `AbsolutePathBuf` so downstream consumers consistently see absolute working directories. Add test-only `.abs()` helpers for `Path`, `PathBuf`, and `TempDir`, and update branch-local tests to use them instead of `AbsolutePathBuf::try_from(...)`. For the remaining TUI/app-server snapshot coverage that renders absolute cwd values, keep the snapshots unchanged and skip the Windows-only cases where the platform-specific absolute path layout differs. --- .../app-server/src/codex_message_processor.rs | 8 +- codex-rs/cli/src/debug_sandbox.rs | 4 +- codex-rs/core/src/agent/role.rs | 2 +- codex-rs/core/src/codex.rs | 77 ++++++++++--------- .../src/codex/rollout_reconstruction_tests.rs | 16 ++-- codex-rs/core/src/codex_delegate.rs | 5 +- codex-rs/core/src/codex_tests.rs | 47 ++++++++--- codex-rs/core/src/codex_tests_guardian.rs | 10 +-- codex-rs/core/src/config/config_tests.rs | 64 ++++++++++----- codex-rs/core/src/config/mod.rs | 39 ++++++---- codex-rs/core/src/environment_context.rs | 6 +- codex-rs/core/src/guardian/review_session.rs | 4 +- codex-rs/core/src/guardian/tests.rs | 11 +-- codex-rs/core/src/hook_runtime.rs | 6 +- codex-rs/core/src/memories/phase2.rs | 11 ++- codex-rs/core/src/memories/tests.rs | 9 ++- codex-rs/core/src/project_doc.rs | 2 +- codex-rs/core/src/project_doc_tests.rs | 14 ++-- codex-rs/core/src/tasks/user_shell.rs | 10 +-- codex-rs/core/src/thread_manager_tests.rs | 11 +-- codex-rs/core/src/tools/handlers/artifacts.rs | 6 +- codex-rs/core/src/tools/handlers/js_repl.rs | 4 +- .../core/src/tools/handlers/js_repl_tests.rs | 2 +- .../src/tools/handlers/multi_agents_tests.rs | 3 +- codex-rs/core/src/tools/js_repl/mod.rs | 2 +- codex-rs/core/src/tools/js_repl/mod_tests.rs | 38 ++++----- codex-rs/core/src/tools/network_approval.rs | 2 +- codex-rs/core/src/tools/registry.rs | 2 +- .../tools/runtimes/shell/unix_escalation.rs | 4 +- .../core/src/unified_exec/process_manager.rs | 2 +- codex-rs/core/tests/common/lib.rs | 31 ++++++++ codex-rs/core/tests/common/test_codex.rs | 12 +-- codex-rs/core/tests/suite/client.rs | 7 +- .../tests/suite/collaboration_instructions.rs | 4 +- .../core/tests/suite/compact_resume_fork.rs | 4 +- .../core/tests/suite/hierarchical_agents.rs | 9 ++- codex-rs/core/tests/suite/prompt_caching.rs | 12 +-- codex-rs/core/tests/suite/resume_warning.rs | 2 +- codex-rs/core/tests/suite/review.rs | 3 +- codex-rs/core/tests/suite/user_shell_cmd.rs | 3 +- codex-rs/core/tests/suite/view_image.rs | 58 ++++++-------- codex-rs/tui/src/app.rs | 70 +++++++++++------ codex-rs/tui/src/chatwidget.rs | 34 +++++--- codex-rs/tui/src/chatwidget/plugins.rs | 28 +++---- .../tui/src/chatwidget/status_surfaces.rs | 4 +- codex-rs/tui/src/chatwidget/tests.rs | 68 ++++++++-------- codex-rs/tui/src/history_cell.rs | 12 ++- codex-rs/tui/src/lib.rs | 2 + .../tui/src/onboarding/onboarding_screen.rs | 2 +- codex-rs/tui/src/status/card.rs | 2 +- codex-rs/tui/src/status/helpers.rs | 2 +- codex-rs/tui/src/status/tests.rs | 23 +++--- codex-rs/tui/src/test_support.rs | 28 +++++++ codex-rs/tui_app_server/src/app.rs | 70 +++++++++++------ codex-rs/tui_app_server/src/chatwidget.rs | 36 ++++++--- .../tui_app_server/src/chatwidget/plugins.rs | 28 +++---- .../tui_app_server/src/chatwidget/tests.rs | 66 ++++++++-------- codex-rs/tui_app_server/src/history_cell.rs | 12 ++- codex-rs/tui_app_server/src/lib.rs | 4 +- .../src/onboarding/onboarding_screen.rs | 2 +- codex-rs/tui_app_server/src/status/card.rs | 2 +- codex-rs/tui_app_server/src/status/helpers.rs | 2 +- codex-rs/tui_app_server/src/status/tests.rs | 23 +++--- codex-rs/tui_app_server/src/test_support.rs | 28 +++++++ codex-rs/utils/absolute-path/src/lib.rs | 25 ++++++ 65 files changed, 717 insertions(+), 422 deletions(-) create mode 100644 codex-rs/tui/src/test_support.rs create mode 100644 codex-rs/tui_app_server/src/test_support.rs diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 402dd51dd..6e42f18e4 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1645,7 +1645,7 @@ impl CodexMessageProcessor { return; } - let cwd = cwd.unwrap_or_else(|| self.config.cwd.clone()); + let cwd = cwd.unwrap_or_else(|| self.config.cwd.to_path_buf()); let mut env = create_env( &self.config.permissions.shell_environment_policy, /*thread_id*/ None, @@ -5464,7 +5464,7 @@ impl CodexMessageProcessor { per_cwd_extra_user_roots, } = params; let cwds = if cwds.is_empty() { - vec![self.config.cwd.clone()] + vec![self.config.cwd.to_path_buf()] } else { cwds }; @@ -7258,7 +7258,7 @@ impl CodexMessageProcessor { let command_cwd = params .cwd .map(PathBuf::from) - .unwrap_or_else(|| config.cwd.clone()); + .unwrap_or_else(|| config.cwd.to_path_buf()); let cli_overrides = self.current_cli_overrides(); let runtime_feature_enablement = self.current_runtime_feature_enablement(); let outgoing = Arc::clone(&self.outgoing); @@ -7283,7 +7283,7 @@ impl CodexMessageProcessor { let setup_request = WindowsSandboxSetupRequest { mode, policy: config.permissions.sandbox_policy.get().clone(), - policy_cwd: config.cwd.clone(), + policy_cwd: config.cwd.to_path_buf(), command_cwd, env_map: std::env::vars().collect(), codex_home: config.codex_home.clone(), diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index 03226cced..d519bd5f6 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -260,7 +260,7 @@ async fn run_command_under_sandbox( PathBuf::from("/usr/bin/sandbox-exec"), args, /*arg0*/ None, - cwd, + cwd.to_path_buf(), network_policy, env, |env_map| { @@ -293,7 +293,7 @@ async fn run_command_under_sandbox( codex_linux_sandbox_exe, args, Some("codex-linux-sandbox"), - cwd, + cwd.to_path_buf(), network_policy, env, |env_map| { diff --git a/codex-rs/core/src/agent/role.rs b/codex-rs/core/src/agent/role.rs index 06c6eae1e..b7d7b55ab 100644 --- a/codex-rs/core/src/agent/role.rs +++ b/codex-rs/core/src/agent/role.rs @@ -252,7 +252,7 @@ mod reload { fn reload_overrides(config: &Config, preserve_current_provider: bool) -> ConfigOverrides { ConfigOverrides { - cwd: Some(config.cwd.clone()), + cwd: Some(config.cwd.to_path_buf()), model_provider: preserve_current_provider.then(|| config.model_provider_id.clone()), codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 168965e1e..1e874b4d6 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -33,6 +33,7 @@ use crate::models_manager::manager::ModelsManager; use crate::models_manager::manager::RefreshStrategy; use crate::parse_command::parse_command; use crate::parse_turn_item; +use crate::path_utils::normalize_for_native_workdir; use crate::realtime_conversation::RealtimeConversationManager; use crate::realtime_conversation::handle_audio as handle_realtime_conversation_audio; use crate::realtime_conversation::handle_close as handle_realtime_conversation_close; @@ -835,10 +836,10 @@ pub(crate) struct TurnContext { pub(crate) reasoning_summary: ReasoningSummaryConfig, pub(crate) session_source: SessionSource, pub(crate) environment: Arc, - /// The session's current working directory. All relative paths provided by - /// the model as well as sandbox policies are resolved against this path + /// The session's absolute working directory. All relative paths provided + /// by the model as well as sandbox policies are resolved against this path /// instead of `std::env::current_dir()`. - pub(crate) cwd: PathBuf, + pub(crate) cwd: AbsolutePathBuf, pub(crate) current_date: Option, pub(crate) timezone: Option, pub(crate) app_server_client_name: Option, @@ -979,7 +980,7 @@ impl TurnContext { pub(crate) fn resolve_path(&self, path: Option) -> PathBuf { path.as_ref() .map(PathBuf::from) - .map_or_else(|| self.cwd.clone(), |p| self.cwd.join(p)) + .map_or_else(|| self.cwd.to_path_buf(), |p| self.cwd.as_path().join(p)) } pub(crate) fn compact_prompt(&self) -> &str { @@ -992,7 +993,7 @@ impl TurnContext { TurnContextItem { turn_id: Some(self.sub_id.clone()), trace_id: self.trace_id.clone(), - cwd: self.cwd.clone(), + cwd: self.cwd.to_path_buf(), current_date: self.current_date.clone(), timezone: self.timezone.clone(), approval_policy: self.approval_policy.value(), @@ -1068,14 +1069,11 @@ pub(crate) struct SessionConfiguration { network_sandbox_policy: NetworkSandboxPolicy, windows_sandbox_level: WindowsSandboxLevel, - /// Working directory that should be treated as the *root* of the + /// Absolute working directory that should be treated as the *root* of the /// session. All relative paths supplied by the model as well as the - /// execution sandbox are resolved against this directory **instead** - /// of the process-wide current working directory. CLI front-ends are - /// expected to expand this to an absolute path before sending the - /// `ConfigureSession` operation so that the business-logic layer can - /// operate deterministically. - cwd: PathBuf, + /// execution sandbox are resolved against this directory **instead** of + /// the process-wide current working directory. + cwd: AbsolutePathBuf, /// Directory containing all Codex state for this session. codex_home: PathBuf, /// Optional user-facing name for the thread, updated during the session. @@ -1107,7 +1105,7 @@ impl SessionConfiguration { approval_policy: self.approval_policy.value(), approvals_reviewer: self.approvals_reviewer, sandbox_policy: self.sandbox_policy.get().clone(), - cwd: self.cwd.clone(), + cwd: self.cwd.to_path_buf(), ephemeral: self.original_config_do_not_use.ephemeral, reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, @@ -1150,11 +1148,23 @@ impl SessionConfiguration { if let Some(windows_sandbox_level) = updates.windows_sandbox_level { next_configuration.windows_sandbox_level = windows_sandbox_level; } - let mut cwd_changed = false; - if let Some(cwd) = updates.cwd.clone() { - next_configuration.cwd = cwd; - cwd_changed = true; - } + + let absolute_cwd = updates + .cwd + .as_ref() + .map(|cwd| { + AbsolutePathBuf::relative_to_current_dir(normalize_for_native_workdir( + cwd.as_path(), + )) + .unwrap_or_else(|e| { + warn!("failed to normalize update cwd: {cwd:?}: {e}"); + self.cwd.clone() + }) + }) + .unwrap_or_else(|| self.cwd.clone()); + + let cwd_changed = absolute_cwd.as_path() != self.cwd.as_path(); + next_configuration.cwd = absolute_cwd; if sandbox_policy_changed || (cwd_changed && file_system_policy_matches_legacy) { // Preserve richer split policies across cwd-only updates; only // rederive when the session is already using the legacy bridge. @@ -1351,8 +1361,6 @@ impl Session { let auth_manager_for_context = auth_manager; let provider_for_context = provider; let session_telemetry_for_context = session_telemetry; - let per_turn_config = Arc::new(per_turn_config); - let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, available_models: &models_manager.try_list_models().unwrap_or_default(), @@ -1372,10 +1380,12 @@ impl Session { .with_agent_roles(per_turn_config.agent_roles.clone()); let cwd = session_configuration.cwd.clone(); + + let per_turn_config = Arc::new(per_turn_config); let turn_metadata_state = Arc::new(TurnMetadataState::new( conversation_id.to_string(), sub_id.clone(), - cwd.clone(), + cwd.to_path_buf(), session_configuration.sandbox_policy.get(), session_configuration.windows_sandbox_level, )); @@ -1447,13 +1457,6 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); - if !session_configuration.cwd.is_absolute() { - return Err(anyhow::anyhow!( - "cwd is not absolute: {:?}", - session_configuration.cwd - )); - } - let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { @@ -1723,7 +1726,7 @@ impl Session { ShellSnapshot::start_snapshotting( config.codex_home.clone(), conversation_id, - session_configuration.cwd.clone(), + session_configuration.cwd.to_path_buf(), &mut default_shell, session_telemetry.clone(), ) @@ -1922,7 +1925,7 @@ impl Session { approval_policy: session_configuration.approval_policy.value(), approvals_reviewer: session_configuration.approvals_reviewer, sandbox_policy: session_configuration.sandbox_policy.get().clone(), - cwd: session_configuration.cwd.clone(), + cwd: session_configuration.cwd.to_path_buf(), reasoning_effort: session_configuration.collaboration_mode.reasoning_effort(), history_log_id, history_entry_count, @@ -1943,7 +1946,7 @@ impl Session { let sandbox_state = SandboxState { sandbox_policy: session_configuration.sandbox_policy.get().clone(), codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(), - sandbox_cwd: session_configuration.cwd.clone(), + sandbox_cwd: session_configuration.cwd.to_path_buf(), use_legacy_landlock: config.features.use_legacy_landlock(), }; let mut required_mcp_servers: Vec = mcp_servers @@ -2407,7 +2410,7 @@ impl Session { let sandbox_state = SandboxState { sandbox_policy: per_turn_config.permissions.sandbox_policy.get().clone(), codex_linux_sandbox_exe: per_turn_config.codex_linux_sandbox_exe.clone(), - sandbox_cwd: per_turn_config.cwd.clone(), + sandbox_cwd: per_turn_config.cwd.to_path_buf(), use_legacy_landlock: per_turn_config.features.use_legacy_landlock(), }; if let Err(e) = self @@ -4138,7 +4141,7 @@ impl Session { let sandbox_state = SandboxState { sandbox_policy: turn_context.sandbox_policy.get().clone(), codex_linux_sandbox_exe: turn_context.codex_linux_sandbox_exe.clone(), - sandbox_cwd: turn_context.cwd.clone(), + sandbox_cwd: turn_context.cwd.to_path_buf(), use_legacy_landlock: turn_context.features.use_legacy_landlock(), }; { @@ -4919,7 +4922,7 @@ mod handlers { ) { let cwds = if cwds.is_empty() { let state = sess.state.lock().await; - vec![state.session_configuration.cwd.clone()] + vec![state.session_configuration.cwd.to_path_buf()] } else { cwds }; @@ -5363,7 +5366,7 @@ async fn spawn_review_thread( let turn_metadata_state = Arc::new(TurnMetadataState::new( sess.conversation_id.to_string(), review_turn_id.clone(), - parent_turn_context.cwd.clone(), + parent_turn_context.cwd.to_path_buf(), parent_turn_context.sandbox_policy.get(), parent_turn_context.windows_sandbox_level, )); @@ -5852,7 +5855,7 @@ pub(crate) async fn run_turn( let stop_request = codex_hooks::StopRequest { session_id: sess.conversation_id, turn_id: turn_context.sub_id.clone(), - cwd: turn_context.cwd.clone(), + cwd: turn_context.cwd.to_path_buf(), transcript_path: sess.hook_transcript_path().await, model: turn_context.model_info.slug.clone(), permission_mode: stop_hook_permission_mode, @@ -5902,7 +5905,7 @@ pub(crate) async fn run_turn( .hooks() .dispatch(HookPayload { session_id: sess.conversation_id, - cwd: turn_context.cwd.clone(), + cwd: turn_context.cwd.to_path_buf(), client: turn_context.app_server_client_name.clone(), triggered_at: chrono::Utc::now(), hook_event: HookEvent::AfterAgent { diff --git a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs index 1ed668d56..e468bfceb 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs @@ -62,7 +62,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), - cwd: turn_context.cwd.clone(), + cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), @@ -101,7 +101,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif let mut previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), - cwd: turn_context.cwd.clone(), + cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), @@ -851,7 +851,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), - cwd: turn_context.cwd.clone(), + cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), @@ -923,7 +923,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis serde_json::to_value(Some(TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), - cwd: turn_context.cwd.clone(), + cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), @@ -952,7 +952,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), - cwd: turn_context.cwd.clone(), + cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), @@ -1058,7 +1058,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo let current_context_item = TurnContextItem { turn_id: Some(current_turn_id.clone()), trace_id: turn_context.trace_id.clone(), - cwd: turn_context.cwd.clone(), + cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), @@ -1160,7 +1160,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), - cwd: turn_context.cwd.clone(), + cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), @@ -1304,7 +1304,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), - cwd: turn_context.cwd.clone(), + cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index a81970756..d87dd070d 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -22,7 +22,6 @@ use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputArgs; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; -use codex_utils_absolute_path::AbsolutePathBuf; use serde_json::Value; use std::time::Duration; use tokio::sync::Mutex; @@ -518,7 +517,7 @@ async fn handle_patch_approval( let change_count = changes.len(); let maybe_files = changes .keys() - .map(|path| AbsolutePathBuf::from_absolute_path(parent_ctx.cwd.join(path)).ok()) + .map(|path| parent_ctx.cwd.join(path).ok()) .collect::>>(); if let Some(files) = maybe_files { let review_cancel = cancel_token.child_token(); @@ -554,7 +553,7 @@ async fn handle_patch_approval( Arc::clone(parent_ctx), GuardianApprovalRequest::ApplyPatch { id: approval_id.clone(), - cwd: parent_ctx.cwd.clone(), + cwd: parent_ctx.cwd.to_path_buf(), files, change_count, patch, diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 12720f243..34399a000 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -80,6 +80,7 @@ use codex_protocol::protocol::ConversationAudioParams; use codex_protocol::protocol::RealtimeAudioFrame; use codex_protocol::protocol::Submission; use codex_protocol::protocol::W3cTraceContext; +use core_test_support::PathBufExt; use core_test_support::context_snapshot; use core_test_support::context_snapshot::ContextSnapshotOptions; use core_test_support::context_snapshot::ContextSnapshotRenderMode; @@ -1266,7 +1267,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), - cwd: turn_context.cwd.clone(), + cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), @@ -2282,10 +2283,9 @@ async fn session_configuration_apply_preserves_split_file_system_policy_on_cwd_o let original_cwd = project_root.join("subdir"); let docs_dir = original_cwd.join("docs"); std::fs::create_dir_all(&docs_dir).expect("create docs dir"); - let docs_dir = - codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs_dir).expect("docs"); + let docs_dir = docs_dir.abs(); - session_configuration.cwd = original_cwd; + session_configuration.cwd = original_cwd.abs(); session_configuration.sandbox_policy = codex_config::Constrained::allow_any(SandboxPolicy::WorkspaceWrite { writable_roots: Vec::new(), @@ -2407,10 +2407,9 @@ async fn session_configuration_apply_rederives_legacy_file_system_policy_on_cwd_ let original_cwd = project_root.join("subdir"); let docs_dir = original_cwd.join("docs"); std::fs::create_dir_all(&docs_dir).expect("create docs dir"); - let docs_dir = - codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs_dir).expect("docs"); + let docs_dir = docs_dir.abs(); - session_configuration.cwd = original_cwd; + session_configuration.cwd = original_cwd.abs(); session_configuration.sandbox_policy = codex_config::Constrained::allow_any(SandboxPolicy::WorkspaceWrite { writable_roots: Vec::new(), @@ -2444,6 +2443,36 @@ async fn session_configuration_apply_rederives_legacy_file_system_policy_on_cwd_ ); } +#[tokio::test] +async fn session_update_settings_keeps_runtime_cwds_absolute() { + let (session, turn_context) = make_session_and_context().await; + let updated_cwd = turn_context + .cwd + .join("project") + .expect("resolve project dir"); + std::fs::create_dir_all(updated_cwd.as_path()).expect("create project dir"); + + session + .update_settings(SessionSettingsUpdate { + cwd: Some(PathBuf::from("project")), + ..Default::default() + }) + .await + .expect("cwd update should succeed"); + + let session_cwd = { + let state = session.state.lock().await; + state.session_configuration.cwd.clone() + }; + let config = session.get_config().await; + let next_turn = session.new_default_turn().await; + + assert_eq!(session_cwd, updated_cwd); + assert_eq!(config.cwd, turn_context.cwd); + assert_eq!(next_turn.cwd, updated_cwd); + assert_eq!(next_turn.config.cwd, updated_cwd); +} + #[tokio::test] async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { let codex_home = tempfile::tempdir().expect("create temp dir"); @@ -3058,7 +3087,7 @@ async fn user_turn_updates_approvals_reviewer() { text: "hello".to_string(), text_elements: Vec::new(), }], - cwd: config.cwd.clone(), + cwd: config.cwd.to_path_buf(), approval_policy: config.permissions.approval_policy.value(), approvals_reviewer: Some(crate::config::types::ApprovalsReviewer::GuardianSubagent), sandbox_policy: config.permissions.sandbox_policy.get().clone(), @@ -5060,7 +5089,7 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() { "echo hi".to_string(), ] }, - cwd: turn_context.cwd.clone(), + cwd: turn_context.cwd.to_path_buf(), expiration: timeout_ms.into(), capture_policy: ExecCapturePolicy::ShellTool, env: HashMap::new(), diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index e9050024e..b576ff3eb 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -23,7 +23,8 @@ use codex_protocol::models::ResponseItem; use codex_protocol::models::function_call_output_content_items_to_text; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; -use codex_utils_absolute_path::AbsolutePathBuf; +use core_test_support::PathExt; +use core_test_support::TempDirExt; use core_test_support::codex_linux_sandbox_exe_or_skip; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -123,7 +124,7 @@ async fn guardian_allows_shell_additional_permissions_requests_past_policy_valid "echo hi".to_string(), ] }, - cwd: turn_context.cwd.clone(), + cwd: turn_context.cwd.to_path_buf(), expiration: expiration_ms.into(), capture_policy: ExecCapturePolicy::ShellTool, env: HashMap::new(), @@ -388,12 +389,11 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { .expect("write policy file"); let mut config = build_test_config(codex_home.path()).await; - config.cwd = project_dir.path().to_path_buf(); + config.cwd = project_dir.abs(); config.config_layer_stack = ConfigLayerStack::new( vec![ConfigLayerEntry::new( ConfigLayerSource::Project { - dot_codex_folder: AbsolutePathBuf::from_absolute_path(project_dir.path()) - .expect("absolute project path"), + dot_codex_folder: project_dir.path().abs(), }, toml::Value::Table(Default::default()), )], diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 7d3af8024..f67c53234 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -27,6 +27,9 @@ use serde::Deserialize; use tempfile::tempdir; use super::*; +use core_test_support::PathBufExt; +use core_test_support::PathExt; +use core_test_support::TempDirExt; use core_test_support::test_absolute_path; use pretty_assertions::assert_eq; @@ -77,6 +80,23 @@ fn http_mcp(url: &str) -> McpServerConfig { } } +#[test] +fn load_config_normalizes_relative_cwd_override() -> std::io::Result<()> { + let expected_cwd = AbsolutePathBuf::relative_to_current_dir("nested")?; + let codex_home = tempdir()?; + let config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides { + cwd: Some(PathBuf::from("nested")), + ..Default::default() + }, + codex_home.abs().into_path_buf(), + )?; + + assert_eq!(config.cwd, expected_cwd); + Ok(()) +} + #[test] fn test_toml_parsing() { let history_with_persistence = r#" @@ -460,7 +480,7 @@ fn default_permissions_profile_populates_runtime_sandbox_policy() -> std::io::Re codex_home.path().to_path_buf(), )?; - let memories_root = AbsolutePathBuf::try_from(codex_home.path().join("memories")).unwrap(); + let memories_root = codex_home.path().join("memories").abs(); assert_eq!( config.permissions.file_system_sandbox_policy, FileSystemSandboxPolicy::restricted(vec![ @@ -496,9 +516,7 @@ fn default_permissions_profile_populates_runtime_sandbox_policy() -> std::io::Re writable_roots: vec![memories_root], read_only_access: ReadOnlyAccess::Restricted { include_platform_defaults: true, - readable_roots: vec![ - AbsolutePathBuf::try_from(cwd.path().join("docs")).expect("absolute docs path"), - ], + readable_roots: vec![cwd.path().join("docs").abs(),], }, network_access: false, exclude_tmpdir_env_var: true, @@ -1277,7 +1295,7 @@ fn add_dir_override_extends_workspace_writable_roots() -> std::io::Result<()> { temp_dir.path().to_path_buf(), )?; - let expected_backend = AbsolutePathBuf::try_from(backend).unwrap(); + let expected_backend = backend.abs(); if cfg!(target_os = "windows") { match config.permissions.sandbox_policy.get() { SandboxPolicy::ReadOnly { .. } => {} @@ -1327,7 +1345,7 @@ fn workspace_write_always_includes_memories_root_once() -> std::io::Result<()> { let config = Config::load_from_base_config_with_overrides( ConfigToml { sandbox_workspace_write: Some(SandboxWorkspaceWrite { - writable_roots: vec![AbsolutePathBuf::from_absolute_path(&memories_root)?], + writable_roots: vec![memories_root.abs()], ..Default::default() }), ..Default::default() @@ -1350,7 +1368,7 @@ fn workspace_write_always_includes_memories_root_once() -> std::io::Result<()> { "expected memories root directory to exist at {}", memories_root.display() ); - let expected_memories_root = AbsolutePathBuf::from_absolute_path(&memories_root)?; + let expected_memories_root = memories_root.abs(); match config.permissions.sandbox_policy.get() { SandboxPolicy::WorkspaceWrite { writable_roots, .. } => { assert_eq!( @@ -1768,7 +1786,7 @@ async fn managed_config_overrides_oauth_store_mode() -> anyhow::Result<()> { macos_managed_config_requirements_base64: None, }; - let cwd = AbsolutePathBuf::try_from(codex_home.path())?; + let cwd = codex_home.path().abs(); let config_layer_stack = load_config_layers_state( codex_home.path(), Some(cwd), @@ -1897,7 +1915,7 @@ async fn managed_config_wins_over_cli_overrides() -> anyhow::Result<()> { macos_managed_config_requirements_base64: None, }; - let cwd = AbsolutePathBuf::try_from(codex_home.path())?; + let cwd = codex_home.path().abs(); let config_layer_stack = load_config_layers_state( codex_home.path(), Some(cwd), @@ -2933,7 +2951,11 @@ struct PrecedenceTestFixture { } impl PrecedenceTestFixture { - fn cwd(&self) -> PathBuf { + fn cwd(&self) -> AbsolutePathBuf { + self.cwd.abs() + } + + fn cwd_path(&self) -> PathBuf { self.cwd.path().to_path_buf() } @@ -2974,7 +2996,7 @@ fn loads_compact_prompt_from_file() -> std::io::Result<()> { std::fs::write(&prompt_path, " summarize differently ")?; let cfg = ConfigToml { - experimental_compact_prompt_file: Some(AbsolutePathBuf::from_absolute_path(prompt_path)?), + experimental_compact_prompt_file: Some(prompt_path.abs()), ..Default::default() }; @@ -3071,7 +3093,7 @@ fn load_config_rejects_missing_agent_role_config_file() -> std::io::Result<()> { "researcher".to_string(), AgentRoleToml { description: Some("Research role".to_string()), - config_file: Some(AbsolutePathBuf::from_absolute_path(missing_path)?), + config_file: Some(missing_path.abs()), nickname_candidates: None, }, )]), @@ -4082,7 +4104,7 @@ fn model_catalog_json_loads_from_path() -> std::io::Result<()> { )?; let cfg = ConfigToml { - model_catalog_json: Some(AbsolutePathBuf::from_absolute_path(catalog_path)?), + model_catalog_json: Some(catalog_path.abs()), ..Default::default() }; @@ -4103,7 +4125,7 @@ fn model_catalog_json_rejects_empty_catalog() -> std::io::Result<()> { std::fs::write(&catalog_path, r#"{"models":[]}"#)?; let cfg = ConfigToml { - model_catalog_json: Some(AbsolutePathBuf::from_absolute_path(catalog_path)?), + model_catalog_json: Some(catalog_path.abs()), ..Default::default() }; @@ -4240,7 +4262,7 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { let o3_profile_overrides = ConfigOverrides { config_profile: Some("o3".to_string()), - cwd: Some(fixture.cwd()), + cwd: Some(fixture.cwd_path()), ..Default::default() }; let o3_profile_config: Config = Config::load_from_base_config_with_overrides( @@ -4368,7 +4390,7 @@ fn metrics_exporter_defaults_to_statsig_when_missing() -> std::io::Result<()> { let config = Config::load_from_base_config_with_overrides( fixture.cfg.clone(), ConfigOverrides { - cwd: Some(fixture.cwd()), + cwd: Some(fixture.cwd_path()), ..Default::default() }, fixture.codex_home(), @@ -4384,7 +4406,7 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { let gpt3_profile_overrides = ConfigOverrides { config_profile: Some("gpt3".to_string()), - cwd: Some(fixture.cwd()), + cwd: Some(fixture.cwd_path()), ..Default::default() }; let gpt3_profile_config = Config::load_from_base_config_with_overrides( @@ -4505,7 +4527,7 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { // Verify that loading without specifying a profile in ConfigOverrides // uses the default profile from the config file (which is "gpt3"). let default_profile_overrides = ConfigOverrides { - cwd: Some(fixture.cwd()), + cwd: Some(fixture.cwd_path()), ..Default::default() }; @@ -4525,7 +4547,7 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { let zdr_profile_overrides = ConfigOverrides { config_profile: Some("zdr".to_string()), - cwd: Some(fixture.cwd()), + cwd: Some(fixture.cwd_path()), ..Default::default() }; let zdr_profile_config = Config::load_from_base_config_with_overrides( @@ -4652,7 +4674,7 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { let gpt5_profile_overrides = ConfigOverrides { config_profile: Some("gpt5".to_string()), - cwd: Some(fixture.cwd()), + cwd: Some(fixture.cwd_path()), ..Default::default() }; let gpt5_profile_config = Config::load_from_base_config_with_overrides( @@ -4820,7 +4842,7 @@ fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() -> any let config = Config::load_config_with_layer_stack( fixture.cfg.clone(), ConfigOverrides { - cwd: Some(fixture.cwd()), + cwd: Some(fixture.cwd_path()), ..Default::default() }, fixture.codex_home(), diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 539663c90..c785b0461 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -395,10 +395,10 @@ pub struct Config { /// Syntax highlighting theme override (kebab-case name). pub tui_theme: Option, - /// The directory that should be treated as the current working directory - /// for the session. All relative paths inside the business-logic layer are - /// resolved against this path. - pub cwd: PathBuf, + /// The absolute directory that should be treated as the current working + /// directory for the session. All relative paths inside the business-logic + /// layer are resolved against this path. + pub cwd: AbsolutePathBuf, /// Preferred store for CLI auth credentials. /// file (default): Use a file in the Codex home directory. @@ -683,7 +683,7 @@ impl ConfigBuilder { let loader_overrides = loader_overrides.unwrap_or_default(); let cwd_override = harness_overrides.cwd.as_deref().or(fallback_cwd.as_deref()); let cwd = match cwd_override { - Some(path) => AbsolutePathBuf::try_from(path)?, + Some(path) => AbsolutePathBuf::relative_to_current_dir(path)?, None => AbsolutePathBuf::current_dir()?, }; harness_overrides.cwd = Some(cwd.to_path_buf()); @@ -2104,7 +2104,7 @@ impl Config { let windows_sandbox_mode = resolve_windows_sandbox_mode(&cfg, &config_profile); let windows_sandbox_private_desktop = resolve_windows_sandbox_private_desktop(&cfg, &config_profile); - let resolved_cwd = normalize_for_native_workdir({ + let resolved_cwd = AbsolutePathBuf::try_from(normalize_for_native_workdir({ use std::env; match cwd { @@ -2121,13 +2121,13 @@ impl Config { current } } - }); + }))?; let mut additional_writable_roots: Vec = additional_writable_roots .into_iter() - .map(|path| AbsolutePathBuf::resolve_path_against_base(path, &resolved_cwd)) + .map(|path| AbsolutePathBuf::resolve_path_against_base(path, resolved_cwd.as_path())) .collect::, _>>()?; let active_project = cfg - .get_active_project(&resolved_cwd) + .get_active_project(resolved_cwd.as_path()) .unwrap_or(ProjectConfig { trust_level: None }); let permission_config_syntax = resolve_permission_config_syntax( &config_layer_stack, @@ -2200,12 +2200,15 @@ impl Config { &mut startup_warnings, )?; let mut sandbox_policy = file_system_sandbox_policy - .to_legacy_sandbox_policy(network_sandbox_policy, &resolved_cwd)?; + .to_legacy_sandbox_policy(network_sandbox_policy, resolved_cwd.as_path())?; if matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) { file_system_sandbox_policy = file_system_sandbox_policy - .with_additional_writable_roots(&resolved_cwd, &additional_writable_roots); + .with_additional_writable_roots( + resolved_cwd.as_path(), + &additional_writable_roots, + ); sandbox_policy = file_system_sandbox_policy - .to_legacy_sandbox_policy(network_sandbox_policy, &resolved_cwd)?; + .to_legacy_sandbox_policy(network_sandbox_policy, resolved_cwd.as_path())?; } ( configured_network_proxy_config, @@ -2219,7 +2222,7 @@ impl Config { sandbox_mode, config_profile.sandbox_mode, windows_sandbox_level, - &resolved_cwd, + resolved_cwd.as_path(), Some(&constrained_sandbox_policy), ); if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = &mut sandbox_policy { @@ -2229,8 +2232,10 @@ impl Config { } } } - let file_system_sandbox_policy = - FileSystemSandboxPolicy::from_legacy_sandbox_policy(&sandbox_policy, &resolved_cwd); + let file_system_sandbox_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy( + &sandbox_policy, + resolved_cwd.as_path(), + ); let network_sandbox_policy = NetworkSandboxPolicy::from(&sandbox_policy); ( configured_network_proxy_config, @@ -2566,11 +2571,11 @@ impl Config { } else { FileSystemSandboxPolicy::from_legacy_sandbox_policy( &effective_sandbox_policy, - &resolved_cwd, + resolved_cwd.as_path(), ) }; let effective_file_system_sandbox_policy = effective_file_system_sandbox_policy - .with_additional_readable_roots(&resolved_cwd, &helper_readable_roots); + .with_additional_readable_roots(resolved_cwd.as_path(), &helper_readable_roots); let effective_network_sandbox_policy = if effective_sandbox_policy == original_sandbox_policy { network_sandbox_policy diff --git a/codex-rs/core/src/environment_context.rs b/codex-rs/core/src/environment_context.rs index e744fd547..bbb39da11 100644 --- a/codex-rs/core/src/environment_context.rs +++ b/codex-rs/core/src/environment_context.rs @@ -70,8 +70,8 @@ impl EnvironmentContext { ) -> Self { let before_network = Self::network_from_turn_context_item(before); let after_network = Self::network_from_turn_context(after); - let cwd = if before.cwd != after.cwd { - Some(after.cwd.clone()) + let cwd = if before.cwd.as_path() != after.cwd.as_path() { + Some(after.cwd.to_path_buf()) } else { None }; @@ -94,7 +94,7 @@ impl EnvironmentContext { pub fn from_turn_context(turn_context: &TurnContext, shell: &Shell) -> Self { Self::new( - Some(turn_context.cwd.clone()), + Some(turn_context.cwd.to_path_buf()), shell.clone(), turn_context.current_date.clone(), turn_context.timezone.clone(), diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 50bf2ed84..64e27c42c 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -140,7 +140,7 @@ impl GuardianReviewSessionReuseKey { base_instructions: spawn_config.base_instructions.clone(), user_instructions: spawn_config.user_instructions.clone(), compact_prompt: spawn_config.compact_prompt.clone(), - cwd: spawn_config.cwd.clone(), + cwd: spawn_config.cwd.to_path_buf(), mcp_servers: spawn_config.mcp_servers.clone(), codex_linux_sandbox_exe: spawn_config.codex_linux_sandbox_exe.clone(), main_execve_wrapper_exe: spawn_config.main_execve_wrapper_exe.clone(), @@ -512,7 +512,7 @@ async fn run_review_on_session( .codex .submit(Op::UserTurn { items: params.prompt_items.clone(), - cwd: params.parent_turn.cwd.clone(), + cwd: params.parent_turn.cwd.to_path_buf(), approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 89c22528e..3e6d409e7 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -25,7 +25,8 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::GuardianAssessmentStatus; use codex_protocol::protocol::GuardianRiskLevel; use codex_protocol::protocol::ReviewDecision; -use codex_utils_absolute_path::AbsolutePathBuf; +use core_test_support::PathBufExt; +use core_test_support::TempDirExt; use core_test_support::context_snapshot; use core_test_support::context_snapshot::ContextSnapshotOptions; use core_test_support::responses::ev_assistant_message; @@ -322,7 +323,7 @@ fn guardian_assessment_action_value_redacts_apply_patch_patch_text() { ("/tmp", "/tmp/guardian.txt") }; let cwd = PathBuf::from(cwd); - let file = AbsolutePathBuf::try_from(file).expect("absolute path"); + let file = PathBuf::from(file).abs(); let action = GuardianApprovalRequest::ApplyPatch { id: "patch-1".to_string(), cwd: cwd.clone(), @@ -356,7 +357,7 @@ fn guardian_request_turn_id_prefers_network_access_owner_turn() { let apply_patch = GuardianApprovalRequest::ApplyPatch { id: "patch-1".to_string(), cwd: PathBuf::from("/tmp"), - files: vec![AbsolutePathBuf::try_from("/tmp/guardian.txt").expect("absolute path")], + files: vec![PathBuf::from("/tmp/guardian.txt").abs()], change_count: 1usize, patch: "*** Begin Patch\n*** Update File: guardian.txt\n@@\n+hello\n*** End Patch" .to_string(), @@ -384,7 +385,7 @@ async fn cancelled_guardian_review_emits_terminal_abort_without_warning() { GuardianApprovalRequest::ApplyPatch { id: "patch-1".to_string(), cwd: PathBuf::from("/tmp"), - files: vec![AbsolutePathBuf::try_from("/tmp/guardian.txt").expect("absolute path")], + files: vec![PathBuf::from("/tmp/guardian.txt").abs()], change_count: 1usize, patch: "*** Begin Patch\n*** Update File: guardian.txt\n@@\n+hello\n*** End Patch" .to_string(), @@ -512,7 +513,7 @@ async fn guardian_review_request_layout_matches_model_visible_request_snapshot() let (mut session, mut turn) = crate::codex::make_session_and_context().await; let temp_cwd = TempDir::new()?; let mut config = (*turn.config).clone(); - config.cwd = temp_cwd.path().to_path_buf(); + config.cwd = temp_cwd.abs(); config.model_provider.base_url = Some(format!("{}/v1", server.uri())); let config = Arc::new(config); let models_manager = Arc::new(test_support::models_manager_with_provider( diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index 7e6ecaca1..744db4886 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -92,7 +92,7 @@ pub(crate) async fn run_pending_session_start_hooks( let request = codex_hooks::SessionStartRequest { session_id: sess.conversation_id, - cwd: turn_context.cwd.clone(), + cwd: turn_context.cwd.to_path_buf(), transcript_path: sess.hook_transcript_path().await, model: turn_context.model_info.slug.clone(), permission_mode: hook_permission_mode(turn_context), @@ -120,7 +120,7 @@ pub(crate) async fn run_pre_tool_use_hooks( let request = PreToolUseRequest { session_id: sess.conversation_id, turn_id: turn_context.sub_id.clone(), - cwd: turn_context.cwd.clone(), + cwd: turn_context.cwd.to_path_buf(), transcript_path: sess.hook_transcript_path().await, model: turn_context.model_info.slug.clone(), permission_mode: hook_permission_mode(turn_context), @@ -149,7 +149,7 @@ pub(crate) async fn run_user_prompt_submit_hooks( let request = UserPromptSubmitRequest { session_id: sess.conversation_id, turn_id: turn_context.sub_id.clone(), - cwd: turn_context.cwd.clone(), + cwd: turn_context.cwd.to_path_buf(), transcript_path: sess.hook_transcript_path().await, model: turn_context.model_info.slug.clone(), permission_mode: hook_permission_mode(turn_context), diff --git a/codex-rs/core/src/memories/phase2.rs b/codex-rs/core/src/memories/phase2.rs index 2e0d7c4ad..321b314ab 100644 --- a/codex-rs/core/src/memories/phase2.rs +++ b/codex-rs/core/src/memories/phase2.rs @@ -266,7 +266,16 @@ mod agent { let root = memory_root(&config.codex_home); let mut agent_config = config.as_ref().clone(); - agent_config.cwd = root; + match AbsolutePathBuf::from_absolute_path(root) { + Ok(root) => agent_config.cwd = root, + Err(err) => { + warn!( + "memory phase-2 consolidation could not set cwd from codex_home {}: {err}", + agent_config.codex_home.display() + ); + return None; + } + } // Consolidation threads must never feed back into phase-1 memory generation. agent_config.memories.generate_memories = false; // Approval policy diff --git a/codex-rs/core/src/memories/tests.rs b/codex-rs/core/src/memories/tests.rs index e04a018a8..a929401b9 100644 --- a/codex-rs/core/src/memories/tests.rs +++ b/codex-rs/core/src/memories/tests.rs @@ -435,6 +435,7 @@ mod phase2 { use codex_state::Phase2JobClaimOutcome; use codex_state::Stage1Output; use codex_state::ThreadMetadataBuilder; + use core_test_support::PathBufExt; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -469,7 +470,7 @@ mod phase2 { let codex_home = tempfile::tempdir().expect("create temp codex home"); let mut config = test_config(); config.codex_home = codex_home.path().to_path_buf(); - config.cwd = config.codex_home.clone(); + config.cwd = config.codex_home.abs(); let config = Arc::new(config); let state_db = codex_state::StateRuntime::init( @@ -507,7 +508,7 @@ mod phase2 { Utc::now(), SessionSource::Cli, ); - metadata_builder.cwd = self.config.cwd.clone(); + metadata_builder.cwd = self.config.cwd.to_path_buf(); metadata_builder.model_provider = Some(self.config.model_provider_id.clone()); let metadata = metadata_builder.build(&self.config.model_provider_id); @@ -882,7 +883,7 @@ mod phase2 { let codex_home = tempfile::tempdir().expect("create temp codex home"); let mut config = test_config(); config.codex_home = codex_home.path().to_path_buf(); - config.cwd = config.codex_home.clone(); + config.cwd = config.codex_home.abs(); let config = Arc::new(config); let state_db = codex_state::StateRuntime::init( @@ -904,7 +905,7 @@ mod phase2 { Utc::now(), SessionSource::Cli, ); - metadata_builder.cwd = config.cwd.clone(); + metadata_builder.cwd = config.cwd.to_path_buf(); metadata_builder.model_provider = Some(config.model_provider_id.clone()); let metadata = metadata_builder.build(&config.model_provider_id); state_db diff --git a/codex-rs/core/src/project_doc.rs b/codex-rs/core/src/project_doc.rs index 7ad122c40..1b72f6461 100644 --- a/codex-rs/core/src/project_doc.rs +++ b/codex-rs/core/src/project_doc.rs @@ -184,7 +184,7 @@ pub async fn read_project_docs(config: &Config) -> std::io::Result std::io::Result> { - let mut dir = config.cwd.clone(); + let mut dir = config.cwd.to_path_buf(); if let Ok(canon) = normalize_path(&dir) { dir = canon; } diff --git a/codex-rs/core/src/project_doc_tests.rs b/codex-rs/core/src/project_doc_tests.rs index 4cea541be..866831d8b 100644 --- a/codex-rs/core/src/project_doc_tests.rs +++ b/codex-rs/core/src/project_doc_tests.rs @@ -1,6 +1,8 @@ use super::*; use crate::config::ConfigBuilder; use codex_features::Feature; +use core_test_support::PathBufExt; +use core_test_support::TempDirExt; use std::fs; use std::path::PathBuf; use tempfile::TempDir; @@ -18,7 +20,7 @@ async fn make_config(root: &TempDir, limit: usize, instructions: Option<&str>) - .await .expect("defaults for test should always succeed"); - config.cwd = root.path().to_path_buf(); + config.cwd = root.abs(); config.project_doc_max_bytes = limit; config.user_instructions = instructions.map(ToOwned::to_owned); @@ -62,7 +64,7 @@ async fn make_config_with_project_root_markers( .await .expect("defaults for test should always succeed"); - config.cwd = root.path().to_path_buf(); + config.cwd = root.abs(); config.project_doc_max_bytes = limit; config.user_instructions = instructions.map(ToOwned::to_owned); config @@ -136,7 +138,7 @@ async fn finds_doc_in_repo_root() { // Build config pointing at the nested dir. let mut cfg = make_config(&repo, 4096, None).await; - cfg.cwd = nested; + cfg.cwd = nested.abs(); let res = get_user_instructions(&cfg).await.expect("doc expected"); assert_eq!(res, "root level doc"); @@ -261,7 +263,7 @@ async fn concatenates_root_and_cwd_docs() { fs::write(nested.join("AGENTS.md"), "crate doc").unwrap(); let mut cfg = make_config(&repo, 4096, None).await; - cfg.cwd = nested; + cfg.cwd = nested.abs(); let res = get_user_instructions(&cfg).await.expect("doc expected"); assert_eq!(res, "root doc\n\ncrate doc"); @@ -278,13 +280,13 @@ async fn project_root_markers_are_honored_for_agents_discovery() { fs::write(nested.join("AGENTS.md"), "child doc").unwrap(); let mut cfg = make_config_with_project_root_markers(&root, 4096, None, &[".codex-root"]).await; - cfg.cwd = nested; + cfg.cwd = nested.abs(); let discovery = discover_project_doc_paths(&cfg).expect("discover paths"); let expected_parent = dunce::canonicalize(root.path().join("AGENTS.md")).expect("canonical parent doc path"); let expected_child = - dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical child doc path"); + dunce::canonicalize(cfg.cwd.as_path().join("AGENTS.md")).expect("canonical child doc path"); assert_eq!(discovery.len(), 2); assert_eq!(discovery[0], expected_parent); assert_eq!(discovery[1], expected_child); diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index e118d3809..b90717e71 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -145,7 +145,7 @@ pub(crate) async fn execute_user_shell_command( process_id: None, turn_id: turn_context.sub_id.clone(), command: display_command.clone(), - cwd: cwd.clone(), + cwd: cwd.to_path_buf(), parsed_cmd: parsed_cmd.clone(), source: ExecCommandSource::UserShell, interaction_input: None, @@ -156,7 +156,7 @@ pub(crate) async fn execute_user_shell_command( let sandbox_policy = SandboxPolicy::DangerFullAccess; let exec_env = ExecRequest { command: exec_command.clone(), - cwd: cwd.clone(), + cwd: cwd.to_path_buf(), env: create_env( &turn_context.shell_environment_policy, Some(session.conversation_id), @@ -221,7 +221,7 @@ pub(crate) async fn execute_user_shell_command( process_id: None, turn_id: turn_context.sub_id.clone(), command: display_command.clone(), - cwd: cwd.clone(), + cwd: cwd.to_path_buf(), parsed_cmd: parsed_cmd.clone(), source: ExecCommandSource::UserShell, interaction_input: None, @@ -245,7 +245,7 @@ pub(crate) async fn execute_user_shell_command( process_id: None, turn_id: turn_context.sub_id.clone(), command: display_command.clone(), - cwd: cwd.clone(), + cwd: cwd.to_path_buf(), parsed_cmd: parsed_cmd.clone(), source: ExecCommandSource::UserShell, interaction_input: None, @@ -289,7 +289,7 @@ pub(crate) async fn execute_user_shell_command( process_id: None, turn_id: turn_context.sub_id.clone(), command: display_command, - cwd, + cwd: cwd.to_path_buf(), parsed_cmd, source: ExecCommandSource::UserShell, interaction_input: None, diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 40e7439cc..64830fa72 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -12,6 +12,7 @@ use codex_protocol::openai_models::ModelsResponse; use codex_protocol::protocol::AgentMessageEvent; use codex_protocol::protocol::TurnStartedEvent; use codex_protocol::protocol::UserMessageEvent; +use core_test_support::PathExt; use core_test_support::responses::mount_models_once; use pretty_assertions::assert_eq; use std::time::Duration; @@ -236,7 +237,7 @@ async fn shutdown_all_threads_bounded_submits_shutdown_to_every_thread() { let temp_dir = tempdir().expect("tempdir"); let mut config = test_config(); config.codex_home = temp_dir.path().join("codex-home"); - config.cwd = config.codex_home.clone(); + config.cwd = config.codex_home.abs(); std::fs::create_dir_all(&config.codex_home).expect("create codex home"); let manager = ThreadManager::with_models_provider_and_home_for_tests( @@ -275,7 +276,7 @@ async fn new_uses_configured_openai_provider_for_model_refresh() { let temp_dir = tempdir().expect("tempdir"); let mut config = test_config(); config.codex_home = temp_dir.path().join("codex-home"); - config.cwd = config.codex_home.clone(); + config.cwd = config.codex_home.abs(); std::fs::create_dir_all(&config.codex_home).expect("create codex home"); config.model_catalog = None; config @@ -408,7 +409,7 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor let temp_dir = tempdir().expect("tempdir"); let mut config = test_config(); config.codex_home = temp_dir.path().join("codex-home"); - config.cwd = config.codex_home.clone(); + config.cwd = config.codex_home.abs(); std::fs::create_dir_all(&config.codex_home).expect("create codex home"); let auth_manager = @@ -505,7 +506,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { let temp_dir = tempdir().expect("tempdir"); let mut config = test_config(); config.codex_home = temp_dir.path().join("codex-home"); - config.cwd = config.codex_home.clone(); + config.cwd = config.codex_home.abs(); std::fs::create_dir_all(&config.codex_home).expect("create codex home"); let auth_manager = @@ -591,7 +592,7 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_ let temp_dir = tempdir().expect("tempdir"); let mut config = test_config(); config.codex_home = temp_dir.path().join("codex-home"); - config.cwd = config.codex_home.clone(); + config.cwd = config.codex_home.abs(); std::fs::create_dir_all(&config.codex_home).expect("create codex home"); let auth_manager = diff --git a/codex-rs/core/src/tools/handlers/artifacts.rs b/codex-rs/core/src/tools/handlers/artifacts.rs index 875fcd486..910dcbb47 100644 --- a/codex-rs/core/src/tools/handlers/artifacts.rs +++ b/codex-rs/core/src/tools/handlers/artifacts.rs @@ -89,7 +89,7 @@ impl ToolHandler for ArtifactsHandler { let result = client .execute_build(ArtifactBuildRequest { source: args.source, - cwd: turn.cwd.clone(), + cwd: turn.cwd.to_path_buf(), timeout: Some(Duration::from_millis( args.timeout_ms .unwrap_or(DEFAULT_EXECUTION_TIMEOUT.as_millis() as u64), @@ -221,7 +221,7 @@ fn default_runtime_manager(codex_home: std::path::PathBuf) -> ArtifactRuntimeMan async fn emit_exec_begin(session: &Session, turn: &TurnContext, call_id: &str) { let emitter = ToolEmitter::shell( vec![ARTIFACTS_TOOL_NAME.to_string()], - turn.cwd.clone(), + turn.cwd.to_path_buf(), ExecCommandSource::Agent, /*freeform*/ true, ); @@ -247,7 +247,7 @@ async fn emit_exec_end( }; let emitter = ToolEmitter::shell( vec![ARTIFACTS_TOOL_NAME.to_string()], - turn.cwd.clone(), + turn.cwd.to_path_buf(), ExecCommandSource::Agent, /*freeform*/ true, ); diff --git a/codex-rs/core/src/tools/handlers/js_repl.rs b/codex-rs/core/src/tools/handlers/js_repl.rs index b380a7107..43b986e0d 100644 --- a/codex-rs/core/src/tools/handlers/js_repl.rs +++ b/codex-rs/core/src/tools/handlers/js_repl.rs @@ -61,7 +61,7 @@ async fn emit_js_repl_exec_begin( ) { let emitter = ToolEmitter::shell( vec!["js_repl".to_string()], - turn.cwd.clone(), + turn.cwd.to_path_buf(), ExecCommandSource::Agent, /*freeform*/ false, ); @@ -80,7 +80,7 @@ async fn emit_js_repl_exec_end( let exec_output = build_js_repl_exec_output(output, error, duration); let emitter = ToolEmitter::shell( vec!["js_repl".to_string()], - turn.cwd.clone(), + turn.cwd.to_path_buf(), ExecCommandSource::Agent, /*freeform*/ false, ); diff --git a/codex-rs/core/src/tools/handlers/js_repl_tests.rs b/codex-rs/core/src/tools/handlers/js_repl_tests.rs index 14dc222ff..9613a8303 100644 --- a/codex-rs/core/src/tools/handlers/js_repl_tests.rs +++ b/codex-rs/core/src/tools/handlers/js_repl_tests.rs @@ -77,7 +77,7 @@ async fn emit_js_repl_exec_end_sends_event() { assert_eq!(event.call_id, "call-1"); assert_eq!(event.turn_id, turn.sub_id); assert_eq!(event.command, vec!["js_repl".to_string()]); - assert_eq!(event.cwd, turn.cwd); + assert_eq!(event.cwd, turn.cwd.to_path_buf()); assert_eq!(event.source, ExecCommandSource::Agent); assert_eq!(event.interaction_input, None); assert_eq!(event.stdout, "hello"); diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 4a6aa26ff..77ed20dd8 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -39,6 +39,7 @@ use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::RolloutItem; use codex_protocol::user_input::UserInput; +use core_test_support::TempDirExt; use pretty_assertions::assert_eq; use serde::Deserialize; use serde_json::json; @@ -2236,7 +2237,7 @@ async fn build_agent_spawn_config_uses_turn_context_values() { ..ShellEnvironmentPolicy::default() }; let temp_dir = tempfile::tempdir().expect("temp dir"); - turn.cwd = temp_dir.path().to_path_buf(); + turn.cwd = temp_dir.abs(); turn.codex_linux_sandbox_exe = Some(PathBuf::from("/bin/echo")); let sandbox_policy = pick_allowed_sandbox_policy( &turn.config.permissions.sandbox_policy, diff --git a/codex-rs/core/src/tools/js_repl/mod.rs b/codex-rs/core/src/tools/js_repl/mod.rs index 0a29fb120..266aa2a1c 100644 --- a/codex-rs/core/src/tools/js_repl/mod.rs +++ b/codex-rs/core/src/tools/js_repl/mod.rs @@ -1050,7 +1050,7 @@ impl JsReplManager { "--experimental-vm-modules".to_string(), kernel_path.to_string_lossy().to_string(), ], - cwd: turn.cwd.clone(), + cwd: turn.cwd.to_path_buf(), env, additional_permissions: None, }; diff --git a/codex-rs/core/src/tools/js_repl/mod_tests.rs b/codex-rs/core/src/tools/js_repl/mod_tests.rs index 413e171d4..c75955400 100644 --- a/codex-rs/core/src/tools/js_repl/mod_tests.rs +++ b/codex-rs/core/src/tools/js_repl/mod_tests.rs @@ -14,6 +14,8 @@ use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ImageDetail; use codex_protocol::models::ResponseInputItem; use codex_protocol::openai_models::InputModality; +use core_test_support::PathBufExt; +use core_test_support::TempDirExt; use pretty_assertions::assert_eq; use std::fs; use std::path::Path; @@ -739,7 +741,7 @@ async fn interrupt_active_exec_stops_aborted_kernel_before_later_exec() -> anyho let dir = tempdir()?; let (session, mut turn) = make_session_and_context().await; - turn.cwd = dir.path().to_path_buf(); + turn.cwd = dir.abs(); set_danger_full_access(&mut turn); let session = Arc::new(session); let turn = Arc::new(turn); @@ -1017,7 +1019,7 @@ async fn js_repl_waits_for_unawaited_tool_calls_before_completion() -> anyhow::R let marker = turn .cwd - .join(format!("js-repl-unawaited-marker-{}.txt", Uuid::new_v4())); + .join(format!("js-repl-unawaited-marker-{}.txt", Uuid::new_v4()))?; let marker_json = serde_json::to_string(&marker.to_string_lossy().to_string())?; let result = manager .execute( @@ -1062,10 +1064,10 @@ async fn js_repl_persisted_tool_helpers_work_across_cells() -> anyhow::Result<() let global_marker = turn .cwd - .join(format!("js-repl-global-helper-{}.txt", Uuid::new_v4())); + .join(format!("js-repl-global-helper-{}.txt", Uuid::new_v4()))?; let lexical_marker = turn .cwd - .join(format!("js-repl-lexical-helper-{}.txt", Uuid::new_v4())); + .join(format!("js-repl-lexical-helper-{}.txt", Uuid::new_v4()))?; let global_marker_json = serde_json::to_string(&global_marker.to_string_lossy().to_string())?; let lexical_marker_json = serde_json::to_string(&lexical_marker.to_string_lossy().to_string())?; @@ -2101,7 +2103,7 @@ async fn js_repl_prefers_env_node_module_dirs_over_config() -> anyhow::Result<() "CODEX_JS_REPL_NODE_MODULE_DIRS".to_string(), env_base.path().to_string_lossy().to_string(), ); - turn.cwd = cwd_dir.path().to_path_buf(); + turn.cwd = cwd_dir.abs(); turn.js_repl = Arc::new(JsReplHandle::with_node_path( turn.config.js_repl_node_path.clone(), vec![config_base.path().to_path_buf()], @@ -2145,7 +2147,7 @@ async fn js_repl_resolves_from_first_config_dir() -> anyhow::Result<()> { turn.shell_environment_policy .r#set .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.path().to_path_buf(); + turn.cwd = cwd_dir.abs(); turn.js_repl = Arc::new(JsReplHandle::with_node_path( turn.config.js_repl_node_path.clone(), vec![ @@ -2189,7 +2191,7 @@ async fn js_repl_falls_back_to_cwd_node_modules() -> anyhow::Result<()> { turn.shell_environment_policy .r#set .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.path().to_path_buf(); + turn.cwd = cwd_dir.abs(); turn.js_repl = Arc::new(JsReplHandle::with_node_path( turn.config.js_repl_node_path.clone(), vec![config_base.path().to_path_buf()], @@ -2230,7 +2232,7 @@ async fn js_repl_accepts_node_modules_dir_entries() -> anyhow::Result<()> { turn.shell_environment_policy .r#set .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.path().to_path_buf(); + turn.cwd = cwd_dir.abs(); turn.js_repl = Arc::new(JsReplHandle::with_node_path( turn.config.js_repl_node_path.clone(), vec![base_dir.path().join("node_modules")], @@ -2284,7 +2286,7 @@ async fn js_repl_supports_relative_file_imports() -> anyhow::Result<()> { turn.shell_environment_policy .r#set .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.path().to_path_buf(); + turn.cwd = cwd_dir.abs(); turn.js_repl = Arc::new(JsReplHandle::with_node_path( turn.config.js_repl_node_path.clone(), Vec::new(), @@ -2331,7 +2333,7 @@ async fn js_repl_supports_absolute_file_imports() -> anyhow::Result<()> { turn.shell_environment_policy .r#set .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.path().to_path_buf(); + turn.cwd = cwd_dir.abs(); turn.js_repl = Arc::new(JsReplHandle::with_node_path( turn.config.js_repl_node_path.clone(), Vec::new(), @@ -2385,7 +2387,7 @@ async fn js_repl_imported_local_files_can_access_repl_globals() -> anyhow::Resul turn.shell_environment_policy .r#set .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.path().to_path_buf(); + turn.cwd = cwd_dir.abs(); turn.js_repl = Arc::new(JsReplHandle::with_node_path( turn.config.js_repl_node_path.clone(), Vec::new(), @@ -2429,7 +2431,7 @@ async fn js_repl_reimports_local_files_after_edit() -> anyhow::Result<()> { turn.shell_environment_policy .r#set .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.path().to_path_buf(); + turn.cwd = cwd_dir.abs(); turn.js_repl = Arc::new(JsReplHandle::with_node_path( turn.config.js_repl_node_path.clone(), Vec::new(), @@ -2485,7 +2487,7 @@ async fn js_repl_reimports_local_files_after_fixing_failure() -> anyhow::Result< turn.shell_environment_policy .r#set .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.path().to_path_buf(); + turn.cwd = cwd_dir.abs(); turn.js_repl = Arc::new(JsReplHandle::with_node_path( turn.config.js_repl_node_path.clone(), Vec::new(), @@ -2563,7 +2565,7 @@ async fn js_repl_local_files_expose_node_like_import_meta() -> anyhow::Result<() turn.shell_environment_policy .r#set .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.path().to_path_buf(); + turn.cwd = cwd_dir.abs(); turn.js_repl = Arc::new(JsReplHandle::with_node_path( turn.config.js_repl_node_path.clone(), Vec::new(), @@ -2648,7 +2650,7 @@ async fn js_repl_local_files_reject_static_bare_imports() -> anyhow::Result<()> turn.shell_environment_policy .r#set .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.path().to_path_buf(); + turn.cwd = cwd_dir.abs(); turn.js_repl = Arc::new(JsReplHandle::with_node_path( turn.config.js_repl_node_path.clone(), Vec::new(), @@ -2693,7 +2695,7 @@ async fn js_repl_rejects_unsupported_file_specifiers() -> anyhow::Result<()> { turn.shell_environment_policy .r#set .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.path().to_path_buf(); + turn.cwd = cwd_dir.abs(); turn.js_repl = Arc::new(JsReplHandle::with_node_path( turn.config.js_repl_node_path.clone(), Vec::new(), @@ -2795,7 +2797,7 @@ async fn js_repl_blocks_sensitive_builtin_imports_from_local_files() -> anyhow:: turn.shell_environment_policy .r#set .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.path().to_path_buf(); + turn.cwd = cwd_dir.abs(); turn.js_repl = Arc::new(JsReplHandle::with_node_path( turn.config.js_repl_node_path.clone(), Vec::new(), @@ -2845,7 +2847,7 @@ async fn js_repl_local_files_do_not_escape_node_module_search_roots() -> anyhow: turn.shell_environment_policy .r#set .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.clone(); + turn.cwd = cwd_dir.abs(); turn.js_repl = Arc::new(JsReplHandle::with_node_path( turn.config.js_repl_node_path.clone(), Vec::new(), diff --git a/codex-rs/core/src/tools/network_approval.rs b/codex-rs/core/src/tools/network_approval.rs index 9e92a6b00..02b6bbe60 100644 --- a/codex-rs/core/src/tools/network_approval.rs +++ b/codex-rs/core/src/tools/network_approval.rs @@ -379,7 +379,7 @@ impl NetworkApprovalService { approval_id, /*approval_id*/ None, prompt_command, - turn_context.cwd.clone(), + turn_context.cwd.to_path_buf(), Some(prompt_reason), Some(network_approval_context.clone()), /*proposed_execpolicy_amendment*/ None, diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index 37b7d015b..df97d7128 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -530,7 +530,7 @@ async fn dispatch_after_tool_use_hook( .hooks() .dispatch(HookPayload { session_id: session.conversation_id, - cwd: turn.cwd.clone(), + cwd: turn.cwd.to_path_buf(), client: turn.app_server_client_name.clone(), triggered_at: chrono::Utc::now(), hook_event: HookEvent::AfterToolUse { diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index 4e107232d..9df94e828 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -159,7 +159,7 @@ pub(super) async fn try_run_zsh_fork( network: sandbox_network, windows_sandbox_level, arg0, - sandbox_policy_cwd: ctx.turn.cwd.clone(), + sandbox_policy_cwd: ctx.turn.cwd.to_path_buf(), macos_seatbelt_profile_extensions: ctx .turn .config @@ -263,7 +263,7 @@ pub(crate) async fn prepare_unified_exec_zsh_fork( network: exec_request.network.clone(), windows_sandbox_level: exec_request.windows_sandbox_level, arg0: exec_request.arg0.clone(), - sandbox_policy_cwd: ctx.turn.cwd.clone(), + sandbox_policy_cwd: ctx.turn.cwd.to_path_buf(), macos_seatbelt_profile_extensions: ctx .turn .config diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index a760249d6..8631a2095 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -160,7 +160,7 @@ impl UnifiedExecProcessManager { let cwd = request .workdir .clone() - .unwrap_or_else(|| context.turn.cwd.clone()); + .unwrap_or_else(|| context.turn.cwd.to_path_buf()); let process = self .open_session_with_sandbox(&request, cwd.clone(), context) .await; diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index 0b3179340..484bd189e 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -14,6 +14,7 @@ use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; use codex_utils_absolute_path::AbsolutePathBuf; use regex_lite::Regex; +use std::path::Path; use std::path::PathBuf; pub mod apps_test_server; @@ -104,6 +105,36 @@ pub fn test_absolute_path(unix_path: &str) -> AbsolutePathBuf { test_absolute_path_with_windows(unix_path, /*windows_path*/ None) } +pub trait PathExt { + fn abs(&self) -> AbsolutePathBuf; +} + +impl PathExt for Path { + fn abs(&self) -> AbsolutePathBuf { + AbsolutePathBuf::try_from(self.to_path_buf()).expect("path should already be absolute") + } +} + +pub trait PathBufExt { + fn abs(&self) -> AbsolutePathBuf; +} + +impl PathBufExt for PathBuf { + fn abs(&self) -> AbsolutePathBuf { + self.as_path().abs() + } +} + +pub trait TempDirExt { + fn abs(&self) -> AbsolutePathBuf; +} + +impl TempDirExt for TempDir { + fn abs(&self) -> AbsolutePathBuf { + self.path().abs() + } +} + pub fn test_tmp_path() -> AbsolutePathBuf { test_absolute_path_with_windows("/tmp", Some(r"C:\Users\codex\AppData\Local\Temp")) } diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 7a7ae7af4..7b0d41278 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -37,7 +37,10 @@ use serde_json::Value; use tempfile::TempDir; use wiremock::MockServer; +use crate::PathBufExt; +use crate::PathExt; use crate::RemoteEnvConfig; +use crate::TempDirExt; use crate::get_remote_test_env; use crate::load_default_config_for_test; use crate::responses::WebSocketTestServer; @@ -297,8 +300,7 @@ fn docker_command_capture_stdout(args: [&str; N]) -> Result Result { - AbsolutePathBuf::try_from(path.to_path_buf()) - .map_err(|err| anyhow!("invalid absolute path {}: {err}", path.display())) + Ok(path.abs()) } /// A collection of different ways the model can output an apply_patch call @@ -393,7 +395,7 @@ impl TestCodexBuilder { let cwd = test_env.cwd.to_path_buf(); self.config_mutators.push(Box::new(move |config| { config.experimental_exec_server_url = experimental_exec_server_url; - config.cwd = cwd; + config.cwd = cwd.abs(); })); let mut test = self.build(server).await?; @@ -556,7 +558,7 @@ impl TestCodexBuilder { }; let cwd = Arc::new(TempDir::new()?); let mut config = load_default_config_for_test(home).await; - config.cwd = cwd.path().to_path_buf(); + config.cwd = cwd.abs(); config.model_provider = model_provider; for hook in self.pre_build_hooks.drain(..) { hook(home.path()); @@ -716,7 +718,7 @@ impl TestCodex { text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: self.config.cwd.clone(), + cwd: self.config.cwd.to_path_buf(), approval_policy, approvals_reviewer: None, sandbox_policy, diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 5da71f556..869acc49f 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -42,6 +42,7 @@ use codex_protocol::protocol::SessionMeta; use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::SessionSource; use codex_protocol::user_input::UserInput; +use core_test_support::PathBufExt; use core_test_support::apps_test_server::AppsTestServer; use core_test_support::load_default_config_for_test; use core_test_support::responses::ev_completed; @@ -1105,7 +1106,7 @@ async fn skills_append_to_developer_message() { .with_home(codex_home.clone()) .with_auth(CodexAuth::from_api_key("Test API Key")) .with_config(move |config| { - config.cwd = codex_home_path; + config.cwd = codex_home_path.abs(); }); let codex = builder .build(&server) @@ -1308,7 +1309,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re text: "hello".into(), text_elements: Vec::new(), }], - cwd: config.cwd.clone(), + cwd: config.cwd.to_path_buf(), approval_policy: config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: config.permissions.sandbox_policy.get().clone(), @@ -1426,7 +1427,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() text: "hello".into(), text_elements: Vec::new(), }], - cwd: config.cwd.clone(), + cwd: config.cwd.to_path_buf(), approval_policy: config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: config.permissions.sandbox_policy.get().clone(), diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index 1517d9968..df8d6e4fe 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -176,7 +176,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { text: "hello".into(), text_elements: Vec::new(), }], - cwd: test.config.cwd.clone(), + cwd: test.config.cwd.to_path_buf(), approval_policy: test.config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: test.config.permissions.sandbox_policy.get().clone(), @@ -292,7 +292,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu text: "hello".into(), text_elements: Vec::new(), }], - cwd: test.config.cwd.clone(), + cwd: test.config.cwd.to_path_buf(), approval_policy: test.config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: test.config.permissions.sandbox_policy.get().clone(), diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index 7da4d4d77..5f01e10d5 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -567,11 +567,11 @@ async fn snapshot_rollback_followup_turn_trims_context_updates() -> Result<()> { user_turn(&conversation, TURN_ONE_USER).await; - let override_cwd = config.cwd.join(PRETURN_CONTEXT_DIFF_CWD); + let override_cwd = config.cwd.join(PRETURN_CONTEXT_DIFF_CWD)?; std::fs::create_dir_all(&override_cwd)?; conversation .submit(Op::OverrideTurnContext { - cwd: Some(override_cwd), + cwd: Some(override_cwd.to_path_buf()), approval_policy: None, approvals_reviewer: None, sandbox_policy: None, diff --git a/codex-rs/core/tests/suite/hierarchical_agents.rs b/codex-rs/core/tests/suite/hierarchical_agents.rs index e1c45d641..8a3fadfbb 100644 --- a/codex-rs/core/tests/suite/hierarchical_agents.rs +++ b/codex-rs/core/tests/suite/hierarchical_agents.rs @@ -23,7 +23,14 @@ async fn hierarchical_agents_appends_to_project_doc_in_user_instructions() { .features .enable(Feature::ChildAgentsMd) .expect("test config should allow feature update"); - std::fs::write(config.cwd.join("AGENTS.md"), "be nice").expect("write AGENTS.md"); + std::fs::write( + config + .cwd + .join("AGENTS.md") + .expect("absolute AGENTS.md path"), + "be nice", + ) + .expect("write AGENTS.md"); }); let test = builder.build(&server).await.expect("build test codex"); diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 9be83e6bb..1bcd06861 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -16,7 +16,7 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::user_input::UserInput; -use codex_utils_absolute_path::AbsolutePathBuf; +use core_test_support::TempDirExt; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once; @@ -689,7 +689,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res let new_cwd = TempDir::new().unwrap(); let writable = TempDir::new().unwrap(); let new_policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![AbsolutePathBuf::try_from(writable.path()).unwrap()], + writable_roots: vec![writable.abs()], read_only_access: Default::default(), network_access: true, exclude_tmpdir_env_var: true, @@ -814,7 +814,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a text: "hello 1".into(), text_elements: Vec::new(), }], - cwd: default_cwd.clone(), + cwd: default_cwd.to_path_buf(), approval_policy: default_approval_policy, approvals_reviewer: None, sandbox_policy: default_sandbox_policy.clone(), @@ -835,7 +835,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a text: "hello 2".into(), text_elements: Vec::new(), }], - cwd: default_cwd.clone(), + cwd: default_cwd.to_path_buf(), approval_policy: default_approval_policy, approvals_reviewer: None, sandbox_policy: default_sandbox_policy.clone(), @@ -940,7 +940,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu text: "hello 1".into(), text_elements: Vec::new(), }], - cwd: default_cwd.clone(), + cwd: default_cwd.to_path_buf(), approval_policy: default_approval_policy, approvals_reviewer: None, sandbox_policy: default_sandbox_policy.clone(), @@ -961,7 +961,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu text: "hello 2".into(), text_elements: Vec::new(), }], - cwd: default_cwd.clone(), + cwd: default_cwd.to_path_buf(), approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index b1e6a694c..e60900543 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -28,7 +28,7 @@ fn resume_history( let turn_ctx = TurnContextItem { turn_id: Some(turn_id.clone()), trace_id: None, - cwd: config.cwd.clone(), + cwd: config.cwd.to_path_buf(), current_date: None, timezone: None, approval_policy: config.permissions.approval_policy.value(), diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index 81d3d1e6d..7aa05f972 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; use codex_protocol::user_input::UserInput; +use core_test_support::PathBufExt; use core_test_support::load_sse_fixture_with_id_from_str; use core_test_support::responses::ResponseMock; use core_test_support::responses::mount_sse_sequence; @@ -817,7 +818,7 @@ async fn review_uses_overridden_cwd_for_base_branch_merge_base() { let codex_home = Arc::new(TempDir::new().unwrap()); let initial_cwd_path = initial_cwd.path().to_path_buf(); let codex = new_conversation_for_server(&server, codex_home.clone(), move |config| { - config.cwd = initial_cwd_path; + config.cwd = initial_cwd_path.abs(); }) .await; diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index 801ff7627..c0fe8b535 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -10,6 +10,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::user_input::UserInput; +use core_test_support::PathBufExt; use core_test_support::assert_regex_match; use core_test_support::responses; use core_test_support::responses::ev_assistant_message; @@ -46,7 +47,7 @@ async fn user_shell_cmd_ls_and_cat_in_temp_dir() { let server = start_mock_server().await; let cwd_path = cwd.path().to_path_buf(); let mut builder = test_codex().with_config(move |config| { - config.cwd = cwd_path; + config.cwd = cwd_path.abs(); }); let codex = builder .build(&server) diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 732d6b29e..4041ca1d1 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -41,7 +41,6 @@ use image::load_from_memory; use pretty_assertions::assert_eq; use serde_json::Value; use std::io::Cursor; -use std::path::Path; use std::path::PathBuf; use tokio::time::Duration; use wiremock::BodyPrintLimit; @@ -78,11 +77,6 @@ fn find_image_message(body: &Value) -> Option<&Value> { image_messages(body).into_iter().next() } -fn absolute_path(path: &Path) -> anyhow::Result { - codex_utils_absolute_path::AbsolutePathBuf::try_from(path.to_path_buf()) - .map_err(|err| anyhow::anyhow!("invalid absolute path {}: {err}", path.display())) -} - fn png_bytes(width: u32, height: u32, rgba: [u8; 4]) -> anyhow::Result> { let image = ImageBuffer::from_pixel(width, height, Rgba(rgba)); let mut cursor = Cursor::new(Vec::new()); @@ -91,14 +85,11 @@ fn png_bytes(width: u32, height: u32, rgba: [u8; 4]) -> anyhow::Result> } async fn create_workspace_directory(test: &TestCodex, rel_path: &str) -> anyhow::Result { - let abs_path = test.config.cwd.join(rel_path); + let abs_path = test.config.cwd.join(rel_path)?; test.fs() - .create_directory( - &absolute_path(&abs_path)?, - CreateDirectoryOptions { recursive: true }, - ) + .create_directory(&abs_path, CreateDirectoryOptions { recursive: true }) .await?; - Ok(abs_path) + Ok(abs_path.into_path_buf()) } async fn write_workspace_file( @@ -106,19 +97,14 @@ async fn write_workspace_file( rel_path: &str, contents: Vec, ) -> anyhow::Result { - let abs_path = test.config.cwd.join(rel_path); + let abs_path = test.config.cwd.join(rel_path)?; if let Some(parent) = abs_path.parent() { test.fs() - .create_directory( - &absolute_path(parent)?, - CreateDirectoryOptions { recursive: true }, - ) + .create_directory(&parent, CreateDirectoryOptions { recursive: true }) .await?; } - test.fs() - .write_file(&absolute_path(&abs_path)?, contents) - .await?; - Ok(abs_path) + test.fs().write_file(&abs_path, contents).await?; + Ok(abs_path.into_path_buf()) } async fn write_workspace_png( @@ -168,7 +154,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { path: abs_path.clone(), }], final_output_json_schema: None, - cwd: config.cwd.clone(), + cwd: config.cwd.to_path_buf(), approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, @@ -240,7 +226,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { let cwd = config.cwd.clone(); let rel_path = "assets/example.png"; - let abs_path = cwd.join(rel_path); + let abs_path = cwd.join(rel_path)?; let original_width = 2304; let original_height = 864; write_workspace_png( @@ -277,7 +263,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: cwd.clone(), + cwd: cwd.to_path_buf(), approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, @@ -312,7 +298,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { _ => unreachable!("stored event must be ViewImageToolCall"), }; assert_eq!(tool_event.call_id, call_id); - assert_eq!(tool_event.path, abs_path); + assert_eq!(tool_event.path, abs_path.to_path_buf()); let req = mock.single_request(); let body = req.body_json(); @@ -418,7 +404,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: config.cwd.clone(), + cwd: config.cwd.to_path_buf(), approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, @@ -517,7 +503,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: config.cwd.clone(), + cwd: config.cwd.to_path_buf(), approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, @@ -609,7 +595,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: config.cwd.clone(), + cwd: config.cwd.to_path_buf(), approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, @@ -709,7 +695,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: config.cwd.clone(), + cwd: config.cwd.to_path_buf(), approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, @@ -820,7 +806,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: config.cwd.clone(), + cwd: config.cwd.to_path_buf(), approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, @@ -1135,7 +1121,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: config.cwd.clone(), + cwd: config.cwd.to_path_buf(), approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, @@ -1211,7 +1197,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: config.cwd.clone(), + cwd: config.cwd.to_path_buf(), approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, @@ -1265,7 +1251,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { } = &test; let rel_path = "missing/example.png"; - let abs_path = config.cwd.join(rel_path); + let abs_path = config.cwd.join(rel_path)?; let call_id = "view-image-missing"; let arguments = serde_json::json!({ "path": rel_path }).to_string(); @@ -1292,7 +1278,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: config.cwd.clone(), + cwd: config.cwd.to_path_buf(), approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, @@ -1415,7 +1401,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: config.cwd.clone(), + cwd: config.cwd.to_path_buf(), approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, @@ -1490,7 +1476,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> path: abs_path.clone(), }], final_output_json_schema: None, - cwd: config.cwd.clone(), + cwd: config.cwd.to_path_buf(), approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 56e542f59..43ccc7865 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -34,6 +34,8 @@ use crate::pager_overlay::Overlay; use crate::render::highlight::highlight_bash_to_lines; use crate::render::renderable::Renderable; use crate::resume_picker::SessionSelection; +#[cfg(test)] +use crate::test_support::PathBufExt; use crate::tui; use crate::tui::TuiEvent; use crate::update_action::UpdateAction; @@ -1029,7 +1031,7 @@ impl App { async fn refresh_in_memory_config_from_disk(&mut self) -> Result<()> { let mut config = self - .rebuild_config_for_cwd(self.chat_widget.config_ref().cwd.clone()) + .rebuild_config_for_cwd(self.chat_widget.config_ref().cwd.to_path_buf()) .await?; self.apply_runtime_policy_overrides(&mut config); self.config = config; @@ -1528,7 +1530,7 @@ impl App { self.chat_widget.current_model(), self.chat_widget.current_service_tier(), ), - self.config.cwd.clone(), + self.config.cwd.to_path_buf(), version, ) .display_lines(width) @@ -1781,7 +1783,7 @@ impl App { cwd: self .thread_cwd(thread_id) .await - .unwrap_or_else(|| self.config.cwd.clone()), + .unwrap_or_else(|| self.config.cwd.to_path_buf()), changes: ev.changes.clone(), }, )), @@ -2545,7 +2547,7 @@ impl App { chat_widget .maybe_prompt_windows_sandbox_enable(should_prompt_windows_sandbox_nux_at_startup); - let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); + let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone()); #[cfg(not(debug_assertions))] let upgrade_version = crate::updates::get_upgrade_version(&config); @@ -2612,7 +2614,13 @@ impl App { let tx = app.app_event_tx.clone(); let logs_base_dir = app.config.codex_home.clone(); let sandbox_policy = app.config.permissions.sandbox_policy.get().clone(); - Self::spawn_world_writable_scan(cwd, env_map, logs_base_dir, sandbox_policy, tx); + Self::spawn_world_writable_scan( + cwd.to_path_buf(), + env_map, + logs_base_dir, + sandbox_policy, + tx, + ); } } @@ -2815,7 +2823,7 @@ impl App { .await? { SessionSelection::Resume(target_session) => { - let current_cwd = self.config.cwd.clone(); + let current_cwd = self.config.cwd.to_path_buf(); let resume_cwd = match crate::resolve_cwd_for_resume_or_fork( tui, &self.config, @@ -2865,7 +2873,8 @@ impl App { self.shutdown_current_thread().await; self.config = resume_config; tui.set_notification_method(self.config.tui_notification_method); - self.file_search.update_search_dir(self.config.cwd.clone()); + self.file_search + .update_search_dir(self.config.cwd.to_path_buf()); let init = self.chatwidget_init_for_forked_or_resumed_thread( tui, self.config.clone(), @@ -3192,7 +3201,8 @@ impl App { plugin_display_name, result, ); - if install_succeeded && self.chat_widget.config_ref().cwd == cwd { + if install_succeeded && self.chat_widget.config_ref().cwd.as_path() == cwd.as_path() + { self.fetch_plugins_list(cwd.clone()); if should_refresh_plugin_detail { self.fetch_plugin_detail( @@ -3644,7 +3654,9 @@ impl App { plugin_display_name, result, ); - if uninstall_succeeded && self.chat_widget.config_ref().cwd == cwd { + if uninstall_succeeded + && self.chat_widget.config_ref().cwd.as_path() == cwd.as_path() + { self.fetch_plugins_list(cwd); } } @@ -3831,7 +3843,7 @@ impl App { let logs_base_dir = self.config.codex_home.clone(); let sandbox_policy = self.config.permissions.sandbox_policy.get().clone(); Self::spawn_world_writable_scan( - cwd, + cwd.to_path_buf(), env_map, logs_base_dir, sandbox_policy, @@ -4981,7 +4993,7 @@ mod tests { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), - cwd: PathBuf::from("/tmp/project"), + cwd: PathBuf::from("/tmp/project").abs().to_path_buf(), reasoning_effort: None, history_log_id: 0, history_entry_count: 0, @@ -6121,7 +6133,7 @@ mod tests { let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; let codex_home = tempdir()?; app.config.codex_home = codex_home.path().to_path_buf(); - let config_toml_path = AbsolutePathBuf::try_from(codex_home.path().join("config.toml"))?; + let config_toml_path = codex_home.path().join("config.toml").abs(); let config_toml = "approvals_reviewer = \"guardian_subagent\"\napproval_policy = \"on-request\"\nsandbox_mode = \"workspace-write\"\n\n[features]\nguardian_approval = true\n"; std::fs::write(config_toml_path.as_path(), config_toml)?; let user_config = toml::from_str::(config_toml)?; @@ -6213,7 +6225,7 @@ mod tests { let codex_home = tempdir()?; app.config.codex_home = codex_home.path().to_path_buf(); let guardian_approvals = guardian_approvals_mode(); - let config_toml_path = AbsolutePathBuf::try_from(codex_home.path().join("config.toml"))?; + let config_toml_path = codex_home.path().join("config.toml").abs(); let config_toml = "approvals_reviewer = \"user\"\n"; std::fs::write(config_toml_path.as_path(), config_toml)?; let user_config = toml::from_str::(config_toml)?; @@ -6280,7 +6292,7 @@ mod tests { let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; let codex_home = tempdir()?; app.config.codex_home = codex_home.path().to_path_buf(); - let config_toml_path = AbsolutePathBuf::try_from(codex_home.path().join("config.toml"))?; + let config_toml_path = codex_home.path().join("config.toml").abs(); let config_toml = "approvals_reviewer = \"user\"\napproval_policy = \"on-request\"\nsandbox_mode = \"workspace-write\"\n\n[features]\nguardian_approval = true\n"; std::fs::write(config_toml_path.as_path(), config_toml)?; let user_config = toml::from_str::(config_toml)?; @@ -6341,7 +6353,7 @@ mod tests { app.config.codex_home = codex_home.path().to_path_buf(); let guardian_approvals = guardian_approvals_mode(); app.active_profile = Some("guardian".to_string()); - let config_toml_path = AbsolutePathBuf::try_from(codex_home.path().join("config.toml"))?; + let config_toml_path = codex_home.path().join("config.toml").abs(); let config_toml = "profile = \"guardian\"\napprovals_reviewer = \"user\"\n"; std::fs::write(config_toml_path.as_path(), config_toml)?; let user_config = toml::from_str::(config_toml)?; @@ -6411,7 +6423,7 @@ mod tests { let codex_home = tempdir()?; app.config.codex_home = codex_home.path().to_path_buf(); app.active_profile = Some("guardian".to_string()); - let config_toml_path = AbsolutePathBuf::try_from(codex_home.path().join("config.toml"))?; + let config_toml_path = codex_home.path().join("config.toml").abs(); let config_toml = r#" profile = "guardian" approvals_reviewer = "user" @@ -6499,7 +6511,7 @@ guardian_approval = true let codex_home = tempdir()?; app.config.codex_home = codex_home.path().to_path_buf(); app.active_profile = Some("guardian".to_string()); - let config_toml_path = AbsolutePathBuf::try_from(codex_home.path().join("config.toml"))?; + let config_toml_path = codex_home.path().join("config.toml").abs(); let config_toml = "profile = \"guardian\"\napprovals_reviewer = \"guardian_subagent\"\n\n[features]\nguardian_approval = true\n"; std::fs::write(config_toml_path.as_path(), config_toml)?; let user_config = toml::from_str::(config_toml)?; @@ -6833,7 +6845,7 @@ guardian_approval = true async fn render_clear_ui_header_after_long_transcript_for_snapshot() -> String { let mut app = make_test_app().await; - app.config.cwd = PathBuf::from("/tmp/project"); + app.config.cwd = PathBuf::from("/tmp/project").abs(); app.chat_widget.set_model("gpt-test"); app.chat_widget .set_reasoning_effort(Some(ReasoningEffortConfig::High)); @@ -6944,21 +6956,33 @@ guardian_approval = true } #[tokio::test] + #[cfg_attr( + target_os = "windows", + ignore = "snapshot path rendering differs on Windows" + )] async fn clear_ui_after_long_transcript_snapshots_fresh_header_only() { let rendered = render_clear_ui_header_after_long_transcript_for_snapshot().await; assert_snapshot!("clear_ui_after_long_transcript_fresh_header_only", rendered); } #[tokio::test] + #[cfg_attr( + target_os = "windows", + ignore = "snapshot path rendering differs on Windows" + )] async fn ctrl_l_clear_ui_after_long_transcript_reuses_clear_header_snapshot() { let rendered = render_clear_ui_header_after_long_transcript_for_snapshot().await; assert_snapshot!("clear_ui_after_long_transcript_fresh_header_only", rendered); } #[tokio::test] + #[cfg_attr( + target_os = "windows", + ignore = "snapshot path rendering differs on Windows" + )] async fn clear_ui_header_shows_fast_status_only_for_gpt54() { let mut app = make_test_app().await; - app.config.cwd = PathBuf::from("/tmp/project"); + app.config.cwd = PathBuf::from("/tmp/project").abs(); app.chat_widget.set_model("gpt-5.4"); app.chat_widget .set_reasoning_effort(Some(ReasoningEffortConfig::XHigh)); @@ -6993,7 +7017,7 @@ guardian_approval = true let auth_manager = codex_core::test_support::auth_manager_from_auth( CodexAuth::from_api_key("Test API Key"), ); - let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); + let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone()); let model = codex_core::test_support::get_model_offline(config.model.as_deref()); let session_telemetry = test_session_telemetry(&config, model.as_str()); @@ -7056,7 +7080,7 @@ guardian_approval = true let auth_manager = codex_core::test_support::auth_manager_from_auth( CodexAuth::from_api_key("Test API Key"), ); - let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); + let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone()); let model = codex_core::test_support::get_model_offline(config.model.as_deref()); let session_telemetry = test_session_telemetry(&config, model.as_str()); @@ -7555,7 +7579,7 @@ guardian_approval = true }), }); - assert_eq!(app.chat_widget.config_ref().cwd, next_cwd); + assert_eq!(app.chat_widget.config_ref().cwd.to_path_buf(), next_cwd); assert_eq!(app.config.cwd, original_cwd); app.refresh_in_memory_config_from_disk().await?; @@ -7575,7 +7599,7 @@ guardian_approval = true let current_cwd = current_config.cwd.clone(); let resume_config = app - .rebuild_config_for_resume_or_fallback(¤t_cwd, current_cwd.clone()) + .rebuild_config_for_resume_or_fallback(¤t_cwd, current_cwd.to_path_buf()) .await?; assert_eq!(resume_config, current_config); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 4c3c30e81..a4a7155ed 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -163,6 +163,7 @@ use codex_terminal_detection::Multiplexer; use codex_terminal_detection::TerminalInfo; use codex_terminal_detection::TerminalName; use codex_terminal_detection::terminal_info; +use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_sleep_inhibitor::SleepInhibitor; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -1416,7 +1417,12 @@ impl ChatWidget { self.forked_from = event.forked_from_id; self.current_rollout_path = event.rollout_path.clone(); self.current_cwd = Some(event.cwd.clone()); - self.config.cwd = event.cwd.clone(); + match AbsolutePathBuf::try_from(event.cwd.clone()) { + Ok(cwd) => self.config.cwd = cwd, + Err(err) => { + tracing::warn!(path = %event.cwd.display(), %err, "session cwd should be absolute"); + } + } if let Err(err) = self .config .permissions @@ -3475,13 +3481,13 @@ impl ChatWidget { id: ev.call_id, reason: ev.reason, changes: ev.changes.clone(), - cwd: self.config.cwd.clone(), + cwd: self.config.cwd.to_path_buf(), }; self.bottom_pane .push_approval_request(request, &self.config.features); self.request_redraw(); self.notify(Notification::EditApprovalRequested { - cwd: self.config.cwd.clone(), + cwd: self.config.cwd.to_path_buf(), changes: ev.changes.keys().cloned().collect(), }); } @@ -3711,7 +3717,7 @@ impl ChatWidget { let active_cell = Some(Self::placeholder_session_header_cell(&config)); - let current_cwd = Some(config.cwd.clone()); + let current_cwd = Some(config.cwd.to_path_buf()); let queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info()); let mut widget = Self { app_event_tx: app_event_tx.clone(), @@ -3914,7 +3920,7 @@ impl ChatWidget { }; let active_cell = Some(Self::placeholder_session_header_cell(&config)); - let current_cwd = Some(config.cwd.clone()); + let current_cwd = Some(config.cwd.to_path_buf()); let queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info()); let mut widget = Self { @@ -4537,7 +4543,15 @@ impl ChatWidget { self.app_event_tx.send(AppEvent::ForkCurrentSession); } SlashCommand::Init => { - let init_target = self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME); + let init_target = match self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME) { + Ok(path) => path, + Err(err) => { + self.add_error_message(format!( + "Failed to prepare {DEFAULT_PROJECT_DOC_FILENAME}: {err}", + )); + return; + } + }; if init_target.exists() { let message = format!( "{DEFAULT_PROJECT_DOC_FILENAME} already exists here. Skipping /init to avoid overwriting it." @@ -5268,7 +5282,7 @@ impl ChatWidget { let service_tier = self.config.service_tier.map(Some); let op = Op::UserTurn { items, - cwd: self.config.cwd.clone(), + cwd: self.config.cwd.to_path_buf(), approval_policy: self.config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: self.config.permissions.sandbox_policy.get().clone(), @@ -8409,7 +8423,7 @@ impl ChatWidget { placeholder_style, /*reasoning_effort*/ None, /*show_fast_status*/ false, - config.cwd.clone(), + config.cwd.to_path_buf(), CODEX_CLI_VERSION, )) } @@ -9158,7 +9172,7 @@ impl ChatWidget { name: "Review against a base branch".to_string(), description: Some("(PR Style)".into()), actions: vec![Box::new({ - let cwd = self.config.cwd.clone(); + let cwd = self.config.cwd.to_path_buf(); move |tx| { tx.send(AppEvent::OpenReviewBranchPicker(cwd.clone())); } @@ -9185,7 +9199,7 @@ impl ChatWidget { items.push(SelectionItem { name: "Review a commit".to_string(), actions: vec![Box::new({ - let cwd = self.config.cwd.clone(); + let cwd = self.config.cwd.to_path_buf(); move |tx| { tx.send(AppEvent::OpenReviewCommitPicker(cwd.clone())); } diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index 120b7237e..588884639 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -159,11 +159,11 @@ impl ChatWidget { cwd: PathBuf, result: Result, ) { - if self.plugins_fetch_state.in_flight_cwd.as_ref() == Some(&cwd) { + if self.plugins_fetch_state.in_flight_cwd.as_deref() == Some(cwd.as_path()) { self.plugins_fetch_state.in_flight_cwd = None; } - if self.config.cwd != cwd { + if self.config.cwd.as_path() != cwd.as_path() { return; } @@ -191,13 +191,13 @@ impl ChatWidget { } fn prefetch_plugins(&mut self) { - let cwd = self.config.cwd.clone(); - if self.plugins_fetch_state.in_flight_cwd.as_ref() == Some(&cwd) { + let cwd = self.config.cwd.to_path_buf(); + if self.plugins_fetch_state.in_flight_cwd.as_deref() == Some(cwd.as_path()) { return; } self.plugins_fetch_state.in_flight_cwd = Some(cwd.clone()); - if self.plugins_fetch_state.cache_cwd.as_ref() != Some(&cwd) { + if self.plugins_fetch_state.cache_cwd.as_deref() != Some(cwd.as_path()) { self.plugins_cache = PluginsCacheState::Loading; } @@ -205,7 +205,7 @@ impl ChatWidget { } fn plugins_cache_for_current_cwd(&self) -> PluginsCacheState { - if self.plugins_fetch_state.cache_cwd.as_ref() == Some(&self.config.cwd) { + if self.plugins_fetch_state.cache_cwd.as_deref() == Some(self.config.cwd.as_path()) { self.plugins_cache.clone() } else { PluginsCacheState::Uninitialized @@ -253,7 +253,7 @@ impl ChatWidget { cwd: PathBuf, result: Result, ) { - if self.config.cwd != cwd { + if self.config.cwd.as_path() != cwd.as_path() { return; } @@ -288,7 +288,7 @@ impl ChatWidget { plugin_display_name: String, result: Result, ) -> bool { - if self.config.cwd != cwd { + if self.config.cwd.as_path() != cwd.as_path() { return true; } @@ -346,7 +346,7 @@ impl ChatWidget { plugin_display_name: String, result: Result, ) { - if self.config.cwd != cwd { + if self.config.cwd.as_path() != cwd.as_path() { return; } @@ -676,7 +676,7 @@ impl ChatWidget { ..Default::default() }]; if let Some(plugins_response) = plugins_response.cloned() { - let cwd = self.config.cwd.clone(); + let cwd = self.config.cwd.to_path_buf(); items.push(SelectionItem { name: "Back to plugins".to_string(), description: Some("Return to the plugin list.".to_string()), @@ -770,7 +770,7 @@ impl ChatWidget { "{display_name} {} {} {}", plugin.id, plugin.name, marketplace_label ); - let cwd = self.config.cwd.clone(); + let cwd = self.config.cwd.to_path_buf(); let plugin_display_name = display_name.clone(); let marketplace_path = marketplace.path.clone(); let plugin_name = plugin.name.clone(); @@ -861,7 +861,7 @@ impl ChatWidget { header.push(Line::from(description.dim())); } - let cwd = self.config.cwd.clone(); + let cwd = self.config.cwd.to_path_buf(); let plugins_response = plugins_response.clone(); let mut items = vec![SelectionItem { name: "Back to plugins".to_string(), @@ -877,7 +877,7 @@ impl ChatWidget { }]; if plugin.summary.installed { - let uninstall_cwd = self.config.cwd.clone(); + let uninstall_cwd = self.config.cwd.to_path_buf(); let plugin_id = plugin.summary.id.clone(); let plugin_display_name = display_name; items.push(SelectionItem { @@ -906,7 +906,7 @@ impl ChatWidget { ..Default::default() }); } else { - let install_cwd = self.config.cwd.clone(); + let install_cwd = self.config.cwd.to_path_buf(); let marketplace_path = plugin.marketplace_path.clone(); let plugin_name = plugin.summary.name.clone(); let plugin_display_name = display_name; diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index 5888b3b1b..837ef0504 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -326,7 +326,9 @@ impl ChatWidget { } fn status_line_cwd(&self) -> &Path { - self.current_cwd.as_ref().unwrap_or(&self.config.cwd) + self.current_cwd + .as_deref() + .unwrap_or(self.config.cwd.as_path()) } /// Resolves the project root associated with `cwd`. diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index e1314e27f..41e9b4989 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -16,6 +16,8 @@ use crate::bottom_pane::MentionBinding; use crate::chatwidget::realtime::RealtimeConversationPhase; use crate::history_cell::UserHistoryCell; use crate::test_backend::VT100Backend; +use crate::test_support::PathBufExt; +use crate::test_support::test_path_display; use crate::tui::FrameRequester; use assert_matches::assert_matches; use codex_app_server_protocol::AppSummary; @@ -434,10 +436,10 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { .sandbox_policy .set(SandboxPolicy::new_workspace_write_policy()) .expect("set sandbox policy"); - chat.config.cwd = PathBuf::from("/home/user/main"); + chat.config.cwd = PathBuf::from("/home/user/main").abs(); let expected_sandbox = SandboxPolicy::new_read_only_policy(); - let expected_cwd = PathBuf::from("/home/user/sub-agent"); + let expected_cwd = PathBuf::from("/home/user/sub-agent").abs(); let configured = codex_protocol::protocol::SessionConfiguredEvent { session_id: ThreadId::new(), forked_from_id: None, @@ -448,7 +450,7 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: expected_sandbox.clone(), - cwd: expected_cwd.clone(), + cwd: expected_cwd.to_path_buf(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, history_entry_count: 0, @@ -5801,7 +5803,7 @@ async fn slash_init_skips_when_project_doc_exists() { let tempdir = tempdir().unwrap(); let existing_path = tempdir.path().join(DEFAULT_PROJECT_DOC_FILENAME); std::fs::write(&existing_path, "existing instructions").unwrap(); - chat.config.cwd = tempdir.path().to_path_buf(); + chat.config.cwd = tempdir.path().to_path_buf().abs(); chat.dispatch_command(SlashCommand::Init); @@ -6822,13 +6824,17 @@ async fn custom_prompt_enter_empty_does_not_send() { #[tokio::test] async fn view_image_tool_call_adds_history_cell() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - let image_path = chat.config.cwd.join("example.png"); + let image_path = chat + .config + .cwd + .join("example.png") + .expect("absolute image path"); chat.handle_codex_event(Event { id: "sub-image".into(), msg: EventMsg::ViewImageToolCall(ViewImageToolCallEvent { call_id: "call-image".into(), - path: image_path, + path: image_path.to_path_buf(), }), }); @@ -7118,12 +7124,10 @@ fn strip_osc8_for_snapshot(text: &str) -> String { } fn plugins_test_absolute_path(path: &str) -> AbsolutePathBuf { - AbsolutePathBuf::try_from( - std::env::temp_dir() - .join("codex-plugin-menu-tests") - .join(path), - ) - .expect("expected absolute test path") + std::env::temp_dir() + .join("codex-plugin-menu-tests") + .join(path) + .abs() } fn plugins_test_interface( @@ -7205,7 +7209,7 @@ fn plugins_test_response(marketplaces: Vec) -> PluginLis fn render_loaded_plugins_popup(chat: &mut ChatWidget, response: PluginListResponse) -> String { let cwd = chat.config.cwd.clone(); - chat.on_plugins_loaded(cwd, Ok(response)); + chat.on_plugins_loaded(cwd.to_path_buf(), Ok(response)); chat.add_plugins_output(); render_bottom_popup(chat, 100) } @@ -7356,10 +7360,10 @@ async fn plugin_detail_popup_snapshot_shows_install_actions_and_capability_summa summary.clone(), ])]); let cwd = chat.config.cwd.clone(); - chat.on_plugins_loaded(cwd.clone(), Ok(response)); + chat.on_plugins_loaded(cwd.to_path_buf(), Ok(response)); chat.add_plugins_output(); chat.on_plugin_detail_loaded( - cwd, + cwd.to_path_buf(), Ok(PluginReadResponse { plugin: plugins_test_detail( summary, @@ -7396,10 +7400,10 @@ async fn plugin_detail_popup_hides_disclosure_for_installed_plugins() { summary.clone(), ])]); let cwd = chat.config.cwd.clone(); - chat.on_plugins_loaded(cwd.clone(), Ok(response)); + chat.on_plugins_loaded(cwd.to_path_buf(), Ok(response)); chat.add_plugins_output(); chat.on_plugin_detail_loaded( - cwd, + cwd.to_path_buf(), Ok(PluginReadResponse { plugin: plugins_test_detail( summary, @@ -7486,7 +7490,7 @@ async fn plugins_popup_refresh_replaces_selection_with_first_row() { ), ])]); let cwd = chat.config.cwd.clone(); - chat.on_plugins_loaded(cwd, Ok(refreshed)); + chat.on_plugins_loaded(cwd.to_path_buf(), Ok(refreshed)); let after = render_bottom_popup(&chat, 100); assert!( @@ -7555,7 +7559,7 @@ async fn plugins_popup_refreshes_installed_counts_after_install() { ), ])]); let cwd = chat.config.cwd.clone(); - chat.on_plugins_loaded(cwd, Ok(refreshed)); + chat.on_plugins_loaded(cwd.to_path_buf(), Ok(refreshed)); let after = render_bottom_popup(&chat, 100); assert!( @@ -8271,8 +8275,7 @@ async fn apps_initial_load_applies_enabled_state_from_config() { chat.bottom_pane.set_connectors_enabled(true); let temp = tempdir().expect("tempdir"); - let config_toml_path = - AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute config path"); + let config_toml_path = temp.path().join("config.toml").abs(); let user_config = toml::from_str::( "[apps.connector_1]\nenabled = false\ndisabled_reason = \"user\"\n", ) @@ -8336,8 +8339,7 @@ async fn apps_initial_load_applies_enabled_state_from_requirements_with_user_ove ..Default::default() }; let temp = tempdir().expect("tempdir"); - let config_toml_path = - AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute config path"); + let config_toml_path = temp.path().join("config.toml").abs(); chat.config.config_layer_stack = ConfigLayerStack::new(Vec::new(), ConfigRequirements::default(), requirements) .expect("requirements stack") @@ -8912,7 +8914,7 @@ async fn preset_matching_accepts_workspace_write_with_extra_roots() { .find(|p| p.id == "auto") .expect("auto preset exists"); let current_sandbox = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![AbsolutePathBuf::try_from("C:\\extra").unwrap()], + writable_roots: vec![PathBuf::from("C:\\extra").abs()], read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: false, @@ -9716,8 +9718,7 @@ async fn permissions_selection_marks_guardian_approvals_current_with_custom_work .features .set_enabled(Feature::GuardianApproval, true); - let extra_root = AbsolutePathBuf::try_from("/tmp/guardian-approvals-extra") - .expect("absolute extra writable root"); + let extra_root = PathBuf::from("/tmp/guardian-approvals-extra").abs(); chat.handle_codex_event(Event { id: "session-configured-custom-workspace".to_string(), @@ -11627,7 +11628,7 @@ async fn default_terminal_title_refreshes_when_spinner_state_changes() { let cwd = chat .current_cwd .clone() - .unwrap_or_else(|| chat.config.cwd.clone()); + .unwrap_or_else(|| chat.config.cwd.to_path_buf()); let project = get_git_repo_root(&cwd) .map(|root| { root.file_name() @@ -11866,7 +11867,7 @@ async fn status_line_fast_mode_footer_snapshot() { #[tokio::test] async fn status_line_model_with_reasoning_includes_fast_for_gpt54_only() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; - chat.config.cwd = PathBuf::from("/tmp/project"); + chat.config.cwd = PathBuf::from("/tmp/project").abs(); chat.config.tui_status_line = Some(vec![ "model-with-reasoning".to_string(), "context-remaining".to_string(), @@ -11876,10 +11877,11 @@ async fn status_line_model_with_reasoning_includes_fast_for_gpt54_only() { chat.set_service_tier(Some(ServiceTier::Fast)); set_chatgpt_auth(&mut chat); chat.refresh_status_surfaces(); + let test_cwd = test_path_display("/tmp/project"); assert_eq!( status_line_text(&chat), - Some("gpt-5.4 xhigh fast · 100% left · /tmp/project".to_string()) + Some(format!("gpt-5.4 xhigh fast · 100% left · {test_cwd}")) ); chat.set_model("gpt-5.3-codex"); @@ -11887,18 +11889,22 @@ async fn status_line_model_with_reasoning_includes_fast_for_gpt54_only() { assert_eq!( status_line_text(&chat), - Some("gpt-5.3-codex xhigh · 100% left · /tmp/project".to_string()) + Some(format!("gpt-5.3-codex xhigh · 100% left · {test_cwd}")) ); } #[tokio::test] +#[cfg_attr( + target_os = "windows", + ignore = "snapshot path rendering differs on Windows" +)] async fn status_line_model_with_reasoning_fast_footer_snapshot() { use ratatui::Terminal; use ratatui::backend::TestBackend; let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; chat.show_welcome_banner = false; - chat.config.cwd = PathBuf::from("/tmp/project"); + chat.config.cwd = PathBuf::from("/tmp/project").abs(); chat.config.tui_status_line = Some(vec![ "model-with-reasoning".to_string(), "context-remaining".to_string(), diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 8192724da..157e5feec 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -27,6 +27,8 @@ use crate::render::line_utils::push_owned_lines; use crate::render::renderable::Renderable; use crate::style::proposed_plan_style; use crate::style::user_message_style; +#[cfg(test)] +use crate::test_support::PathBufExt; use crate::text_formatting::format_and_truncate_tool_result; use crate::text_formatting::truncate_text; use crate::tooltips; @@ -1137,7 +1139,7 @@ pub(crate) fn new_session_info( model.clone(), reasoning_effort, show_fast_status, - config.cwd.clone(), + config.cwd.to_path_buf(), CODEX_CLI_VERSION, ); let mut parts: Vec> = vec![Box::new(header)]; @@ -2654,7 +2656,7 @@ mod tests { approval_policy: AskForApproval::Never, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), - cwd: PathBuf::from("/tmp/project"), + cwd: PathBuf::from("/tmp/project").abs().to_path_buf(), reasoning_effort: None, history_log_id: 0, history_entry_count: 0, @@ -2768,9 +2770,13 @@ mod tests { } #[tokio::test] + #[cfg_attr( + target_os = "windows", + ignore = "snapshot path rendering differs on Windows" + )] async fn session_info_availability_nux_tooltip_snapshot() { let mut config = test_config().await; - config.cwd = PathBuf::from("/tmp/project"); + config.cwd = PathBuf::from("/tmp/project").abs(); let cell = new_session_info( &config, "gpt-5", diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 039148a71..2fcf2e3dc 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -251,6 +251,8 @@ mod wrapping; #[cfg(test)] pub mod test_backend; +#[cfg(test)] +pub(crate) mod test_support; use crate::onboarding::onboarding_screen::OnboardingScreenArgs; use crate::onboarding::onboarding_screen::run_onboarding_app; diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index 127ac9f66..fe3cab73b 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -82,7 +82,7 @@ impl OnboardingScreen { auth_manager, config, } = args; - let cwd = config.cwd.clone(); + let cwd = config.cwd.to_path_buf(); let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone(); let forced_login_method = config.forced_login_method; let codex_home = config.codex_home.clone(); diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index a7546230b..cadd3383a 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -256,7 +256,7 @@ impl StatusHistoryCell { Self { model_name, model_details, - directory: config.cwd.clone(), + directory: config.cwd.to_path_buf(), permissions, agents_summary, collaboration_mode: collaboration_mode.map(ToString::to_string), diff --git a/codex-rs/tui/src/status/helpers.rs b/codex-rs/tui/src/status/helpers.rs index e09073d13..e14f27fdc 100644 --- a/codex-rs/tui/src/status/helpers.rs +++ b/codex-rs/tui/src/status/helpers.rs @@ -46,7 +46,7 @@ pub(crate) fn compose_agents_summary(config: &Config) -> String { .map(|name| name.to_string_lossy().to_string()) .unwrap_or_else(|| "".to_string()); let display = if let Some(parent) = p.parent() { - if parent == config.cwd { + if parent == config.cwd.as_path() { file_name.clone() } else { let mut cur = config.cwd.as_path(); diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 16ab60bd7..10f26445e 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -1,6 +1,7 @@ use super::new_status_output; use super::rate_limit_snapshot_display; use crate::history_cell::HistoryCell; +use crate::test_support::PathBufExt; use chrono::Duration as ChronoDuration; use chrono::TimeZone; use chrono::Utc; @@ -109,7 +110,7 @@ async fn status_snapshot_includes_reasoning_details() { }) .expect("set sandbox policy"); - config.cwd = PathBuf::from("/workspace/tests"); + config.cwd = PathBuf::from("/workspace/tests").abs(); let auth_manager = test_auth_manager(&config); let usage = TokenUsage { @@ -193,7 +194,7 @@ async fn status_permissions_non_default_workspace_write_is_custom() { exclude_slash_tmp: false, }) .expect("set sandbox policy"); - config.cwd = PathBuf::from("/workspace/tests"); + config.cwd = PathBuf::from("/workspace/tests").abs(); let auth_manager = test_auth_manager(&config); let usage = TokenUsage::default(); @@ -242,7 +243,7 @@ async fn status_snapshot_includes_forked_from() { let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); config.model_provider_id = "openai".to_string(); - config.cwd = PathBuf::from("/workspace/tests"); + config.cwd = PathBuf::from("/workspace/tests").abs(); let auth_manager = test_auth_manager(&config); let usage = TokenUsage { @@ -296,7 +297,7 @@ async fn status_snapshot_includes_monthly_limit() { let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); config.model_provider_id = "openai".to_string(); - config.cwd = PathBuf::from("/workspace/tests"); + config.cwd = PathBuf::from("/workspace/tests").abs(); let auth_manager = test_auth_manager(&config); let usage = TokenUsage { @@ -549,7 +550,7 @@ async fn status_card_token_usage_excludes_cached_tokens() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); - config.cwd = PathBuf::from("/workspace/tests"); + config.cwd = PathBuf::from("/workspace/tests").abs(); let auth_manager = test_auth_manager(&config); let usage = TokenUsage { @@ -597,7 +598,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() { config.model = Some("gpt-5.1-codex-max".to_string()); config.model_provider_id = "openai".to_string(); config.model_reasoning_summary = Some(ReasoningSummary::Detailed); - config.cwd = PathBuf::from("/workspace/tests"); + config.cwd = PathBuf::from("/workspace/tests").abs(); let auth_manager = test_auth_manager(&config); let usage = TokenUsage { @@ -660,7 +661,7 @@ async fn status_snapshot_shows_missing_limits_message() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); - config.cwd = PathBuf::from("/workspace/tests"); + config.cwd = PathBuf::from("/workspace/tests").abs(); let auth_manager = test_auth_manager(&config); let usage = TokenUsage { @@ -708,7 +709,7 @@ async fn status_snapshot_includes_credits_and_limits() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex".to_string()); - config.cwd = PathBuf::from("/workspace/tests"); + config.cwd = PathBuf::from("/workspace/tests").abs(); let auth_manager = test_auth_manager(&config); let usage = TokenUsage { @@ -777,7 +778,7 @@ async fn status_snapshot_shows_empty_limits_message() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); - config.cwd = PathBuf::from("/workspace/tests"); + config.cwd = PathBuf::from("/workspace/tests").abs(); let auth_manager = test_auth_manager(&config); let usage = TokenUsage { @@ -834,7 +835,7 @@ async fn status_snapshot_shows_stale_limits_message() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); - config.cwd = PathBuf::from("/workspace/tests"); + config.cwd = PathBuf::from("/workspace/tests").abs(); let auth_manager = test_auth_manager(&config); let usage = TokenUsage { @@ -900,7 +901,7 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex".to_string()); - config.cwd = PathBuf::from("/workspace/tests"); + config.cwd = PathBuf::from("/workspace/tests").abs(); let auth_manager = test_auth_manager(&config); let usage = TokenUsage { diff --git a/codex-rs/tui/src/test_support.rs b/codex-rs/tui/src/test_support.rs new file mode 100644 index 000000000..790fc805e --- /dev/null +++ b/codex-rs/tui/src/test_support.rs @@ -0,0 +1,28 @@ +use codex_utils_absolute_path::AbsolutePathBuf; +use std::path::Path; +use std::path::PathBuf; + +pub(crate) trait PathExt { + fn abs(&self) -> AbsolutePathBuf; +} + +impl PathExt for Path { + fn abs(&self) -> AbsolutePathBuf { + AbsolutePathBuf::try_from(self.to_path_buf()) + .unwrap_or_else(|_| panic!("path should already be absolute")) + } +} + +pub(crate) trait PathBufExt { + fn abs(&self) -> AbsolutePathBuf; +} + +impl PathBufExt for PathBuf { + fn abs(&self) -> AbsolutePathBuf { + self.as_path().abs() + } +} + +pub(crate) fn test_path_display(path: &str) -> String { + Path::new(path).abs().display().to_string() +} diff --git a/codex-rs/tui_app_server/src/app.rs b/codex-rs/tui_app_server/src/app.rs index b1773d0ac..e00d5ebc0 100644 --- a/codex-rs/tui_app_server/src/app.rs +++ b/codex-rs/tui_app_server/src/app.rs @@ -42,6 +42,8 @@ use crate::read_session_model; use crate::render::highlight::highlight_bash_to_lines; use crate::render::renderable::Renderable; use crate::resume_picker::SessionSelection; +#[cfg(test)] +use crate::test_support::PathBufExt; use crate::tui; use crate::tui::TuiEvent; use crate::update_action::UpdateAction; @@ -1039,7 +1041,7 @@ impl App { async fn refresh_in_memory_config_from_disk(&mut self) -> Result<()> { let mut config = self - .rebuild_config_for_cwd(self.chat_widget.config_ref().cwd.clone()) + .rebuild_config_for_cwd(self.chat_widget.config_ref().cwd.to_path_buf()) .await?; self.apply_runtime_policy_overrides(&mut config); self.config = config; @@ -1406,7 +1408,7 @@ impl App { self.chat_widget.current_model(), self.chat_widget.current_service_tier(), ), - self.config.cwd.clone(), + self.config.cwd.to_path_buf(), version, ) .display_lines(width) @@ -1702,7 +1704,7 @@ impl App { cwd: self .thread_cwd(thread_id) .await - .unwrap_or_else(|| self.config.cwd.clone()), + .unwrap_or_else(|| self.config.cwd.to_path_buf()), changes: HashMap::new(), }), ), @@ -3077,7 +3079,7 @@ impl App { chat_widget .maybe_prompt_windows_sandbox_enable(should_prompt_windows_sandbox_nux_at_startup); - let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); + let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone()); #[cfg(not(debug_assertions))] let upgrade_version = crate::updates::get_upgrade_version(&config); @@ -3144,7 +3146,13 @@ impl App { let tx = app.app_event_tx.clone(); let logs_base_dir = app.config.codex_home.clone(); let sandbox_policy = app.config.permissions.sandbox_policy.get().clone(); - Self::spawn_world_writable_scan(cwd, env_map, logs_base_dir, sandbox_policy, tx); + Self::spawn_world_writable_scan( + cwd.to_path_buf(), + env_map, + logs_base_dir, + sandbox_policy, + tx, + ); } } @@ -3369,7 +3377,7 @@ impl App { .await? { SessionSelection::Resume(target_session) => { - let current_cwd = self.config.cwd.clone(); + let current_cwd = self.config.cwd.to_path_buf(); let resume_cwd = if self.remote_app_server_url.is_some() { current_cwd.clone() } else { @@ -3417,7 +3425,8 @@ impl App { self.shutdown_current_thread(app_server).await; self.config = resume_config; tui.set_notification_method(self.config.tui_notification_method); - self.file_search.update_search_dir(self.config.cwd.clone()); + self.file_search + .update_search_dir(self.config.cwd.to_path_buf()); match self .replace_chat_widget_with_app_server_thread(tui, resumed) .await @@ -3715,7 +3724,8 @@ impl App { plugin_display_name, result, ); - if install_succeeded && self.chat_widget.config_ref().cwd == cwd { + if install_succeeded && self.chat_widget.config_ref().cwd.as_path() == cwd.as_path() + { self.fetch_plugins_list(app_server, cwd.clone()); if should_refresh_plugin_detail { self.fetch_plugin_detail( @@ -4190,7 +4200,9 @@ impl App { plugin_display_name, result, ); - if uninstall_succeeded && self.chat_widget.config_ref().cwd == cwd { + if uninstall_succeeded + && self.chat_widget.config_ref().cwd.as_path() == cwd.as_path() + { self.fetch_plugins_list(app_server, cwd); } } @@ -4377,7 +4389,7 @@ impl App { let logs_base_dir = self.config.codex_home.clone(); let sandbox_policy = self.config.permissions.sandbox_policy.get().clone(); Self::spawn_world_writable_scan( - cwd, + cwd.to_path_buf(), env_map, logs_base_dir, sandbox_policy, @@ -6693,7 +6705,7 @@ mod tests { let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; let codex_home = tempdir()?; app.config.codex_home = codex_home.path().to_path_buf(); - let config_toml_path = AbsolutePathBuf::try_from(codex_home.path().join("config.toml"))?; + let config_toml_path = codex_home.path().join("config.toml").abs(); let config_toml = "approvals_reviewer = \"guardian_subagent\"\napproval_policy = \"on-request\"\nsandbox_mode = \"workspace-write\"\n\n[features]\nguardian_approval = true\n"; std::fs::write(config_toml_path.as_path(), config_toml)?; let user_config = toml::from_str::(config_toml)?; @@ -6785,7 +6797,7 @@ mod tests { let codex_home = tempdir()?; app.config.codex_home = codex_home.path().to_path_buf(); let guardian_approvals = guardian_approvals_mode(); - let config_toml_path = AbsolutePathBuf::try_from(codex_home.path().join("config.toml"))?; + let config_toml_path = codex_home.path().join("config.toml").abs(); let config_toml = "approvals_reviewer = \"user\"\n"; std::fs::write(config_toml_path.as_path(), config_toml)?; let user_config = toml::from_str::(config_toml)?; @@ -6852,7 +6864,7 @@ mod tests { let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; let codex_home = tempdir()?; app.config.codex_home = codex_home.path().to_path_buf(); - let config_toml_path = AbsolutePathBuf::try_from(codex_home.path().join("config.toml"))?; + let config_toml_path = codex_home.path().join("config.toml").abs(); let config_toml = "approvals_reviewer = \"user\"\napproval_policy = \"on-request\"\nsandbox_mode = \"workspace-write\"\n\n[features]\nguardian_approval = true\n"; std::fs::write(config_toml_path.as_path(), config_toml)?; let user_config = toml::from_str::(config_toml)?; @@ -6913,7 +6925,7 @@ mod tests { app.config.codex_home = codex_home.path().to_path_buf(); let guardian_approvals = guardian_approvals_mode(); app.active_profile = Some("guardian".to_string()); - let config_toml_path = AbsolutePathBuf::try_from(codex_home.path().join("config.toml"))?; + let config_toml_path = codex_home.path().join("config.toml").abs(); let config_toml = "profile = \"guardian\"\napprovals_reviewer = \"user\"\n"; std::fs::write(config_toml_path.as_path(), config_toml)?; let user_config = toml::from_str::(config_toml)?; @@ -6983,7 +6995,7 @@ mod tests { let codex_home = tempdir()?; app.config.codex_home = codex_home.path().to_path_buf(); app.active_profile = Some("guardian".to_string()); - let config_toml_path = AbsolutePathBuf::try_from(codex_home.path().join("config.toml"))?; + let config_toml_path = codex_home.path().join("config.toml").abs(); let config_toml = r#" profile = "guardian" approvals_reviewer = "user" @@ -7071,7 +7083,7 @@ guardian_approval = true let codex_home = tempdir()?; app.config.codex_home = codex_home.path().to_path_buf(); app.active_profile = Some("guardian".to_string()); - let config_toml_path = AbsolutePathBuf::try_from(codex_home.path().join("config.toml"))?; + let config_toml_path = codex_home.path().join("config.toml").abs(); let config_toml = "profile = \"guardian\"\napprovals_reviewer = \"guardian_subagent\"\n\n[features]\nguardian_approval = true\n"; std::fs::write(config_toml_path.as_path(), config_toml)?; let user_config = toml::from_str::(config_toml)?; @@ -7652,7 +7664,7 @@ guardian_approval = true async fn render_clear_ui_header_after_long_transcript_for_snapshot() -> String { let mut app = make_test_app().await; - app.config.cwd = PathBuf::from("/tmp/project"); + app.config.cwd = PathBuf::from("/tmp/project").abs(); app.chat_widget.set_model("gpt-test"); app.chat_widget .set_reasoning_effort(Some(ReasoningEffortConfig::High)); @@ -7705,7 +7717,7 @@ guardian_approval = true approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), - cwd: PathBuf::from("/tmp/project"), + cwd: PathBuf::from("/tmp/project").abs().to_path_buf(), reasoning_effort: Some(ReasoningEffortConfig::High), history_log_id: 0, history_entry_count: 0, @@ -7763,21 +7775,33 @@ guardian_approval = true } #[tokio::test] + #[cfg_attr( + target_os = "windows", + ignore = "snapshot path rendering differs on Windows" + )] async fn clear_ui_after_long_transcript_snapshots_fresh_header_only() { let rendered = render_clear_ui_header_after_long_transcript_for_snapshot().await; assert_snapshot!("clear_ui_after_long_transcript_fresh_header_only", rendered); } #[tokio::test] + #[cfg_attr( + target_os = "windows", + ignore = "snapshot path rendering differs on Windows" + )] async fn ctrl_l_clear_ui_after_long_transcript_reuses_clear_header_snapshot() { let rendered = render_clear_ui_header_after_long_transcript_for_snapshot().await; assert_snapshot!("clear_ui_after_long_transcript_fresh_header_only", rendered); } #[tokio::test] + #[cfg_attr( + target_os = "windows", + ignore = "snapshot path rendering differs on Windows" + )] async fn clear_ui_header_shows_fast_status_only_for_gpt54() { let mut app = make_test_app().await; - app.config.cwd = PathBuf::from("/tmp/project"); + app.config.cwd = PathBuf::from("/tmp/project").abs(); app.chat_widget.set_model("gpt-5.4"); app.chat_widget .set_reasoning_effort(Some(ReasoningEffortConfig::XHigh)); @@ -7803,7 +7827,7 @@ guardian_approval = true async fn make_test_app() -> App { let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await; let config = chat_widget.config_ref().clone(); - let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); + let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone()); let model = codex_core::test_support::get_model_offline(config.model.as_deref()); let session_telemetry = test_session_telemetry(&config, model.as_str()); @@ -7853,7 +7877,7 @@ guardian_approval = true ) { let (chat_widget, app_event_tx, rx, op_rx) = make_chatwidget_manual_with_sender().await; let config = chat_widget.config_ref().clone(); - let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); + let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone()); let model = codex_core::test_support::get_model_offline(config.model.as_deref()); let session_telemetry = test_session_telemetry(&config, model.as_str()); @@ -8598,7 +8622,7 @@ guardian_approval = true }), }); - assert_eq!(app.chat_widget.config_ref().cwd, next_cwd); + assert_eq!(app.chat_widget.config_ref().cwd.to_path_buf(), next_cwd); assert_eq!(app.config.cwd, original_cwd); app.refresh_in_memory_config_from_disk().await?; @@ -8618,7 +8642,7 @@ guardian_approval = true let current_cwd = current_config.cwd.clone(); let resume_config = app - .rebuild_config_for_resume_or_fallback(¤t_cwd, current_cwd.clone()) + .rebuild_config_for_resume_or_fallback(¤t_cwd, current_cwd.to_path_buf()) .await?; assert_eq!(resume_config, current_config); diff --git a/codex-rs/tui_app_server/src/chatwidget.rs b/codex-rs/tui_app_server/src/chatwidget.rs index df73ef20a..7a636eded 100644 --- a/codex-rs/tui_app_server/src/chatwidget.rs +++ b/codex-rs/tui_app_server/src/chatwidget.rs @@ -207,6 +207,7 @@ use codex_terminal_detection::Multiplexer; use codex_terminal_detection::TerminalInfo; use codex_terminal_detection::TerminalName; use codex_terminal_detection::terminal_info; +use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_sleep_inhibitor::SleepInhibitor; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -1778,7 +1779,12 @@ impl ChatWidget { self.forked_from = event.forked_from_id; self.current_rollout_path = event.rollout_path.clone(); self.current_cwd = Some(event.cwd.clone()); - self.config.cwd = event.cwd.clone(); + match AbsolutePathBuf::try_from(event.cwd.clone()) { + Ok(cwd) => self.config.cwd = cwd, + Err(err) => { + tracing::warn!(path = %event.cwd.display(), %err, "session cwd should be absolute"); + } + } if let Err(err) = self .config .permissions @@ -4026,13 +4032,13 @@ impl ChatWidget { id: ev.call_id, reason: ev.reason, changes: ev.changes.clone(), - cwd: self.config.cwd.clone(), + cwd: self.config.cwd.to_path_buf(), }; self.bottom_pane .push_approval_request(request, &self.config.features); self.request_redraw(); self.notify(Notification::EditApprovalRequested { - cwd: self.config.cwd.clone(), + cwd: self.config.cwd.to_path_buf(), changes: ev.changes.keys().cloned().collect(), }); } @@ -4274,7 +4280,7 @@ impl ChatWidget { let active_cell = Some(Self::placeholder_session_header_cell(&config)); - let current_cwd = Some(config.cwd.clone()); + let current_cwd = Some(config.cwd.to_path_buf()); let queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info()); let mut widget = Self { app_event_tx: app_event_tx.clone(), @@ -4693,7 +4699,15 @@ impl ChatWidget { self.app_event_tx.send(AppEvent::ForkCurrentSession); } SlashCommand::Init => { - let init_target = self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME); + let init_target = match self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME) { + Ok(path) => path, + Err(err) => { + self.add_error_message(format!( + "Failed to prepare {DEFAULT_PROJECT_DOC_FILENAME}: {err}", + )); + return; + } + }; if init_target.exists() { let message = format!( "{DEFAULT_PROJECT_DOC_FILENAME} already exists here. Skipping /init to avoid overwriting it." @@ -5415,7 +5429,7 @@ impl ChatWidget { let service_tier = self.config.service_tier.map(Some); let op = AppCommand::user_turn( items, - self.config.cwd.clone(), + self.config.cwd.to_path_buf(), self.config.permissions.approval_policy.value(), self.config.permissions.sandbox_policy.get().clone(), effective_mode.model().to_string(), @@ -7022,7 +7036,9 @@ impl ChatWidget { } fn status_line_cwd(&self) -> &Path { - self.current_cwd.as_ref().unwrap_or(&self.config.cwd) + self.current_cwd + .as_deref() + .unwrap_or(self.config.cwd.as_path()) } fn status_line_project_root(&self) -> Option { @@ -9617,7 +9633,7 @@ impl ChatWidget { placeholder_style, /*reasoning_effort*/ None, /*show_fast_status*/ false, - config.cwd.clone(), + config.cwd.to_path_buf(), CODEX_CLI_VERSION, )) } @@ -10331,7 +10347,7 @@ impl ChatWidget { name: "Review against a base branch".to_string(), description: Some("(PR Style)".into()), actions: vec![Box::new({ - let cwd = self.config.cwd.clone(); + let cwd = self.config.cwd.to_path_buf(); move |tx| { tx.send(AppEvent::OpenReviewBranchPicker(cwd.clone())); } @@ -10356,7 +10372,7 @@ impl ChatWidget { items.push(SelectionItem { name: "Review a commit".to_string(), actions: vec![Box::new({ - let cwd = self.config.cwd.clone(); + let cwd = self.config.cwd.to_path_buf(); move |tx| { tx.send(AppEvent::OpenReviewCommitPicker(cwd.clone())); } diff --git a/codex-rs/tui_app_server/src/chatwidget/plugins.rs b/codex-rs/tui_app_server/src/chatwidget/plugins.rs index 120b7237e..588884639 100644 --- a/codex-rs/tui_app_server/src/chatwidget/plugins.rs +++ b/codex-rs/tui_app_server/src/chatwidget/plugins.rs @@ -159,11 +159,11 @@ impl ChatWidget { cwd: PathBuf, result: Result, ) { - if self.plugins_fetch_state.in_flight_cwd.as_ref() == Some(&cwd) { + if self.plugins_fetch_state.in_flight_cwd.as_deref() == Some(cwd.as_path()) { self.plugins_fetch_state.in_flight_cwd = None; } - if self.config.cwd != cwd { + if self.config.cwd.as_path() != cwd.as_path() { return; } @@ -191,13 +191,13 @@ impl ChatWidget { } fn prefetch_plugins(&mut self) { - let cwd = self.config.cwd.clone(); - if self.plugins_fetch_state.in_flight_cwd.as_ref() == Some(&cwd) { + let cwd = self.config.cwd.to_path_buf(); + if self.plugins_fetch_state.in_flight_cwd.as_deref() == Some(cwd.as_path()) { return; } self.plugins_fetch_state.in_flight_cwd = Some(cwd.clone()); - if self.plugins_fetch_state.cache_cwd.as_ref() != Some(&cwd) { + if self.plugins_fetch_state.cache_cwd.as_deref() != Some(cwd.as_path()) { self.plugins_cache = PluginsCacheState::Loading; } @@ -205,7 +205,7 @@ impl ChatWidget { } fn plugins_cache_for_current_cwd(&self) -> PluginsCacheState { - if self.plugins_fetch_state.cache_cwd.as_ref() == Some(&self.config.cwd) { + if self.plugins_fetch_state.cache_cwd.as_deref() == Some(self.config.cwd.as_path()) { self.plugins_cache.clone() } else { PluginsCacheState::Uninitialized @@ -253,7 +253,7 @@ impl ChatWidget { cwd: PathBuf, result: Result, ) { - if self.config.cwd != cwd { + if self.config.cwd.as_path() != cwd.as_path() { return; } @@ -288,7 +288,7 @@ impl ChatWidget { plugin_display_name: String, result: Result, ) -> bool { - if self.config.cwd != cwd { + if self.config.cwd.as_path() != cwd.as_path() { return true; } @@ -346,7 +346,7 @@ impl ChatWidget { plugin_display_name: String, result: Result, ) { - if self.config.cwd != cwd { + if self.config.cwd.as_path() != cwd.as_path() { return; } @@ -676,7 +676,7 @@ impl ChatWidget { ..Default::default() }]; if let Some(plugins_response) = plugins_response.cloned() { - let cwd = self.config.cwd.clone(); + let cwd = self.config.cwd.to_path_buf(); items.push(SelectionItem { name: "Back to plugins".to_string(), description: Some("Return to the plugin list.".to_string()), @@ -770,7 +770,7 @@ impl ChatWidget { "{display_name} {} {} {}", plugin.id, plugin.name, marketplace_label ); - let cwd = self.config.cwd.clone(); + let cwd = self.config.cwd.to_path_buf(); let plugin_display_name = display_name.clone(); let marketplace_path = marketplace.path.clone(); let plugin_name = plugin.name.clone(); @@ -861,7 +861,7 @@ impl ChatWidget { header.push(Line::from(description.dim())); } - let cwd = self.config.cwd.clone(); + let cwd = self.config.cwd.to_path_buf(); let plugins_response = plugins_response.clone(); let mut items = vec![SelectionItem { name: "Back to plugins".to_string(), @@ -877,7 +877,7 @@ impl ChatWidget { }]; if plugin.summary.installed { - let uninstall_cwd = self.config.cwd.clone(); + let uninstall_cwd = self.config.cwd.to_path_buf(); let plugin_id = plugin.summary.id.clone(); let plugin_display_name = display_name; items.push(SelectionItem { @@ -906,7 +906,7 @@ impl ChatWidget { ..Default::default() }); } else { - let install_cwd = self.config.cwd.clone(); + let install_cwd = self.config.cwd.to_path_buf(); let marketplace_path = plugin.marketplace_path.clone(); let plugin_name = plugin.summary.name.clone(); let plugin_display_name = display_name; diff --git a/codex-rs/tui_app_server/src/chatwidget/tests.rs b/codex-rs/tui_app_server/src/chatwidget/tests.rs index c3c2ef769..6eefc52a8 100644 --- a/codex-rs/tui_app_server/src/chatwidget/tests.rs +++ b/codex-rs/tui_app_server/src/chatwidget/tests.rs @@ -17,6 +17,8 @@ use crate::chatwidget::realtime::RealtimeConversationPhase; use crate::history_cell::UserHistoryCell; use crate::model_catalog::ModelCatalog; use crate::test_backend::VT100Backend; +use crate::test_support::PathBufExt; +use crate::test_support::test_path_display; use crate::tui::FrameRequester; use assert_matches::assert_matches; use codex_app_server_protocol::AppSummary; @@ -458,10 +460,10 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { .sandbox_policy .set(SandboxPolicy::new_workspace_write_policy()) .expect("set sandbox policy"); - chat.config.cwd = PathBuf::from("/home/user/main"); + chat.config.cwd = PathBuf::from("/home/user/main").abs(); let expected_sandbox = SandboxPolicy::new_read_only_policy(); - let expected_cwd = PathBuf::from("/home/user/sub-agent"); + let expected_cwd = PathBuf::from("/home/user/sub-agent").abs(); let configured = codex_protocol::protocol::SessionConfiguredEvent { session_id: ThreadId::new(), forked_from_id: None, @@ -472,7 +474,7 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: expected_sandbox.clone(), - cwd: expected_cwd.clone(), + cwd: expected_cwd.to_path_buf(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, history_entry_count: 0, @@ -6426,7 +6428,7 @@ async fn slash_init_skips_when_project_doc_exists() { let tempdir = tempdir().unwrap(); let existing_path = tempdir.path().join(DEFAULT_PROJECT_DOC_FILENAME); std::fs::write(&existing_path, "existing instructions").unwrap(); - chat.config.cwd = tempdir.path().to_path_buf(); + chat.config.cwd = tempdir.path().to_path_buf().abs(); chat.dispatch_command(SlashCommand::Init); @@ -7419,13 +7421,17 @@ async fn custom_prompt_enter_empty_does_not_send() { #[tokio::test] async fn view_image_tool_call_adds_history_cell() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - let image_path = chat.config.cwd.join("example.png"); + let image_path = chat + .config + .cwd + .join("example.png") + .expect("absolute image path"); chat.handle_codex_event(Event { id: "sub-image".into(), msg: EventMsg::ViewImageToolCall(ViewImageToolCallEvent { call_id: "call-image".into(), - path: image_path, + path: image_path.to_path_buf(), }), }); @@ -7715,12 +7721,10 @@ fn strip_osc8_for_snapshot(text: &str) -> String { } fn plugins_test_absolute_path(path: &str) -> AbsolutePathBuf { - AbsolutePathBuf::try_from( - std::env::temp_dir() - .join("codex-plugin-menu-tests") - .join(path), - ) - .expect("expected absolute test path") + std::env::temp_dir() + .join("codex-plugin-menu-tests") + .join(path) + .abs() } fn plugins_test_interface( @@ -7802,7 +7806,7 @@ fn plugins_test_response(marketplaces: Vec) -> PluginLis fn render_loaded_plugins_popup(chat: &mut ChatWidget, response: PluginListResponse) -> String { let cwd = chat.config.cwd.clone(); - chat.on_plugins_loaded(cwd, Ok(response)); + chat.on_plugins_loaded(cwd.to_path_buf(), Ok(response)); chat.add_plugins_output(); render_bottom_popup(chat, 100) } @@ -7953,10 +7957,10 @@ async fn plugin_detail_popup_snapshot_shows_install_actions_and_capability_summa summary.clone(), ])]); let cwd = chat.config.cwd.clone(); - chat.on_plugins_loaded(cwd.clone(), Ok(response)); + chat.on_plugins_loaded(cwd.to_path_buf(), Ok(response)); chat.add_plugins_output(); chat.on_plugin_detail_loaded( - cwd, + cwd.to_path_buf(), Ok(PluginReadResponse { plugin: plugins_test_detail( summary, @@ -7993,10 +7997,10 @@ async fn plugin_detail_popup_hides_disclosure_for_installed_plugins() { summary.clone(), ])]); let cwd = chat.config.cwd.clone(); - chat.on_plugins_loaded(cwd.clone(), Ok(response)); + chat.on_plugins_loaded(cwd.to_path_buf(), Ok(response)); chat.add_plugins_output(); chat.on_plugin_detail_loaded( - cwd, + cwd.to_path_buf(), Ok(PluginReadResponse { plugin: plugins_test_detail( summary, @@ -8083,7 +8087,7 @@ async fn plugins_popup_refresh_replaces_selection_with_first_row() { ), ])]); let cwd = chat.config.cwd.clone(); - chat.on_plugins_loaded(cwd, Ok(refreshed)); + chat.on_plugins_loaded(cwd.to_path_buf(), Ok(refreshed)); let after = render_bottom_popup(&chat, 100); assert!( @@ -8152,7 +8156,7 @@ async fn plugins_popup_refreshes_installed_counts_after_install() { ), ])]); let cwd = chat.config.cwd.clone(); - chat.on_plugins_loaded(cwd, Ok(refreshed)); + chat.on_plugins_loaded(cwd.to_path_buf(), Ok(refreshed)); let after = render_bottom_popup(&chat, 100); assert!( @@ -8824,8 +8828,7 @@ async fn apps_initial_load_applies_enabled_state_from_config() { chat.bottom_pane.set_connectors_enabled(true); let temp = tempdir().expect("tempdir"); - let config_toml_path = - AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute config path"); + let config_toml_path = temp.path().join("config.toml").abs(); let user_config = toml::from_str::( "[apps.connector_1]\nenabled = false\ndisabled_reason = \"user\"\n", ) @@ -8889,8 +8892,7 @@ async fn apps_initial_load_applies_enabled_state_from_requirements_with_user_ove ..Default::default() }; let temp = tempdir().expect("tempdir"); - let config_toml_path = - AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute config path"); + let config_toml_path = temp.path().join("config.toml").abs(); chat.config.config_layer_stack = ConfigLayerStack::new(Vec::new(), ConfigRequirements::default(), requirements) .expect("requirements stack") @@ -9465,7 +9467,7 @@ async fn preset_matching_accepts_workspace_write_with_extra_roots() { .find(|p| p.id == "auto") .expect("auto preset exists"); let current_sandbox = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![AbsolutePathBuf::try_from("C:\\extra").unwrap()], + writable_roots: vec![PathBuf::from("C:\\extra").abs()], read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: false, @@ -10265,8 +10267,7 @@ async fn permissions_selection_marks_guardian_approvals_current_with_custom_work .features .set_enabled(Feature::GuardianApproval, true); - let extra_root = AbsolutePathBuf::try_from("/tmp/guardian-approvals-extra") - .expect("absolute extra writable root"); + let extra_root = PathBuf::from("/tmp/guardian-approvals-extra").abs(); chat.handle_codex_event(Event { id: "session-configured-custom-workspace".to_string(), @@ -12279,7 +12280,7 @@ async fn status_line_fast_mode_footer_snapshot() { #[tokio::test] async fn status_line_model_with_reasoning_includes_fast_for_gpt54_only() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; - chat.config.cwd = PathBuf::from("/tmp/project"); + chat.config.cwd = PathBuf::from("/tmp/project").abs(); chat.config.tui_status_line = Some(vec![ "model-with-reasoning".to_string(), "context-remaining".to_string(), @@ -12289,10 +12290,11 @@ async fn status_line_model_with_reasoning_includes_fast_for_gpt54_only() { chat.set_service_tier(Some(ServiceTier::Fast)); set_chatgpt_auth(&mut chat); chat.refresh_status_line(); + let test_cwd = test_path_display("/tmp/project"); assert_eq!( status_line_text(&chat), - Some("gpt-5.4 xhigh fast · 100% left · /tmp/project".to_string()) + Some(format!("gpt-5.4 xhigh fast · 100% left · {test_cwd}")) ); chat.set_model("gpt-5.3-codex"); @@ -12300,18 +12302,22 @@ async fn status_line_model_with_reasoning_includes_fast_for_gpt54_only() { assert_eq!( status_line_text(&chat), - Some("gpt-5.3-codex xhigh · 100% left · /tmp/project".to_string()) + Some(format!("gpt-5.3-codex xhigh · 100% left · {test_cwd}")) ); } #[tokio::test] +#[cfg_attr( + target_os = "windows", + ignore = "snapshot path rendering differs on Windows" +)] async fn status_line_model_with_reasoning_fast_footer_snapshot() { use ratatui::Terminal; use ratatui::backend::TestBackend; let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; chat.show_welcome_banner = false; - chat.config.cwd = PathBuf::from("/tmp/project"); + chat.config.cwd = PathBuf::from("/tmp/project").abs(); chat.config.tui_status_line = Some(vec![ "model-with-reasoning".to_string(), "context-remaining".to_string(), diff --git a/codex-rs/tui_app_server/src/history_cell.rs b/codex-rs/tui_app_server/src/history_cell.rs index 38937406b..445d68446 100644 --- a/codex-rs/tui_app_server/src/history_cell.rs +++ b/codex-rs/tui_app_server/src/history_cell.rs @@ -27,6 +27,8 @@ use crate::render::line_utils::push_owned_lines; use crate::render::renderable::Renderable; use crate::style::proposed_plan_style; use crate::style::user_message_style; +#[cfg(test)] +use crate::test_support::PathBufExt; use crate::text_formatting::format_and_truncate_tool_result; use crate::text_formatting::truncate_text; use crate::tooltips; @@ -1143,7 +1145,7 @@ pub(crate) fn new_session_info( model.clone(), reasoning_effort, show_fast_status, - config.cwd.clone(), + config.cwd.to_path_buf(), CODEX_CLI_VERSION, ); let mut parts: Vec> = vec![Box::new(header)]; @@ -2883,7 +2885,7 @@ mod tests { approval_policy: AskForApproval::Never, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), - cwd: PathBuf::from("/tmp/project"), + cwd: PathBuf::from("/tmp/project").abs().to_path_buf(), reasoning_effort: None, history_log_id: 0, history_entry_count: 0, @@ -2997,9 +2999,13 @@ mod tests { } #[tokio::test] + #[cfg_attr( + target_os = "windows", + ignore = "snapshot path rendering differs on Windows" + )] async fn session_info_availability_nux_tooltip_snapshot() { let mut config = test_config().await; - config.cwd = PathBuf::from("/tmp/project"); + config.cwd = PathBuf::from("/tmp/project").abs(); let cell = new_session_info( &config, "gpt-5", diff --git a/codex-rs/tui_app_server/src/lib.rs b/codex-rs/tui_app_server/src/lib.rs index 790a7c6f5..4a36eb58c 100644 --- a/codex-rs/tui_app_server/src/lib.rs +++ b/codex-rs/tui_app_server/src/lib.rs @@ -248,6 +248,8 @@ mod wrapping; #[cfg(test)] pub mod test_backend; +#[cfg(test)] +pub(crate) mod test_support; use crate::onboarding::onboarding_screen::OnboardingScreenArgs; use crate::onboarding::onboarding_screen::run_onboarding_app; @@ -1221,7 +1223,7 @@ async fn run_ratatui_app( let fallback_cwd = match action_and_target_session_if_resume_or_fork { Some((action, target_session)) => { if remote_mode { - Some(current_cwd.clone()) + Some(current_cwd.to_path_buf()) } else { match resolve_cwd_for_resume_or_fork( &mut tui, diff --git a/codex-rs/tui_app_server/src/onboarding/onboarding_screen.rs b/codex-rs/tui_app_server/src/onboarding/onboarding_screen.rs index d092d06e2..ec8e42894 100644 --- a/codex-rs/tui_app_server/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui_app_server/src/onboarding/onboarding_screen.rs @@ -85,7 +85,7 @@ impl OnboardingScreen { app_server_request_handle, config, } = args; - let cwd = config.cwd.clone(); + let cwd = config.cwd.to_path_buf(); let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone(); let forced_login_method = config.forced_login_method; let codex_home = config.codex_home.clone(); diff --git a/codex-rs/tui_app_server/src/status/card.rs b/codex-rs/tui_app_server/src/status/card.rs index 4b5a6474b..d7556154d 100644 --- a/codex-rs/tui_app_server/src/status/card.rs +++ b/codex-rs/tui_app_server/src/status/card.rs @@ -255,7 +255,7 @@ impl StatusHistoryCell { Self { model_name, model_details, - directory: config.cwd.clone(), + directory: config.cwd.to_path_buf(), permissions, agents_summary, collaboration_mode: collaboration_mode.map(ToString::to_string), diff --git a/codex-rs/tui_app_server/src/status/helpers.rs b/codex-rs/tui_app_server/src/status/helpers.rs index 727212556..90c045fb9 100644 --- a/codex-rs/tui_app_server/src/status/helpers.rs +++ b/codex-rs/tui_app_server/src/status/helpers.rs @@ -42,7 +42,7 @@ pub(crate) fn compose_agents_summary(config: &Config) -> String { .map(|name| name.to_string_lossy().to_string()) .unwrap_or_else(|| "".to_string()); let display = if let Some(parent) = p.parent() { - if parent == config.cwd { + if parent == config.cwd.as_path() { file_name.clone() } else { let mut cur = config.cwd.as_path(); diff --git a/codex-rs/tui_app_server/src/status/tests.rs b/codex-rs/tui_app_server/src/status/tests.rs index a224eb780..fbfbca912 100644 --- a/codex-rs/tui_app_server/src/status/tests.rs +++ b/codex-rs/tui_app_server/src/status/tests.rs @@ -2,6 +2,7 @@ use super::new_status_output; use super::rate_limit_snapshot_display; use crate::history_cell::HistoryCell; use crate::status::StatusAccountDisplay; +use crate::test_support::PathBufExt; use chrono::Duration as ChronoDuration; use chrono::TimeZone; use chrono::Utc; @@ -105,7 +106,7 @@ async fn status_snapshot_includes_reasoning_details() { }) .expect("set sandbox policy"); - config.cwd = PathBuf::from("/workspace/tests"); + config.cwd = PathBuf::from("/workspace/tests").abs(); let account_display = test_status_account_display(); let usage = TokenUsage { @@ -189,7 +190,7 @@ async fn status_permissions_non_default_workspace_write_is_custom() { exclude_slash_tmp: false, }) .expect("set sandbox policy"); - config.cwd = PathBuf::from("/workspace/tests"); + config.cwd = PathBuf::from("/workspace/tests").abs(); let account_display = test_status_account_display(); let usage = TokenUsage::default(); @@ -238,7 +239,7 @@ async fn status_snapshot_includes_forked_from() { let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); config.model_provider_id = "openai".to_string(); - config.cwd = PathBuf::from("/workspace/tests"); + config.cwd = PathBuf::from("/workspace/tests").abs(); let account_display = test_status_account_display(); let usage = TokenUsage { @@ -292,7 +293,7 @@ async fn status_snapshot_includes_monthly_limit() { let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); config.model_provider_id = "openai".to_string(); - config.cwd = PathBuf::from("/workspace/tests"); + config.cwd = PathBuf::from("/workspace/tests").abs(); let account_display = test_status_account_display(); let usage = TokenUsage { @@ -545,7 +546,7 @@ async fn status_card_token_usage_excludes_cached_tokens() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); - config.cwd = PathBuf::from("/workspace/tests"); + config.cwd = PathBuf::from("/workspace/tests").abs(); let account_display = test_status_account_display(); let usage = TokenUsage { @@ -593,7 +594,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() { config.model = Some("gpt-5.1-codex-max".to_string()); config.model_provider_id = "openai".to_string(); config.model_reasoning_summary = Some(ReasoningSummary::Detailed); - config.cwd = PathBuf::from("/workspace/tests"); + config.cwd = PathBuf::from("/workspace/tests").abs(); let account_display = test_status_account_display(); let usage = TokenUsage { @@ -656,7 +657,7 @@ async fn status_snapshot_shows_missing_limits_message() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); - config.cwd = PathBuf::from("/workspace/tests"); + config.cwd = PathBuf::from("/workspace/tests").abs(); let account_display = test_status_account_display(); let usage = TokenUsage { @@ -704,7 +705,7 @@ async fn status_snapshot_includes_credits_and_limits() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex".to_string()); - config.cwd = PathBuf::from("/workspace/tests"); + config.cwd = PathBuf::from("/workspace/tests").abs(); let account_display = test_status_account_display(); let usage = TokenUsage { @@ -773,7 +774,7 @@ async fn status_snapshot_shows_empty_limits_message() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); - config.cwd = PathBuf::from("/workspace/tests"); + config.cwd = PathBuf::from("/workspace/tests").abs(); let account_display = test_status_account_display(); let usage = TokenUsage { @@ -830,7 +831,7 @@ async fn status_snapshot_shows_stale_limits_message() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); - config.cwd = PathBuf::from("/workspace/tests"); + config.cwd = PathBuf::from("/workspace/tests").abs(); let account_display = test_status_account_display(); let usage = TokenUsage { @@ -896,7 +897,7 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex".to_string()); - config.cwd = PathBuf::from("/workspace/tests"); + config.cwd = PathBuf::from("/workspace/tests").abs(); let account_display = test_status_account_display(); let usage = TokenUsage { diff --git a/codex-rs/tui_app_server/src/test_support.rs b/codex-rs/tui_app_server/src/test_support.rs new file mode 100644 index 000000000..790fc805e --- /dev/null +++ b/codex-rs/tui_app_server/src/test_support.rs @@ -0,0 +1,28 @@ +use codex_utils_absolute_path::AbsolutePathBuf; +use std::path::Path; +use std::path::PathBuf; + +pub(crate) trait PathExt { + fn abs(&self) -> AbsolutePathBuf; +} + +impl PathExt for Path { + fn abs(&self) -> AbsolutePathBuf { + AbsolutePathBuf::try_from(self.to_path_buf()) + .unwrap_or_else(|_| panic!("path should already be absolute")) + } +} + +pub(crate) trait PathBufExt { + fn abs(&self) -> AbsolutePathBuf; +} + +impl PathBufExt for PathBuf { + fn abs(&self) -> AbsolutePathBuf { + self.as_path().abs() + } +} + +pub(crate) fn test_path_display(path: &str) -> String { + Path::new(path).abs().display().to_string() +} diff --git a/codex-rs/utils/absolute-path/src/lib.rs b/codex-rs/utils/absolute-path/src/lib.rs index 1ba1c40cf..69691ac75 100644 --- a/codex-rs/utils/absolute-path/src/lib.rs +++ b/codex-rs/utils/absolute-path/src/lib.rs @@ -63,6 +63,12 @@ impl AbsolutePathBuf { Self::from_absolute_path(current_dir) } + /// Construct an absolute path from `path`, resolving relative paths against + /// the process current working directory. + pub fn relative_to_current_dir>(path: P) -> std::io::Result { + Self::resolve_path_against_base(path, std::env::current_dir()?) + } + pub fn join>(&self, path: P) -> std::io::Result { Self::resolve_path_against_base(path, &self.0) } @@ -104,6 +110,14 @@ impl AsRef for AbsolutePathBuf { } } +impl std::ops::Deref for AbsolutePathBuf { + type Target = Path; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + impl From for PathBuf { fn from(path: AbsolutePathBuf) -> Self { path.into_path_buf() @@ -216,6 +230,17 @@ mod tests { assert_eq!(abs_path_buf.as_path(), base_dir.join("file.txt").as_path()); } + #[test] + fn relative_to_current_dir_resolves_relative_path() -> std::io::Result<()> { + let current_dir = std::env::current_dir()?; + let abs_path_buf = AbsolutePathBuf::relative_to_current_dir("file.txt")?; + assert_eq!( + abs_path_buf.as_path(), + current_dir.join("file.txt").as_path() + ); + Ok(()) + } + #[test] fn guard_used_in_deserialization() { let temp_dir = tempdir().expect("base dir");