From 95ba76262059226193af10fdfbbb529000f8463c Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Tue, 24 Mar 2026 22:54:18 -0700 Subject: [PATCH 01/19] fix: support split carveouts in windows restricted-token sandbox (#14172) ## Summary - keep legacy Windows restricted-token sandboxing as the supported baseline - support the split-policy subset that restricted-token can enforce directly today - support full-disk read, the same writable root set as legacy `WorkspaceWrite`, and extra read-only carveouts under those writable roots via additional deny-write ACLs - continue to fail closed for unsupported split-only shapes, including explicit unreadable (`none`) carveouts, reopened writable descendants under read-only carveouts, and writable root sets that do not match the legacy workspace roots ## Example Given a filesystem policy like: ```toml ":root" = "read" ":cwd" = "write" "./docs" = "read" ``` the restricted-token backend can keep the workspace writable while denying writes under `docs` by layering an extra deny-write carveout on top of the legacy workspace-write roots. A policy like: ```toml "/workspace" = "write" "/workspace/docs" = "read" "/workspace/docs/tmp" = "write" ``` still fails closed, because the unelevated backend cannot reopen the nested writable descendant safely. ## Stack -> fix: support split carveouts in windows restricted-token sandbox #14172 fix: support split carveouts in windows elevated sandbox #14568 --- codex-rs/app-server/src/command_exec.rs | 60 ++-- codex-rs/core/README.md | 14 +- codex-rs/core/src/exec.rs | 268 +++++++++++---- codex-rs/core/src/exec_tests.rs | 306 ++++++++++++------ codex-rs/core/src/sandboxing/mod.rs | 38 +++ codex-rs/core/src/tasks/user_shell.rs | 1 + .../tools/runtimes/shell/unix_escalation.rs | 2 + codex-rs/windows-sandbox-rs/src/lib.rs | 34 +- 8 files changed, 538 insertions(+), 185 deletions(-) diff --git a/codex-rs/app-server/src/command_exec.rs b/codex-rs/app-server/src/command_exec.rs index 8f92cd0ba2..a1a042de6a 100644 --- a/codex-rs/app-server/src/command_exec.rs +++ b/codex-rs/app-server/src/command_exec.rs @@ -734,21 +734,21 @@ mod tests { access: ReadOnlyAccess::FullAccess, network_access: false, }; - ExecRequest { - command: vec!["cmd".to_string()], - cwd: PathBuf::from("."), - env: HashMap::new(), - network: None, - expiration: ExecExpiration::DefaultTimeout, - capture_policy: codex_core::exec::ExecCapturePolicy::ShellTool, - sandbox: SandboxType::WindowsRestrictedToken, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - windows_sandbox_private_desktop: false, - sandbox_policy: sandbox_policy.clone(), - file_system_sandbox_policy: FileSystemSandboxPolicy::from(&sandbox_policy), - network_sandbox_policy: NetworkSandboxPolicy::from(&sandbox_policy), - arg0: None, - } + ExecRequest::new( + vec!["cmd".to_string()], + PathBuf::from("."), + HashMap::new(), + /*network*/ None, + ExecExpiration::DefaultTimeout, + codex_core::exec::ExecCapturePolicy::ShellTool, + SandboxType::WindowsRestrictedToken, + WindowsSandboxLevel::Disabled, + /*windows_sandbox_private_desktop*/ false, + sandbox_policy.clone(), + FileSystemSandboxPolicy::from(&sandbox_policy), + NetworkSandboxPolicy::from(&sandbox_policy), + /*arg0*/ None, + ) } #[tokio::test] @@ -846,21 +846,21 @@ mod tests { outgoing: Arc::new(OutgoingMessageSender::new(tx)), request_id: request_id.clone(), process_id: Some("proc-100".to_string()), - exec_request: ExecRequest { - command: vec!["sh".to_string(), "-lc".to_string(), "sleep 30".to_string()], - cwd: PathBuf::from("."), - env: HashMap::new(), - network: None, - expiration: ExecExpiration::Cancellation(CancellationToken::new()), - capture_policy: codex_core::exec::ExecCapturePolicy::ShellTool, - sandbox: SandboxType::None, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - windows_sandbox_private_desktop: false, - sandbox_policy: sandbox_policy.clone(), - file_system_sandbox_policy: FileSystemSandboxPolicy::from(&sandbox_policy), - network_sandbox_policy: NetworkSandboxPolicy::from(&sandbox_policy), - arg0: None, - }, + exec_request: ExecRequest::new( + vec!["sh".to_string(), "-lc".to_string(), "sleep 30".to_string()], + PathBuf::from("."), + HashMap::new(), + /*network*/ None, + ExecExpiration::Cancellation(CancellationToken::new()), + codex_core::exec::ExecCapturePolicy::ShellTool, + SandboxType::None, + WindowsSandboxLevel::Disabled, + /*windows_sandbox_private_desktop*/ false, + sandbox_policy.clone(), + FileSystemSandboxPolicy::from(&sandbox_policy), + NetworkSandboxPolicy::from(&sandbox_policy), + /*arg0*/ None, + ), started_network_proxy: None, tty: false, stream_stdin: false, diff --git a/codex-rs/core/README.md b/codex-rs/core/README.md index c27ec5460e..c154ef463d 100644 --- a/codex-rs/core/README.md +++ b/codex-rs/core/README.md @@ -51,7 +51,6 @@ Expects the binary containing `codex-core` to run the equivalent of `codex sandb Legacy `SandboxPolicy` / `sandbox_mode` configs are still supported on Linux. They can continue to use the legacy Landlock path when the split filesystem policy is sandbox-equivalent to the legacy model after `cwd` resolution. - Split filesystem policies that need direct `FileSystemSandboxPolicy` enforcement, such as read-only or denied carveouts under a broader writable root, automatically route through bubblewrap. The legacy Landlock path is used @@ -83,13 +82,18 @@ backend-managed system read roots required for basic execution, such as `C:\ProgramData`. When it is `false`, those extra system roots are omitted. The unelevated restricted-token backend still supports the legacy full-read -Windows model only. Restricted read-only policies continue to fail closed there -instead of running with weaker read enforcement. +Windows model for legacy `ReadOnly` and `WorkspaceWrite` behavior. It also +supports a narrow split-filesystem subset: full-read split policies whose +writable roots still match the legacy `WorkspaceWrite` root set, but add extra +read-only carveouts under those writable roots. New `[permissions]` / split filesystem policies remain supported on Windows only when they round-trip through the legacy `SandboxPolicy` model without -changing semantics. Richer split-only carveouts still fail closed instead of -running with weaker enforcement. +changing semantics. Policies that would require direct read restriction, +explicit unreadable carveouts, reopened writable descendants under read-only +carveouts, different writable root sets, or split carveout support in the +elevated setup/runner backend still fail closed instead of running with weaker +enforcement. ### All Platforms diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 0229e1dd57..a4399ddc46 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -1,6 +1,7 @@ #[cfg(unix)] use std::os::unix::process::ExitStatusExt; +use std::collections::BTreeSet; use std::collections::HashMap; use std::io; use std::path::Path; @@ -32,7 +33,7 @@ use crate::spawn::StdioPolicy; use crate::spawn::spawn_child_async; use crate::text_encoding::bytes_to_string_smart; use codex_network_proxy::NetworkProxy; -#[cfg(any(target_os = "windows", test))] +use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::permissions::FileSystemSandboxKind; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; @@ -41,6 +42,7 @@ use codex_sandboxing::SandboxManager; use codex_sandboxing::SandboxTransformRequest; use codex_sandboxing::SandboxType; use codex_sandboxing::SandboxablePreference; +use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP; use codex_utils_pty::process_group::kill_child_process_group; @@ -91,6 +93,17 @@ pub struct ExecParams { pub arg0: Option, } +/// Extra filesystem deny-write carveouts for the non-elevated Windows +/// restricted-token backend. +/// +/// These are applied on top of the legacy `WorkspaceWrite` allow set, so we +/// can support a narrow split-policy subset without changing legacy Windows +/// sandbox semantics. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct WindowsRestrictedTokenFilesystemOverlay { + pub(crate) additional_deny_write_paths: Vec, +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum ExecCapturePolicy { /// Shell-like execs keep the historical output cap and timeout behavior. @@ -269,7 +282,7 @@ pub fn build_exec_request( expiration, capture_policy, }; - let exec_req = manager + let mut exec_req = manager .transform(SandboxTransformRequest { command, policy: sandbox_policy, @@ -288,6 +301,16 @@ pub fn build_exec_request( }) .map(|request| ExecRequest::from_sandbox_exec_request(request, options)) .map_err(CodexErr::from)?; + exec_req.windows_restricted_token_filesystem_overlay = + resolve_windows_restricted_token_filesystem_overlay( + exec_req.sandbox, + &exec_req.sandbox_policy, + &exec_req.file_system_sandbox_policy, + exec_req.network_sandbox_policy, + sandbox_cwd, + exec_req.windows_sandbox_level, + ) + .map_err(CodexErr::UnsupportedOperation)?; Ok(exec_req) } @@ -310,6 +333,7 @@ pub(crate) async fn execute_exec_request( sandbox_policy: _sandbox_policy_from_env, file_system_sandbox_policy, network_sandbox_policy, + windows_restricted_token_filesystem_overlay, arg0, } = exec_request; let _ = _sandbox_policy_from_env; @@ -334,6 +358,7 @@ pub(crate) async fn execute_exec_request( sandbox, sandbox_policy, &file_system_sandbox_policy, + windows_restricted_token_filesystem_overlay.as_ref(), network_sandbox_policy, stdout_stream, after_spawn, @@ -413,11 +438,11 @@ fn record_windows_sandbox_spawn_failure( async fn exec_windows_sandbox( params: ExecParams, sandbox_policy: &SandboxPolicy, + windows_restricted_token_filesystem_overlay: Option<&WindowsRestrictedTokenFilesystemOverlay>, ) -> Result { use crate::config::find_codex_home; - use codex_protocol::config_types::WindowsSandboxLevel; - use codex_windows_sandbox::run_windows_sandbox_capture; use codex_windows_sandbox::run_windows_sandbox_capture_elevated; + use codex_windows_sandbox::run_windows_sandbox_capture_with_extra_deny_write_paths; let ExecParams { command, @@ -456,6 +481,15 @@ async fn exec_windows_sandbox( let command_path = command.first().cloned(); let sandbox_level = windows_sandbox_level; let use_elevated = matches!(sandbox_level, WindowsSandboxLevel::Elevated); + let additional_deny_write_paths = windows_restricted_token_filesystem_overlay + .map(|overlay| { + overlay + .additional_deny_write_paths + .iter() + .map(AbsolutePathBuf::to_path_buf) + .collect::>() + }) + .unwrap_or_default(); let spawn_res = tokio::task::spawn_blocking(move || { if use_elevated { run_windows_sandbox_capture_elevated( @@ -469,7 +503,7 @@ async fn exec_windows_sandbox( windows_sandbox_private_desktop, ) } else { - run_windows_sandbox_capture( + run_windows_sandbox_capture_with_extra_deny_write_paths( policy_str.as_str(), &sandbox_cwd, codex_home.as_ref(), @@ -477,6 +511,7 @@ async fn exec_windows_sandbox( &cwd, env, timeout_ms, + &additional_deny_write_paths, windows_sandbox_private_desktop, ) } @@ -785,29 +820,25 @@ impl Default for ExecToolCallOutput { } } -#[cfg_attr(not(target_os = "windows"), allow(unused_variables))] +#[allow(clippy::too_many_arguments)] async fn exec( params: ExecParams, - sandbox: SandboxType, - sandbox_policy: &SandboxPolicy, - file_system_sandbox_policy: &FileSystemSandboxPolicy, + _sandbox: SandboxType, + _sandbox_policy: &SandboxPolicy, + _file_system_sandbox_policy: &FileSystemSandboxPolicy, + _windows_restricted_token_filesystem_overlay: Option<&WindowsRestrictedTokenFilesystemOverlay>, network_sandbox_policy: NetworkSandboxPolicy, stdout_stream: Option, after_spawn: Option>, ) -> Result { #[cfg(target_os = "windows")] - if sandbox == SandboxType::WindowsRestrictedToken { - let support = windows_restricted_token_sandbox_support( - sandbox, - params.windows_sandbox_level, - sandbox_policy, - file_system_sandbox_policy, - network_sandbox_policy, - ); - if let Some(reason) = support.unsupported_reason { - return Err(CodexErr::Io(io::Error::other(reason))); - } - return exec_windows_sandbox(params, sandbox_policy).await; + if _sandbox == SandboxType::WindowsRestrictedToken { + return exec_windows_sandbox( + params, + _sandbox_policy, + _windows_restricted_token_filesystem_overlay, + ) + .await; } let ExecParams { command, @@ -852,54 +883,175 @@ async fn exec( } #[cfg_attr(not(target_os = "windows"), allow(dead_code))] -#[derive(Debug, PartialEq, Eq)] -struct WindowsRestrictedTokenSandboxSupport { - should_use: bool, - unsupported_reason: Option, -} - -#[cfg(any(target_os = "windows", test))] -fn windows_restricted_token_sandbox_support( +fn should_use_windows_restricted_token_sandbox( sandbox: SandboxType, - windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel, sandbox_policy: &SandboxPolicy, file_system_sandbox_policy: &FileSystemSandboxPolicy, - network_sandbox_policy: NetworkSandboxPolicy, -) -> WindowsRestrictedTokenSandboxSupport { - if sandbox != SandboxType::WindowsRestrictedToken { - return WindowsRestrictedTokenSandboxSupport { - should_use: false, - unsupported_reason: None, - }; - } - - // Windows currently reuses SandboxType::WindowsRestrictedToken for both - // the legacy restricted-token backend and the elevated setup/runner path. - // The sandbox level decides whether restricted read-only policies are - // supported. - let should_use = file_system_sandbox_policy.kind == FileSystemSandboxKind::Restricted +) -> bool { + sandbox == SandboxType::WindowsRestrictedToken + && file_system_sandbox_policy.kind == FileSystemSandboxKind::Restricted && !matches!( sandbox_policy, SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } ) - && (matches!( - windows_sandbox_level, - codex_protocol::config_types::WindowsSandboxLevel::Elevated - ) || sandbox_policy.has_full_disk_read_access()); +} - let unsupported_reason = if should_use { - None - } else { - Some(format!( +#[cfg_attr(not(test), allow(dead_code))] +pub(crate) fn unsupported_windows_restricted_token_sandbox_reason( + sandbox: SandboxType, + sandbox_policy: &SandboxPolicy, + file_system_sandbox_policy: &FileSystemSandboxPolicy, + network_sandbox_policy: NetworkSandboxPolicy, + sandbox_policy_cwd: &Path, + windows_sandbox_level: WindowsSandboxLevel, +) -> Option { + resolve_windows_restricted_token_filesystem_overlay( + sandbox, + sandbox_policy, + file_system_sandbox_policy, + network_sandbox_policy, + sandbox_policy_cwd, + windows_sandbox_level, + ) + .err() +} + +pub(crate) fn resolve_windows_restricted_token_filesystem_overlay( + sandbox: SandboxType, + sandbox_policy: &SandboxPolicy, + file_system_sandbox_policy: &FileSystemSandboxPolicy, + network_sandbox_policy: NetworkSandboxPolicy, + sandbox_policy_cwd: &Path, + windows_sandbox_level: WindowsSandboxLevel, +) -> std::result::Result, String> { + if sandbox != SandboxType::WindowsRestrictedToken { + return Ok(None); + } + + let needs_direct_runtime_enforcement = file_system_sandbox_policy + .needs_direct_runtime_enforcement(network_sandbox_policy, sandbox_policy_cwd); + + if should_use_windows_restricted_token_sandbox( + sandbox, + sandbox_policy, + file_system_sandbox_policy, + ) && !needs_direct_runtime_enforcement + { + return Ok(None); + } + + if !should_use_windows_restricted_token_sandbox( + sandbox, + sandbox_policy, + file_system_sandbox_policy, + ) { + return Err(format!( "windows sandbox backend cannot enforce file_system={:?}, network={network_sandbox_policy:?}, legacy_policy={sandbox_policy:?}; refusing to run unsandboxed", file_system_sandbox_policy.kind, - )) - }; - - WindowsRestrictedTokenSandboxSupport { - should_use, - unsupported_reason, + )); } + + if windows_sandbox_level != WindowsSandboxLevel::RestrictedToken { + return Err( + "windows elevated sandbox backend cannot enforce split filesystem permissions directly; refusing to run unsandboxed" + .to_string(), + ); + } + + if !file_system_sandbox_policy.has_full_disk_read_access() { + return Err( + "windows unelevated restricted-token sandbox cannot enforce split filesystem read restrictions directly; refusing to run unsandboxed" + .to_string(), + ); + } + + if !file_system_sandbox_policy + .get_unreadable_roots_with_cwd(sandbox_policy_cwd) + .is_empty() + { + return Err( + "windows unelevated restricted-token sandbox cannot enforce unreadable split filesystem carveouts directly; refusing to run unsandboxed" + .to_string(), + ); + } + + let legacy_writable_roots = sandbox_policy.get_writable_roots_with_cwd(sandbox_policy_cwd); + let split_writable_roots = + file_system_sandbox_policy.get_writable_roots_with_cwd(sandbox_policy_cwd); + let legacy_root_paths: BTreeSet = legacy_writable_roots + .iter() + .map(|root| normalize_windows_overlay_path(root.root.as_path())) + .collect::>()?; + let split_root_paths: BTreeSet = split_writable_roots + .iter() + .map(|root| normalize_windows_overlay_path(root.root.as_path())) + .collect::>()?; + + if legacy_root_paths != split_root_paths { + return Err( + "windows unelevated restricted-token sandbox cannot enforce split writable root sets directly; refusing to run unsandboxed" + .to_string(), + ); + } + + for writable_root in &split_writable_roots { + for read_only_subpath in &writable_root.read_only_subpaths { + if split_writable_roots.iter().any(|candidate| { + candidate.root.as_path() != writable_root.root.as_path() + && candidate + .root + .as_path() + .starts_with(read_only_subpath.as_path()) + }) { + return Err( + "windows unelevated restricted-token sandbox cannot reopen writable descendants under read-only carveouts directly; refusing to run unsandboxed" + .to_string(), + ); + } + } + } + + let mut additional_deny_write_paths = BTreeSet::new(); + for split_root in &split_writable_roots { + let split_root_path = normalize_windows_overlay_path(split_root.root.as_path())?; + let Some(legacy_root) = legacy_writable_roots.iter().find(|candidate| { + normalize_windows_overlay_path(candidate.root.as_path()) + .is_ok_and(|candidate_path| candidate_path == split_root_path) + }) else { + return Err( + "windows unelevated restricted-token sandbox cannot enforce split writable root sets directly; refusing to run unsandboxed" + .to_string(), + ); + }; + + for read_only_subpath in &split_root.read_only_subpaths { + if !legacy_root + .read_only_subpaths + .iter() + .any(|candidate| candidate == read_only_subpath) + { + additional_deny_write_paths + .insert(normalize_windows_overlay_path(read_only_subpath.as_path())?); + } + } + } + + if additional_deny_write_paths.is_empty() { + return Ok(None); + } + + Ok(Some(WindowsRestrictedTokenFilesystemOverlay { + additional_deny_write_paths: additional_deny_write_paths + .into_iter() + .map(|path| AbsolutePathBuf::from_absolute_path(path).map_err(|err| err.to_string())) + .collect::>()?, + })) +} + +fn normalize_windows_overlay_path(path: &Path) -> std::result::Result { + AbsolutePathBuf::from_absolute_path(dunce::simplified(path)) + .map(AbsolutePathBuf::into_path_buf) + .map_err(|err| err.to_string()) } /// Consumes the output of a child process according to the configured capture diff --git a/codex-rs/core/src/exec_tests.rs b/codex-rs/core/src/exec_tests.rs index 6f452d6e8a..074db95e1c 100644 --- a/codex-rs/core/src/exec_tests.rs +++ b/codex-rs/core/src/exec_tests.rs @@ -269,6 +269,7 @@ async fn exec_full_buffer_capture_ignores_expiration() -> Result<()> { SandboxType::None, &SandboxPolicy::DangerFullAccess, &FileSystemSandboxPolicy::unrestricted(), + /*windows_restricted_token_filesystem_overlay*/ None, NetworkSandboxPolicy::Enabled, /*stdout_stream*/ None, /*after_spawn*/ None, @@ -308,6 +309,7 @@ async fn exec_full_buffer_capture_keeps_io_drain_timeout_when_descendant_holds_p SandboxType::None, &SandboxPolicy::DangerFullAccess, &FileSystemSandboxPolicy::unrestricted(), + /*windows_restricted_token_filesystem_overlay*/ None, NetworkSandboxPolicy::Enabled, /*stdout_stream*/ None, /*after_spawn*/ None, @@ -376,42 +378,30 @@ fn windows_restricted_token_skips_external_sandbox_policies() { let policy = SandboxPolicy::ExternalSandbox { network_access: codex_protocol::protocol::NetworkAccess::Restricted, }; - let file_system_policy = FileSystemSandboxPolicy::restricted(vec![]); + let file_system_policy = FileSystemSandboxPolicy::from(&policy); assert_eq!( - windows_restricted_token_sandbox_support( + should_use_windows_restricted_token_sandbox( SandboxType::WindowsRestrictedToken, - WindowsSandboxLevel::Disabled, &policy, &file_system_policy, - NetworkSandboxPolicy::Restricted, ), - WindowsRestrictedTokenSandboxSupport { - should_use: false, - unsupported_reason: Some( - "windows sandbox backend cannot enforce file_system=Restricted, network=Restricted, legacy_policy=ExternalSandbox { network_access: Restricted }; refusing to run unsandboxed".to_string() - ), - } + false ); } #[test] fn windows_restricted_token_runs_for_legacy_restricted_policies() { let policy = SandboxPolicy::new_read_only_policy(); - let file_system_policy = FileSystemSandboxPolicy::restricted(vec![]); + let file_system_policy = FileSystemSandboxPolicy::from(&policy); assert_eq!( - windows_restricted_token_sandbox_support( + should_use_windows_restricted_token_sandbox( SandboxType::WindowsRestrictedToken, - WindowsSandboxLevel::Disabled, &policy, &file_system_policy, - NetworkSandboxPolicy::Restricted, ), - WindowsRestrictedTokenSandboxSupport { - should_use: true, - unsupported_reason: None, - } + true ); } @@ -421,70 +411,39 @@ fn windows_restricted_token_rejects_network_only_restrictions() { network_access: codex_protocol::protocol::NetworkAccess::Restricted, }; let file_system_policy = FileSystemSandboxPolicy::unrestricted(); + let sandbox_policy_cwd = std::env::current_dir().expect("cwd"); assert_eq!( - windows_restricted_token_sandbox_support( - SandboxType::WindowsRestrictedToken, - WindowsSandboxLevel::Disabled, - &policy, - &file_system_policy, - NetworkSandboxPolicy::Restricted, - ), - WindowsRestrictedTokenSandboxSupport { - should_use: false, - unsupported_reason: Some( - "windows sandbox backend cannot enforce file_system=Unrestricted, network=Restricted, legacy_policy=ExternalSandbox { network_access: Restricted }; refusing to run unsandboxed".to_string() + unsupported_windows_restricted_token_sandbox_reason( + SandboxType::WindowsRestrictedToken, + &policy, + &file_system_policy, + NetworkSandboxPolicy::Restricted, + &sandbox_policy_cwd, + WindowsSandboxLevel::RestrictedToken, ), - } - ); + Some( + "windows sandbox backend cannot enforce file_system=Unrestricted, network=Restricted, legacy_policy=ExternalSandbox { network_access: Restricted }; refusing to run unsandboxed".to_string() + ) + ); } #[test] fn windows_restricted_token_allows_legacy_restricted_policies() { let policy = SandboxPolicy::new_read_only_policy(); - let file_system_policy = FileSystemSandboxPolicy::restricted(vec![]); - - assert_eq!( - windows_restricted_token_sandbox_support( - SandboxType::WindowsRestrictedToken, - WindowsSandboxLevel::Disabled, - &policy, - &file_system_policy, - NetworkSandboxPolicy::Restricted, - ), - WindowsRestrictedTokenSandboxSupport { - should_use: true, - unsupported_reason: None, - } - ); -} - -#[test] -fn windows_restricted_token_rejects_restricted_read_only_policies() { - let policy = SandboxPolicy::ReadOnly { - access: codex_protocol::protocol::ReadOnlyAccess::Restricted { - include_platform_defaults: true, - readable_roots: vec![], - }, - network_access: false, - }; let file_system_policy = FileSystemSandboxPolicy::from(&policy); + let sandbox_policy_cwd = std::env::current_dir().expect("cwd"); assert_eq!( - windows_restricted_token_sandbox_support( + unsupported_windows_restricted_token_sandbox_reason( SandboxType::WindowsRestrictedToken, - WindowsSandboxLevel::Disabled, &policy, &file_system_policy, NetworkSandboxPolicy::Restricted, + &sandbox_policy_cwd, + WindowsSandboxLevel::RestrictedToken, ), - WindowsRestrictedTokenSandboxSupport { - should_use: false, - unsupported_reason: Some( - "windows sandbox backend cannot enforce file_system=Restricted, network=Restricted, legacy_policy=ReadOnly { access: Restricted { include_platform_defaults: true, readable_roots: [] }, network_access: false }; refusing to run unsandboxed".to_string() - ), - }, - "restricted-token should fail closed for restricted read-only policies" + None ); } @@ -494,50 +453,213 @@ fn windows_restricted_token_allows_legacy_workspace_write_policies() { writable_roots: vec![], read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess, network_access: false, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, }; let file_system_policy = FileSystemSandboxPolicy::from(&policy); + let sandbox_policy_cwd = std::env::current_dir().expect("cwd"); assert_eq!( - windows_restricted_token_sandbox_support( + unsupported_windows_restricted_token_sandbox_reason( SandboxType::WindowsRestrictedToken, - WindowsSandboxLevel::Disabled, &policy, &file_system_policy, NetworkSandboxPolicy::Restricted, + &sandbox_policy_cwd, + WindowsSandboxLevel::RestrictedToken, ), - WindowsRestrictedTokenSandboxSupport { - should_use: true, - unsupported_reason: None, - } + None ); } #[test] -fn windows_elevated_sandbox_allows_restricted_read_only_policies() { - let policy = SandboxPolicy::ReadOnly { - access: codex_protocol::protocol::ReadOnlyAccess::Restricted { - include_platform_defaults: true, - readable_roots: vec![], - }, +fn windows_restricted_token_rejects_split_only_filesystem_policies() { + let temp_dir = tempfile::TempDir::new().expect("tempdir"); + let docs = temp_dir.path().join("docs"); + std::fs::create_dir_all(&docs).expect("create docs"); + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess, network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, }; - let file_system_policy = FileSystemSandboxPolicy::from(&policy); + let file_system_policy = FileSystemSandboxPolicy::restricted(vec![ + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Special { + value: codex_protocol::permissions::FileSystemSpecialPath::CurrentWorkingDirectory, + }, + access: codex_protocol::permissions::FileSystemAccessMode::Write, + }, + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Path { + path: codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs) + .expect("absolute docs"), + }, + access: codex_protocol::permissions::FileSystemAccessMode::Read, + }, + ]); assert_eq!( - windows_restricted_token_sandbox_support( + unsupported_windows_restricted_token_sandbox_reason( SandboxType::WindowsRestrictedToken, - WindowsSandboxLevel::Elevated, &policy, &file_system_policy, NetworkSandboxPolicy::Restricted, + temp_dir.path(), + WindowsSandboxLevel::RestrictedToken, ), - WindowsRestrictedTokenSandboxSupport { - should_use: true, - unsupported_reason: None, + Some( + "windows unelevated restricted-token sandbox cannot enforce split filesystem read restrictions directly; refusing to run unsandboxed" + .to_string() + ) + ); +} + +#[test] +fn windows_restricted_token_rejects_root_write_read_only_carveouts() { + let temp_dir = tempfile::TempDir::new().expect("tempdir"); + let docs = temp_dir.path().join("docs"); + std::fs::create_dir_all(&docs).expect("create docs"); + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess, + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }; + let file_system_policy = FileSystemSandboxPolicy::restricted(vec![ + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Special { + value: codex_protocol::permissions::FileSystemSpecialPath::Root, + }, + access: codex_protocol::permissions::FileSystemAccessMode::Write, }, - "elevated Windows sandbox should keep restricted read-only support enabled" + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Path { + path: codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs) + .expect("absolute docs"), + }, + access: codex_protocol::permissions::FileSystemAccessMode::Read, + }, + ]); + + assert_eq!( + unsupported_windows_restricted_token_sandbox_reason( + SandboxType::WindowsRestrictedToken, + &policy, + &file_system_policy, + NetworkSandboxPolicy::Restricted, + temp_dir.path(), + WindowsSandboxLevel::RestrictedToken, + ), + Some( + "windows unelevated restricted-token sandbox cannot enforce split writable root sets directly; refusing to run unsandboxed" + .to_string() + ) + ); +} + +#[test] +fn windows_restricted_token_supports_full_read_split_write_read_carveouts() { + let temp_dir = tempfile::TempDir::new().expect("tempdir"); + let cwd = dunce::canonicalize(temp_dir.path()).expect("canonicalize temp dir"); + let docs = cwd.join("docs"); + std::fs::create_dir_all(&docs).expect("create docs"); + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess, + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }; + let file_system_policy = FileSystemSandboxPolicy::restricted(vec![ + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Special { + value: codex_protocol::permissions::FileSystemSpecialPath::Root, + }, + access: codex_protocol::permissions::FileSystemAccessMode::Read, + }, + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Special { + value: codex_protocol::permissions::FileSystemSpecialPath::CurrentWorkingDirectory, + }, + access: codex_protocol::permissions::FileSystemAccessMode::Write, + }, + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Path { + path: codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs) + .expect("absolute docs"), + }, + access: codex_protocol::permissions::FileSystemAccessMode::Read, + }, + ]); + + assert_eq!( + resolve_windows_restricted_token_filesystem_overlay( + SandboxType::WindowsRestrictedToken, + &policy, + &file_system_policy, + NetworkSandboxPolicy::Restricted, + &cwd, + WindowsSandboxLevel::RestrictedToken, + ), + Ok(Some(WindowsRestrictedTokenFilesystemOverlay { + additional_deny_write_paths: vec![ + codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs) + .expect("absolute docs"), + ], + })) + ); +} + +#[test] +fn windows_elevated_rejects_split_write_read_carveouts() { + let temp_dir = tempfile::TempDir::new().expect("tempdir"); + let docs = temp_dir.path().join("docs"); + std::fs::create_dir_all(&docs).expect("create docs"); + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess, + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }; + let file_system_policy = FileSystemSandboxPolicy::restricted(vec![ + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Special { + value: codex_protocol::permissions::FileSystemSpecialPath::Root, + }, + access: codex_protocol::permissions::FileSystemAccessMode::Read, + }, + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Special { + value: codex_protocol::permissions::FileSystemSpecialPath::CurrentWorkingDirectory, + }, + access: codex_protocol::permissions::FileSystemAccessMode::Write, + }, + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Path { + path: codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs) + .expect("absolute docs"), + }, + access: codex_protocol::permissions::FileSystemAccessMode::Read, + }, + ]); + + assert_eq!( + unsupported_windows_restricted_token_sandbox_reason( + SandboxType::WindowsRestrictedToken, + &policy, + &file_system_policy, + NetworkSandboxPolicy::Restricted, + temp_dir.path(), + WindowsSandboxLevel::Elevated, + ), + Some( + "windows elevated sandbox backend cannot enforce split filesystem permissions directly; refusing to run unsandboxed" + .to_string() + ) ); } @@ -549,7 +671,7 @@ fn process_exec_tool_call_uses_platform_sandbox_for_network_only_restrictions() select_process_exec_tool_sandbox_type( &FileSystemSandboxPolicy::unrestricted(), NetworkSandboxPolicy::Restricted, - WindowsSandboxLevel::Disabled, + codex_protocol::config_types::WindowsSandboxLevel::Disabled, false, ), expected @@ -581,16 +703,17 @@ async fn kill_child_process_group_kills_grandchildren_on_timeout() -> Result<()> "-c".to_string(), "sleep 60 & echo $!; sleep 60".to_string(), ]; + let cwd = std::env::current_dir()?; let env: HashMap = std::env::vars().collect(); let params = ExecParams { command, - cwd: std::env::current_dir()?, + cwd, expiration: 500.into(), capture_policy: ExecCapturePolicy::ShellTool, env, network: None, sandbox_permissions: SandboxPermissions::UseDefault, - windows_sandbox_level: WindowsSandboxLevel::Disabled, + windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, justification: None, arg0: None, @@ -601,6 +724,7 @@ async fn kill_child_process_group_kills_grandchildren_on_timeout() -> Result<()> SandboxType::None, &SandboxPolicy::new_read_only_policy(), &FileSystemSandboxPolicy::from(&SandboxPolicy::new_read_only_policy()), + None, NetworkSandboxPolicy::Restricted, None, None, @@ -648,7 +772,7 @@ async fn process_exec_tool_call_respects_cancellation_token() -> Result<()> { env, network: None, sandbox_permissions: SandboxPermissions::UseDefault, - windows_sandbox_level: WindowsSandboxLevel::Disabled, + windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, justification: None, arg0: None, diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index 81f2a0af2c..26f4f733c6 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -11,6 +11,7 @@ use crate::exec::ExecCapturePolicy; use crate::exec::ExecExpiration; use crate::exec::ExecToolCallOutput; use crate::exec::StdoutStream; +use crate::exec::WindowsRestrictedTokenFilesystemOverlay; use crate::exec::execute_exec_request; #[cfg(target_os = "macos")] use crate::spawn::CODEX_SANDBOX_ENV_VAR; @@ -46,10 +47,46 @@ pub struct ExecRequest { pub sandbox_policy: SandboxPolicy, pub file_system_sandbox_policy: FileSystemSandboxPolicy, pub network_sandbox_policy: NetworkSandboxPolicy, + pub(crate) windows_restricted_token_filesystem_overlay: + Option, pub arg0: Option, } impl ExecRequest { + #[allow(clippy::too_many_arguments)] + pub fn new( + command: Vec, + cwd: PathBuf, + env: HashMap, + network: Option, + expiration: ExecExpiration, + capture_policy: ExecCapturePolicy, + sandbox: SandboxType, + windows_sandbox_level: WindowsSandboxLevel, + windows_sandbox_private_desktop: bool, + sandbox_policy: SandboxPolicy, + file_system_sandbox_policy: FileSystemSandboxPolicy, + network_sandbox_policy: NetworkSandboxPolicy, + arg0: Option, + ) -> Self { + Self { + command, + cwd, + env, + network, + expiration, + capture_policy, + sandbox, + windows_sandbox_level, + windows_sandbox_private_desktop, + sandbox_policy, + file_system_sandbox_policy, + network_sandbox_policy, + windows_restricted_token_filesystem_overlay: None, + arg0, + } + } + pub(crate) fn from_sandbox_exec_request( request: SandboxExecRequest, options: ExecOptions, @@ -94,6 +131,7 @@ impl ExecRequest { sandbox_policy, file_system_sandbox_policy, network_sandbox_policy, + windows_restricted_token_filesystem_overlay: None, arg0, } } diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 682902f2d6..e118d38095 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -175,6 +175,7 @@ pub(crate) async fn execute_user_shell_command( sandbox_policy: sandbox_policy.clone(), file_system_sandbox_policy: FileSystemSandboxPolicy::from(&sandbox_policy), network_sandbox_policy: NetworkSandboxPolicy::from(&sandbox_policy), + windows_restricted_token_filesystem_overlay: None, arg0: None, }; 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 8c4f24ed05..4e107232d0 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -137,6 +137,7 @@ pub(super) async fn try_run_zsh_fork( sandbox_policy, file_system_sandbox_policy, network_sandbox_policy, + windows_restricted_token_filesystem_overlay: _windows_restricted_token_filesystem_overlay, arg0, } = sandbox_exec_request; let ParsedShellCommand { script, login, .. } = extract_shell_script(&command)?; @@ -909,6 +910,7 @@ impl ShellCommandExecutor for CoreShellCommandExecutor { sandbox_policy: self.sandbox_policy.clone(), file_system_sandbox_policy: self.file_system_sandbox_policy.clone(), network_sandbox_policy: self.network_sandbox_policy, + windows_restricted_token_filesystem_overlay: None, arg0: self.arg0.clone(), }, /*stdout_stream*/ None, diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs index cb4be52750..73193a207d 100644 --- a/codex-rs/windows-sandbox-rs/src/lib.rs +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -144,6 +144,8 @@ pub use token::get_current_token_for_restriction; #[cfg(target_os = "windows")] pub use windows_impl::run_windows_sandbox_capture; #[cfg(target_os = "windows")] +pub use windows_impl::run_windows_sandbox_capture_with_extra_deny_write_paths; +#[cfg(target_os = "windows")] pub use windows_impl::run_windows_sandbox_legacy_preflight; #[cfg(target_os = "windows")] pub use windows_impl::CaptureResult; @@ -259,6 +261,30 @@ mod windows_impl { #[allow(clippy::too_many_arguments)] pub fn run_windows_sandbox_capture( + policy_json_or_preset: &str, + sandbox_policy_cwd: &Path, + codex_home: &Path, + command: Vec, + cwd: &Path, + env_map: HashMap, + timeout_ms: Option, + use_private_desktop: bool, + ) -> Result { + run_windows_sandbox_capture_with_extra_deny_write_paths( + policy_json_or_preset, + sandbox_policy_cwd, + codex_home, + command, + cwd, + env_map, + timeout_ms, + &[], + use_private_desktop, + ) + } + + #[allow(clippy::too_many_arguments)] + pub fn run_windows_sandbox_capture_with_extra_deny_write_paths( policy_json_or_preset: &str, sandbox_policy_cwd: &Path, codex_home: &Path, @@ -266,6 +292,7 @@ mod windows_impl { cwd: &Path, mut env_map: HashMap, timeout_ms: Option, + additional_deny_write_paths: &[PathBuf], use_private_desktop: bool, ) -> Result { let policy = parse_policy(policy_json_or_preset)?; @@ -335,8 +362,13 @@ mod windows_impl { } let persist_aces = is_workspace_write; - let AllowDenyPaths { allow, deny } = + let AllowDenyPaths { allow, mut deny } = compute_allow_paths(&policy, sandbox_policy_cwd, ¤t_dir, &env_map); + for path in additional_deny_write_paths { + if path.exists() { + deny.insert(path.clone()); + } + } let canonical_cwd = canonicalize_path(¤t_dir); let mut guards: Vec<(PathBuf, *mut c_void)> = Vec::new(); unsafe { From c0ffd000dd6d246be139b888b316531c80d6538a Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 25 Mar 2026 00:28:07 -0600 Subject: [PATCH 02/19] Fix stale turn steering fallback in tui_app_server (#15714) This PR adds code to recover from a narrow app-server timing race where a follow-up can be sent after the previous turn has already ended but before the TUI has observed that completion. Instead of surfacing turn/steer failed: no active turn to steer, the client now treats that as a stale active-turn cache and falls back to starting a fresh turn, matching the intended submit behavior more closely. This is similar to the strategy employed by other app server clients (notably, the IDE extension and desktop app). This race exists because the current app-server API makes the client choose between two separate RPCs, turn/steer and turn/start, based on its local view of whether a turn is still active. That view is replicated from asynchronous notifications, so it can be stale for a brief window. The server may already have ended the turn while the client still believes it is in progress. Since the choice is made client-side rather than atomically on the server, tui_app_server can occasionally send turn/steer for a turn that no longer exists. --- codex-rs/tui_app_server/src/app.rs | 50 ++++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/codex-rs/tui_app_server/src/app.rs b/codex-rs/tui_app_server/src/app.rs index 29da170b82..b1773d0ac2 100644 --- a/codex-rs/tui_app_server/src/app.rs +++ b/codex-rs/tui_app_server/src/app.rs @@ -621,6 +621,10 @@ impl ThreadEventStore { fn active_turn_id(&self) -> Option<&str> { self.active_turn_id.as_deref() } + + fn clear_active_turn_id(&mut self) { + self.active_turn_id = None; + } } #[derive(Debug)] @@ -986,6 +990,13 @@ fn active_turn_not_steerable_turn_error(error: &TypedRequestError) -> Option bool { + let TypedRequestError::Server { source, .. } = error else { + return false; + }; + source.message == "no active turn to steer" +} + impl App { pub fn chatwidget_init_for_forked_or_resumed_thread( &self, @@ -2021,23 +2032,32 @@ impl App { collaboration_mode, personality, } => { + let mut should_start_turn = true; if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await { match app_server .turn_steer(thread_id, turn_id, items.to_vec()) .await { - Ok(_) => {} + Ok(_) => return Ok(true), Err(error) => { if let Some(turn_error) = active_turn_not_steerable_turn_error(&error) { if !self.chat_widget.enqueue_rejected_steer() { self.chat_widget.add_error_message(turn_error.message); } + return Ok(true); + } else if active_turn_missing_steer_error(&error) { + if let Some(channel) = self.thread_event_channels.get(&thread_id) { + let mut store = channel.store.lock().await; + store.clear_active_turn_id(); + } + should_start_turn = true; } else { return Err(error.into()); } } } - } else { + } + if should_start_turn { app_server .turn_start( thread_id, @@ -8043,6 +8063,17 @@ guardian_approval = true assert_eq!(refreshed_store.active_turn_id(), Some("turn-2")); } + #[test] + fn thread_event_store_clear_active_turn_id_resets_cached_turn() { + let mut store = ThreadEventStore::new(8); + let thread_id = ThreadId::new(); + store.push_notification(turn_started_notification(thread_id, "turn-1")); + + store.clear_active_turn_id(); + + assert_eq!(store.active_turn_id(), None); + } + #[test] fn thread_event_store_rebase_preserves_resolved_request_state() { let thread_id = ThreadId::new(); @@ -8273,6 +8304,21 @@ guardian_approval = true ); } + #[test] + fn active_turn_missing_steer_error_detects_stale_turn_race() { + let error = TypedRequestError::Server { + method: "turn/steer".to_string(), + source: JSONRPCErrorError { + code: -32602, + message: "no active turn to steer".to_string(), + data: None, + }, + }; + + assert!(active_turn_missing_steer_error(&error)); + assert_eq!(active_turn_not_steerable_turn_error(&error), None); + } + #[test] fn select_model_availability_nux_uses_existing_model_order_as_priority() { let mut presets = all_model_presets(); From e590fad50b832bebc68e7f18067cad5fe13a7d85 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Wed, 25 Mar 2026 00:00:25 -0700 Subject: [PATCH 03/19] [plugins] Add a flag for tool search. (#15722) - [x] Add a flag for tool search. --- codex-rs/app-server/src/config_api.rs | 8 ++- .../suite/v2/experimental_feature_list.rs | 43 ++++++++++++++-- codex-rs/core/config.schema.json | 6 +++ codex-rs/core/src/tools/spec.rs | 3 +- codex-rs/core/src/tools/spec_tests.rs | 25 +++++++++- codex-rs/core/tests/suite/search_tool.rs | 49 ++++++++++++++++++- codex-rs/features/src/lib.rs | 8 +++ codex-rs/features/src/tests.rs | 6 +++ 8 files changed, 141 insertions(+), 7 deletions(-) diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index 9ade74914d..4aa2982a81 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -40,7 +40,13 @@ use std::sync::RwLock; use toml::Value as TomlValue; use tracing::warn; -const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &["apps", "plugins"]; +const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[ + "apps", + "plugins", + "tool_search", + "tool_suggest", + "tool_call_mcp_elicitation", +]; #[async_trait] pub(crate) trait UserConfigReloader: Send + Sync { diff --git a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs index 7bbcc36ee2..3063830d5a 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs @@ -163,14 +163,24 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re .await?; let actual = set_experimental_feature_enablement( &mut mcp, - BTreeMap::from([("plugins".to_string(), true)]), + BTreeMap::from([ + ("plugins".to_string(), true), + ("tool_search".to_string(), true), + ("tool_suggest".to_string(), true), + ("tool_call_mcp_elicitation".to_string(), false), + ]), ) .await?; assert_eq!( actual, ExperimentalFeatureEnablementSetResponse { - enablement: BTreeMap::from([("plugins".to_string(), true)]), + enablement: BTreeMap::from([ + ("plugins".to_string(), true), + ("tool_search".to_string(), true), + ("tool_suggest".to_string(), true), + ("tool_call_mcp_elicitation".to_string(), false), + ]), } ); @@ -190,6 +200,27 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re .and_then(|features| features.get("plugins")), Some(&json!(true)) ); + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("tool_search")), + Some(&json!(true)) + ); + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("tool_suggest")), + Some(&json!(true)) + ); + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("tool_call_mcp_elicitation")), + Some(&json!(false)) + ); Ok(()) } @@ -249,7 +280,13 @@ async fn experimental_feature_enablement_set_rejects_non_allowlisted_feature() - "{}", error.message ); - assert!(error.message.contains("apps, plugins"), "{}", error.message); + assert!( + error + .message + .contains("apps, plugins, tool_search, tool_suggest, tool_call_mcp_elicitation"), + "{}", + error.message + ); Ok(()) } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 0b7cf3ced9..3e2d7ff689 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -485,6 +485,9 @@ "tool_call_mcp_elicitation": { "type": "boolean" }, + "tool_search": { + "type": "boolean" + }, "tool_suggest": { "type": "boolean" }, @@ -2099,6 +2102,9 @@ "tool_call_mcp_elicitation": { "type": "boolean" }, + "tool_search": { + "type": "boolean" + }, "tool_suggest": { "type": "boolean" }, diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 77c2954067..5686b4bb78 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -388,7 +388,8 @@ impl ToolsConfig { let include_request_user_input = !matches!(session_source, SessionSource::SubAgent(_)); let include_default_mode_request_user_input = include_request_user_input && features.enabled(Feature::DefaultModeRequestUserInput); - let include_search_tool = model_info.supports_search_tool; + let include_search_tool = + model_info.supports_search_tool && features.enabled(Feature::ToolSearch); let include_tool_suggest = include_search_tool && features.enabled(Feature::ToolSuggest) && features.enabled(Feature::Apps) diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index c114761761..36a0990393 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -1858,6 +1858,7 @@ fn search_tool_description_lists_each_codex_apps_connector_once() { let model_info = search_capable_model_info(); let mut features = Features::with_defaults(); features.enable(Feature::Apps); + features.enable(Feature::ToolSearch); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, @@ -1976,7 +1977,7 @@ fn search_tool_description_lists_each_codex_apps_connector_once() { } #[test] -fn search_tool_requires_model_capability_only() { +fn search_tool_requires_model_capability_and_feature_flag() { let model_info = search_capable_model_info(); let app_tools = Some(HashMap::from([( "mcp__codex_apps__calendar_create_event".to_string(), @@ -2012,6 +2013,22 @@ fn search_tool_requires_model_capability_only() { }); let (tools, _) = build_specs(&tools_config, None, app_tools.clone(), &[]).build(); assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME); + + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + sandbox_policy: &SandboxPolicy::DangerFullAccess, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }); + let (tools, _) = build_specs(&tools_config, None, app_tools.clone(), &[]).build(); + assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME); + + let mut features = Features::with_defaults(); + features.enable(Feature::ToolSearch); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, @@ -2030,6 +2047,7 @@ fn search_tool_requires_model_capability_only() { fn tool_suggest_is_not_registered_without_feature_flag() { let model_info = search_capable_model_info(); let mut features = Features::with_defaults(); + features.enable(Feature::ToolSearch); features.disable(Feature::ToolSuggest); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { @@ -2073,6 +2091,7 @@ fn tool_suggest_requires_apps_and_plugins_features() { for disabled_feature in [Feature::Apps, Feature::Plugins] { let mut features = Features::with_defaults(); + features.enable(Feature::ToolSearch); features.enable(Feature::ToolSuggest); features.enable(Feature::Apps); features.enable(Feature::Plugins); @@ -2110,6 +2129,7 @@ fn search_tool_description_handles_no_enabled_apps() { let model_info = search_capable_model_info(); let mut features = Features::with_defaults(); features.enable(Feature::Apps); + features.enable(Feature::ToolSearch); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, @@ -2136,6 +2156,7 @@ fn search_tool_description_falls_back_to_connector_name_without_description() { let model_info = search_capable_model_info(); let mut features = Features::with_defaults(); features.enable(Feature::Apps); + features.enable(Feature::ToolSearch); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, @@ -2184,6 +2205,7 @@ fn search_tool_registers_namespaced_app_tool_aliases() { let model_info = search_capable_model_info(); let mut features = Features::with_defaults(); features.enable(Feature::Apps); + features.enable(Feature::ToolSearch); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, @@ -2250,6 +2272,7 @@ fn tool_suggest_description_lists_discoverable_tools() { let mut features = Features::with_defaults(); features.enable(Feature::Apps); features.enable(Feature::Plugins); + features.enable(Feature::ToolSearch); features.enable(Feature::ToolSuggest); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index 0eee2f1d3d..423c57a8c6 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -86,7 +86,7 @@ fn tool_search_output_tools(request: &ResponsesRequest, call_id: &str) -> Vec TestCodexBuilder { test_codex() .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) @@ -169,6 +177,45 @@ async fn search_tool_flag_adds_tool_search() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn tool_search_disabled_by_default_exposes_apps_tools_directly() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount_searchable(&server).await?; + let mock = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-1"), + ]), + ) + .await; + + let mut builder = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(move |config| { + configure_apps_without_tool_search(config, apps_server.chatgpt_base_url.as_str()) + }); + let test = builder.build(&server).await?; + + test.submit_turn_with_policies( + "list tools", + AskForApproval::Never, + SandboxPolicy::DangerFullAccess, + ) + .await?; + + let body = mock.single_request().body_json(); + let tools = tool_names(&body); + assert!(!tools.iter().any(|name| name == TOOL_SEARCH_TOOL_NAME)); + assert!(tools.iter().any(|name| name == CALENDAR_CREATE_TOOL)); + assert!(tools.iter().any(|name| name == CALENDAR_LIST_TOOL)); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn search_tool_is_hidden_for_api_key_auth() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index be98fac69a..ccde6a5e54 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -142,6 +142,8 @@ pub enum Feature { SpawnCsv, /// Enable apps. Apps, + /// Enable the tool_search tool for apps. + ToolSearch, /// Enable discoverable tool suggestions for apps. ToolSuggest, /// Enable plugins. @@ -717,6 +719,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Stable, default_enabled: true, }, + FeatureSpec { + id: Feature::ToolSearch, + key: "tool_search", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::ToolSuggest, key: "tool_suggest", diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index 7c37ef6175..ecfa87b41b 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -120,6 +120,12 @@ fn tool_suggest_is_stable_and_enabled_by_default() { assert_eq!(Feature::ToolSuggest.default_enabled(), true); } +#[test] +fn tool_search_is_under_development_and_disabled_by_default() { + assert_eq!(Feature::ToolSearch.stage(), Stage::UnderDevelopment); + assert_eq!(Feature::ToolSearch.default_enabled(), false); +} + #[test] fn use_linux_sandbox_bwrap_is_a_removed_feature_key() { assert_eq!( From f5dccab5cf9674f46c8bd2cc9606d9d6cd94fadb Mon Sep 17 00:00:00 2001 From: xl-openai Date: Wed, 25 Mar 2026 01:55:10 -0700 Subject: [PATCH 04/19] Update plugin creator skill. (#15734) Add support for home-local plugin + fix policy. --- .../src/assets/samples/plugin-creator/SKILL.md | 12 ++++++++++++ .../references/plugin-json-spec.md | 10 +++++++--- .../scripts/create_basic_plugin.py | 16 +++++++++++++--- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/SKILL.md b/codex-rs/skills/src/assets/samples/plugin-creator/SKILL.md index 28d47b7548..5108f549a7 100644 --- a/codex-rs/skills/src/assets/samples/plugin-creator/SKILL.md +++ b/codex-rs/skills/src/assets/samples/plugin-creator/SKILL.md @@ -26,6 +26,15 @@ python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py ` as the root and use: + +```bash +python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin \ + --path ~/plugins \ + --marketplace-path ~/.agents/plugins/marketplace.json \ + --with-marketplace +``` + 4. Generate/adjust optional companion folders as needed: ```bash @@ -37,6 +46,7 @@ python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin - ## What this skill creates +- If the user has not made the plugin location explicit, ask whether they want a repo-local plugin or a home-local plugin before generating marketplace entries. - Creates plugin root at `///`. - Always creates `///.codex-plugin/plugin.json`. - Fills the manifest with the full schema shape, placeholder values, and the complete `interface` section. @@ -58,6 +68,8 @@ python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin - ## Marketplace workflow - `marketplace.json` always lives at `/.agents/plugins/marketplace.json`. +- For a home-local plugin, use the same convention with `` as the root: + `~/.agents/plugins/marketplace.json` plus `./plugins/`. - Marketplace root metadata supports top-level `name` plus optional `interface.displayName`. - Treat plugin order in `plugins[]` as render order in Codex. Append new entries unless a user explicitly asks to reorder the list. - `displayName` belongs inside the marketplace `interface` object, not individual `plugins[]` entries. diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md b/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md index 3db14d332a..6831aae9db 100644 --- a/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md +++ b/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md @@ -115,8 +115,10 @@ "source": "local", "path": "./plugins/linear" }, - "installPolicy": "AVAILABLE", - "authPolicy": "ON_INSTALL", + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, "category": "Productivity" } ] @@ -142,7 +144,9 @@ - `source` (`string`): Use `local` for this repo workflow. - `path` (`string`): Relative plugin path based on the marketplace root. - Repo plugin: `./plugins/` - - Local plugin in `~/.agents/plugins/marketplace.json`: `./.codex/plugins/` + - Local plugin in `~/.agents/plugins/marketplace.json`: `./plugins/` + - The same relative path convention is used for both repo-rooted and home-rooted marketplaces. + - Example: with `~/.agents/plugins/marketplace.json`, `./plugins/` resolves to `~/plugins/`. - `policy` (`object`): Marketplace policy block. Always include it. - `installation` (`string`): Availability policy. - Allowed values: `NOT_AVAILABLE`, `AVAILABLE`, `INSTALLED_BY_DEFAULT` diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/scripts/create_basic_plugin.py b/codex-rs/skills/src/assets/samples/plugin-creator/scripts/create_basic_plugin.py index 5a61093164..87c2e17d4c 100755 --- a/codex-rs/skills/src/assets/samples/plugin-creator/scripts/create_basic_plugin.py +++ b/codex-rs/skills/src/assets/samples/plugin-creator/scripts/create_basic_plugin.py @@ -191,7 +191,10 @@ def parse_args() -> argparse.Namespace: parser.add_argument( "--path", default=str(DEFAULT_PLUGIN_PARENT), - help="Parent directory for plugin creation (defaults to /plugins)", + help=( + "Parent directory for plugin creation (defaults to /plugins). " + "When using a home-rooted marketplace, use /plugins." + ), ) parser.add_argument("--with-skills", action="store_true", help="Create skills/ directory") parser.add_argument("--with-hooks", action="store_true", help="Create hooks/ directory") @@ -202,12 +205,19 @@ def parse_args() -> argparse.Namespace: parser.add_argument( "--with-marketplace", action="store_true", - help="Create or update /.agents/plugins/marketplace.json", + help=( + "Create or update /.agents/plugins/marketplace.json. " + "Marketplace entries always point to ./plugins/ relative to the " + "marketplace root." + ), ) parser.add_argument( "--marketplace-path", default=str(DEFAULT_MARKETPLACE_PATH), - help="Path to marketplace.json (defaults to /.agents/plugins/marketplace.json)", + help=( + "Path to marketplace.json (defaults to /.agents/plugins/marketplace.json). " + "For a home-rooted marketplace, use /.agents/plugins/marketplace.json." + ), ) parser.add_argument( "--install-policy", From 047ea642d2989f4095a6dc5070aaa818554e550e Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 25 Mar 2026 13:34:43 +0000 Subject: [PATCH 05/19] chore: tty metric (#15766) --- codex-rs/core/src/tools/handlers/unified_exec.rs | 11 +++++++++++ codex-rs/otel/src/metrics/names.rs | 1 + 2 files changed, 12 insertions(+) diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index 109ac713c4..54543277f5 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -25,6 +25,8 @@ use crate::unified_exec::UnifiedExecProcessManager; use crate::unified_exec::WriteStdinRequest; use async_trait::async_trait; use codex_features::Feature; +use codex_otel::SessionTelemetry; +use codex_otel::metrics::names::TOOL_CALL_UNIFIED_EXEC_METRIC; use codex_protocol::models::PermissionProfile; use serde::Deserialize; use std::path::PathBuf; @@ -260,6 +262,7 @@ impl ToolHandler for UnifiedExecHandler { }); } + emit_unified_exec_tty_metric(&turn.session_telemetry, tty); manager .exec_command( ExecCommandRequest { @@ -323,6 +326,14 @@ impl ToolHandler for UnifiedExecHandler { } } +fn emit_unified_exec_tty_metric(session_telemetry: &SessionTelemetry, tty: bool) { + session_telemetry.counter( + TOOL_CALL_UNIFIED_EXEC_METRIC, + /*inc*/ 1, + &[("tty", if tty { "true" } else { "false" })], + ); +} + pub(crate) fn get_command( args: &ExecCommandArgs, session_shell: Arc, diff --git a/codex-rs/otel/src/metrics/names.rs b/codex-rs/otel/src/metrics/names.rs index 58a82afea5..d4f5760d5c 100644 --- a/codex-rs/otel/src/metrics/names.rs +++ b/codex-rs/otel/src/metrics/names.rs @@ -1,5 +1,6 @@ pub const TOOL_CALL_COUNT_METRIC: &str = "codex.tool.call"; pub const TOOL_CALL_DURATION_METRIC: &str = "codex.tool.call.duration_ms"; +pub const TOOL_CALL_UNIFIED_EXEC_METRIC: &str = "codex.tool.unified_exec"; pub const API_CALL_COUNT_METRIC: &str = "codex.api_request"; pub const API_CALL_DURATION_METRIC: &str = "codex.api_request.duration_ms"; pub const SSE_EVENT_COUNT_METRIC: &str = "codex.sse_event"; From 32c4993c8a17cb0608d747f268e3d543745de962 Mon Sep 17 00:00:00 2001 From: Fouad Matin <169186268+fouad-openai@users.noreply.github.com> Date: Wed, 25 Mar 2026 07:55:41 -0700 Subject: [PATCH 06/19] fix(core): default approval behavior for mcp missing annotations (#15519) - Changed `requires_mcp_tool_approval` to apply MCP spec defaults when annotations are missing. - Unannotated tools now default to: - `readOnlyHint = false` - `destructiveHint = true` - `openWorldHint = true` - This means unannotated MCP tools now go through approval/ARC monitoring instead of silently bypassing it. - Explicitly read-only tools still skip approval unless they are also explicitly marked destructive. **Previous behavior** Failed open for missing annotations, which was unsafe for custom MCP tools that omitted or forgot annotations. --------- Co-authored-by: colby-oai <228809017+colby-oai@users.noreply.github.com> --- codex-rs/core/src/mcp_tool_call.rs | 19 +++- codex-rs/core/src/mcp_tool_call_tests.rs | 86 ++++++++++++++++++- .../core/tests/common/apps_test_server.rs | 11 +++ .../rmcp-client/src/bin/test_stdio_server.rs | 19 ++-- .../src/bin/test_streamable_http_server.rs | 7 +- 5 files changed, 127 insertions(+), 15 deletions(-) diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index f82cc73bb5..18a4843578 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -495,7 +495,7 @@ async fn maybe_request_mcp_tool_approval( approval_mode: AppToolApproval, ) -> Option { let annotations = metadata.and_then(|metadata| metadata.annotations.as_ref()); - let approval_required = annotations.is_some_and(requires_mcp_tool_approval); + let approval_required = requires_mcp_tool_approval(annotations); let mut monitor_reason = None; let auto_approved_by_policy = approval_mode == AppToolApproval::Approve || (approval_mode == AppToolApproval::Auto && is_full_access_mode(turn_context)); @@ -1299,12 +1299,23 @@ async fn persist_codex_app_tool_approval( .await } -fn requires_mcp_tool_approval(annotations: &ToolAnnotations) -> bool { - if annotations.destructive_hint == Some(true) { +fn requires_mcp_tool_approval(annotations: Option<&ToolAnnotations>) -> bool { + let destructive_hint = annotations.and_then(|annotations| annotations.destructive_hint); + if destructive_hint == Some(true) { return true; } - annotations.read_only_hint == Some(false) && annotations.open_world_hint == Some(true) + let read_only_hint = annotations + .and_then(|annotations| annotations.read_only_hint) + .unwrap_or(false); + if read_only_hint { + return false; + } + + destructive_hint.unwrap_or(true) + || annotations + .and_then(|annotations| annotations.open_world_hint) + .unwrap_or(true) } async fn notify_mcp_tool_call_skip( diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 0ac37f9ce8..9d7d1c3a41 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -64,19 +64,30 @@ fn prompt_options( #[test] fn approval_required_when_read_only_false_and_destructive() { let annotations = annotations(Some(false), Some(true), None); - assert_eq!(requires_mcp_tool_approval(&annotations), true); + assert_eq!(requires_mcp_tool_approval(Some(&annotations)), true); } #[test] fn approval_required_when_read_only_false_and_open_world() { let annotations = annotations(Some(false), None, Some(true)); - assert_eq!(requires_mcp_tool_approval(&annotations), true); + assert_eq!(requires_mcp_tool_approval(Some(&annotations)), true); } #[test] fn approval_required_when_destructive_even_if_read_only_true() { let annotations = annotations(Some(true), Some(true), Some(true)); - assert_eq!(requires_mcp_tool_approval(&annotations), true); + assert_eq!(requires_mcp_tool_approval(Some(&annotations)), true); +} + +#[test] +fn approval_required_when_annotations_are_absent() { + assert_eq!(requires_mcp_tool_approval(None), true); +} + +#[test] +fn approval_not_required_when_read_only_and_other_hints_are_absent() { + let annotations = annotations(Some(true), None, None); + assert_eq!(requires_mcp_tool_approval(Some(&annotations)), false); } #[test] @@ -1067,6 +1078,75 @@ async fn approve_mode_blocks_when_arc_returns_interrupt_for_model() { ); } +#[tokio::test] +async fn approve_mode_blocks_when_arc_returns_interrupt_without_annotations() { + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::method; + use wiremock::matchers::path; + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/safety/arc")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "outcome": "steer-model", + "short_reason": "needs approval", + "rationale": "high-risk action", + "risk_score": 96, + "risk_level": "critical", + "evidence": [{ + "message": "dangerous_tool", + "why": "high-risk action", + }], + }))) + .expect(1) + .mount(&server) + .await; + + let (session, mut turn_context) = make_session_and_context().await; + turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth( + crate::CodexAuth::create_dummy_chatgpt_auth_for_testing(), + )); + let mut config = (*turn_context.config).clone(); + config.chatgpt_base_url = server.uri(); + turn_context.config = Arc::new(config); + + let session = Arc::new(session); + let turn_context = Arc::new(turn_context); + let invocation = McpInvocation { + server: CODEX_APPS_MCP_SERVER_NAME.to_string(), + tool: "dangerous_tool".to_string(), + arguments: Some(serde_json::json!({ "id": 1 })), + }; + let metadata = McpToolApprovalMetadata { + annotations: None, + connector_id: Some("calendar".to_string()), + connector_name: Some("Calendar".to_string()), + connector_description: Some("Manage events".to_string()), + tool_title: Some("Dangerous Tool".to_string()), + tool_description: Some("Performs a risky action.".to_string()), + codex_apps_meta: None, + }; + + let decision = maybe_request_mcp_tool_approval( + &session, + &turn_context, + "call-3", + &invocation, + Some(&metadata), + AppToolApproval::Approve, + ) + .await; + + assert_eq!( + decision, + Some(McpToolApprovalDecision::BlockedBySafetyMonitor( + "Tool call was cancelled because of safety risks: high-risk action".to_string(), + )) + ); +} + #[tokio::test] async fn full_access_auto_mode_blocks_when_arc_returns_interrupt_for_model() { use wiremock::Mock; diff --git a/codex-rs/core/tests/common/apps_test_server.rs b/codex-rs/core/tests/common/apps_test_server.rs index 450a170b2a..da5499c573 100644 --- a/codex-rs/core/tests/common/apps_test_server.rs +++ b/codex-rs/core/tests/common/apps_test_server.rs @@ -185,6 +185,11 @@ impl Respond for CodexAppsJsonRpcResponder { { "name": "calendar_create_event", "description": "Create a calendar event.", + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "openWorldHint": false + }, "inputSchema": { "type": "object", "properties": { @@ -209,6 +214,9 @@ impl Respond for CodexAppsJsonRpcResponder { { "name": "calendar_list_events", "description": "List calendar events.", + "annotations": { + "readOnlyHint": true + }, "inputSchema": { "type": "object", "properties": { @@ -241,6 +249,9 @@ impl Respond for CodexAppsJsonRpcResponder { tools.push(json!({ "name": format!("calendar_timezone_option_{index}"), "description": format!("Read timezone option {index}."), + "annotations": { + "readOnlyHint": true + }, "inputSchema": { "type": "object", "properties": { diff --git a/codex-rs/rmcp-client/src/bin/test_stdio_server.rs b/codex-rs/rmcp-client/src/bin/test_stdio_server.rs index cd08307767..d9202babae 100644 --- a/codex-rs/rmcp-client/src/bin/test_stdio_server.rs +++ b/codex-rs/rmcp-client/src/bin/test_stdio_server.rs @@ -22,6 +22,7 @@ use rmcp::model::ResourceTemplate; use rmcp::model::ServerCapabilities; use rmcp::model::ServerInfo; use rmcp::model::Tool; +use rmcp::model::ToolAnnotations; use serde::Deserialize; use serde_json::json; use tokio::task; @@ -85,11 +86,13 @@ impl TestToolServer { })) .expect("echo tool schema should deserialize"); - Tool::new( + let mut tool = Tool::new( Cow::Borrowed(name), Cow::Borrowed(description), Arc::new(schema), - ) + ); + tool.annotations = Some(ToolAnnotations::new().read_only(true)); + tool } fn image_tool() -> Tool { @@ -101,11 +104,13 @@ impl TestToolServer { })) .expect("image tool schema should deserialize"); - Tool::new( + let mut tool = Tool::new( Cow::Borrowed("image"), Cow::Borrowed("Return a single image content block."), Arc::new(schema), - ) + ); + tool.annotations = Some(ToolAnnotations::new().read_only(true)); + tool } /// Tool intended for manual testing of Codex TUI rendering for MCP image tool results. @@ -154,13 +159,15 @@ impl TestToolServer { })) .expect("image_scenario tool schema should deserialize"); - Tool::new( + let mut tool = Tool::new( Cow::Borrowed("image_scenario"), Cow::Borrowed( "Return content blocks for manual testing of MCP image rendering scenarios.", ), Arc::new(schema), - ) + ); + tool.annotations = Some(ToolAnnotations::new().read_only(true)); + tool } fn memo_resource() -> Resource { diff --git a/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs b/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs index 284e1194c3..50ea06c7fa 100644 --- a/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs +++ b/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs @@ -38,6 +38,7 @@ use rmcp::model::ResourceTemplate; use rmcp::model::ServerCapabilities; use rmcp::model::ServerInfo; use rmcp::model::Tool; +use rmcp::model::ToolAnnotations; use rmcp::transport::StreamableHttpServerConfig; use rmcp::transport::StreamableHttpService; use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; @@ -84,11 +85,13 @@ impl TestToolServer { })) .expect("echo tool schema should deserialize"); - Tool::new( + let mut tool = Tool::new( Cow::Borrowed("echo"), Cow::Borrowed("Echo back the provided message and include environment data."), Arc::new(schema), - ) + ); + tool.annotations = Some(ToolAnnotations::new().read_only(true)); + tool } fn memo_resource() -> Resource { From 178c3b15b475104459815767e0561756c8cd00ed Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 25 Mar 2026 16:01:45 +0000 Subject: [PATCH 07/19] chore: remove grep_files handler (#15775) # External (non-OpenAI) Pull Request Requirements Before opening this Pull Request, please read the dedicated "Contributing" markdown file or your PR may be closed: https://github.com/openai/codex/blob/main/docs/contributing.md If your PR conforms to our contribution guidelines, replace this text with a detailed and high quality description of your changes. Include a link to a bug report or enhancement request. --------- Co-authored-by: Codex --- .../core/src/tools/handlers/grep_files.rs | 176 ------------------ codex-rs/core/src/tools/handlers/mod.rs | 2 - codex-rs/core/src/tools/spec.rs | 68 ------- codex-rs/core/src/tools/spec_tests.rs | 21 +-- codex-rs/core/tests/common/test_codex.rs | 7 +- codex-rs/core/tests/suite/grep_files.rs | 145 --------------- codex-rs/core/tests/suite/list_dir.rs | 167 ----------------- codex-rs/core/tests/suite/mod.rs | 3 - codex-rs/core/tests/suite/read_file.rs | 42 ----- 9 files changed, 2 insertions(+), 629 deletions(-) delete mode 100644 codex-rs/core/src/tools/handlers/grep_files.rs delete mode 100644 codex-rs/core/tests/suite/grep_files.rs delete mode 100644 codex-rs/core/tests/suite/list_dir.rs delete mode 100644 codex-rs/core/tests/suite/read_file.rs diff --git a/codex-rs/core/src/tools/handlers/grep_files.rs b/codex-rs/core/src/tools/handlers/grep_files.rs deleted file mode 100644 index fdb0fce7be..0000000000 --- a/codex-rs/core/src/tools/handlers/grep_files.rs +++ /dev/null @@ -1,176 +0,0 @@ -use std::path::Path; -use std::time::Duration; - -use async_trait::async_trait; -use serde::Deserialize; -use tokio::process::Command; -use tokio::time::timeout; - -use crate::function_tool::FunctionCallError; -use crate::tools::context::FunctionToolOutput; -use crate::tools::context::ToolInvocation; -use crate::tools::context::ToolPayload; -use crate::tools::handlers::parse_arguments; -use crate::tools::registry::ToolHandler; -use crate::tools::registry::ToolKind; - -pub struct GrepFilesHandler; - -const DEFAULT_LIMIT: usize = 100; -const MAX_LIMIT: usize = 2000; -const COMMAND_TIMEOUT: Duration = Duration::from_secs(30); - -fn default_limit() -> usize { - DEFAULT_LIMIT -} - -#[derive(Deserialize)] -struct GrepFilesArgs { - pattern: String, - #[serde(default)] - include: Option, - #[serde(default)] - path: Option, - #[serde(default = "default_limit")] - limit: usize, -} - -#[async_trait] -impl ToolHandler for GrepFilesHandler { - type Output = FunctionToolOutput; - - fn kind(&self) -> ToolKind { - ToolKind::Function - } - - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { payload, turn, .. } = invocation; - - let arguments = match payload { - ToolPayload::Function { arguments } => arguments, - _ => { - return Err(FunctionCallError::RespondToModel( - "grep_files handler received unsupported payload".to_string(), - )); - } - }; - - let args: GrepFilesArgs = parse_arguments(&arguments)?; - - let pattern = args.pattern.trim(); - if pattern.is_empty() { - return Err(FunctionCallError::RespondToModel( - "pattern must not be empty".to_string(), - )); - } - - if args.limit == 0 { - return Err(FunctionCallError::RespondToModel( - "limit must be greater than zero".to_string(), - )); - } - - let limit = args.limit.min(MAX_LIMIT); - let search_path = turn.resolve_path(args.path.clone()); - - verify_path_exists(&search_path).await?; - - let include = args.include.as_deref().map(str::trim).and_then(|val| { - if val.is_empty() { - None - } else { - Some(val.to_string()) - } - }); - - let search_results = - run_rg_search(pattern, include.as_deref(), &search_path, limit, &turn.cwd).await?; - - if search_results.is_empty() { - Ok(FunctionToolOutput::from_text( - "No matches found.".to_string(), - Some(false), - )) - } else { - Ok(FunctionToolOutput::from_text( - search_results.join("\n"), - Some(true), - )) - } - } -} - -async fn verify_path_exists(path: &Path) -> Result<(), FunctionCallError> { - tokio::fs::metadata(path).await.map_err(|err| { - FunctionCallError::RespondToModel(format!("unable to access `{}`: {err}", path.display())) - })?; - Ok(()) -} - -async fn run_rg_search( - pattern: &str, - include: Option<&str>, - search_path: &Path, - limit: usize, - cwd: &Path, -) -> Result, FunctionCallError> { - let mut command = Command::new("rg"); - command - .current_dir(cwd) - .arg("--files-with-matches") - .arg("--sortr=modified") - .arg("--regexp") - .arg(pattern) - .arg("--no-messages"); - - if let Some(glob) = include { - command.arg("--glob").arg(glob); - } - - command.arg("--").arg(search_path); - - let output = timeout(COMMAND_TIMEOUT, command.output()) - .await - .map_err(|_| { - FunctionCallError::RespondToModel("rg timed out after 30 seconds".to_string()) - })? - .map_err(|err| { - FunctionCallError::RespondToModel(format!( - "failed to launch rg: {err}. Ensure ripgrep is installed and on PATH." - )) - })?; - - match output.status.code() { - Some(0) => Ok(parse_results(&output.stdout, limit)), - Some(1) => Ok(Vec::new()), - _ => { - let stderr = String::from_utf8_lossy(&output.stderr); - Err(FunctionCallError::RespondToModel(format!( - "rg failed: {stderr}" - ))) - } - } -} - -fn parse_results(stdout: &[u8], limit: usize) -> Vec { - let mut results = Vec::new(); - for line in stdout.split(|byte| *byte == b'\n') { - if line.is_empty() { - continue; - } - if let Ok(text) = std::str::from_utf8(line) { - if text.is_empty() { - continue; - } - results.push(text.to_string()); - if results.len() == limit { - break; - } - } - } - results -} - -#[cfg(test)] -#[path = "grep_files_tests.rs"] -mod tests; diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index 9b14f7ee50..1e9f48820b 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -2,7 +2,6 @@ pub(crate) mod agent_jobs; pub mod apply_patch; mod artifacts; mod dynamic; -mod grep_files; mod js_repl; mod list_dir; mod mcp; @@ -41,7 +40,6 @@ pub use artifacts::ArtifactsHandler; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; pub use dynamic::DynamicToolHandler; -pub use grep_files::GrepFilesHandler; pub use js_repl::JsReplHandler; pub use js_repl::JsReplResetHandler; pub use list_dir::ListDirHandler; diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 5686b4bb78..d16b2567ac 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -1839,59 +1839,6 @@ fn create_test_sync_tool() -> ToolSpec { }) } -fn create_grep_files_tool() -> ToolSpec { - let properties = BTreeMap::from([ - ( - "pattern".to_string(), - JsonSchema::String { - description: Some("Regular expression pattern to search for.".to_string()), - }, - ), - ( - "include".to_string(), - JsonSchema::String { - description: Some( - "Optional glob that limits which files are searched (e.g. \"*.rs\" or \ - \"*.{ts,tsx}\")." - .to_string(), - ), - }, - ), - ( - "path".to_string(), - JsonSchema::String { - description: Some( - "Directory or file path to search. Defaults to the session's working directory." - .to_string(), - ), - }, - ), - ( - "limit".to_string(), - JsonSchema::Number { - description: Some( - "Maximum number of file paths to return (defaults to 100).".to_string(), - ), - }, - ), - ]); - - ToolSpec::Function(ResponsesApiTool { - name: "grep_files".to_string(), - description: "Finds files whose contents match the pattern and lists them by modification \ - time." - .to_string(), - strict: false, - defer_loading: None, - parameters: JsonSchema::Object { - properties, - required: Some(vec!["pattern".to_string()]), - additional_properties: Some(false.into()), - }, - output_schema: None, - }) -} - fn create_tool_search_tool(app_tools: &HashMap) -> ToolSpec { let properties = BTreeMap::from([ ( @@ -2751,7 +2698,6 @@ pub(crate) fn build_specs_with_discoverable_tools( use crate::tools::handlers::CodeModeExecuteHandler; use crate::tools::handlers::CodeModeWaitHandler; use crate::tools::handlers::DynamicToolHandler; - use crate::tools::handlers::GrepFilesHandler; use crate::tools::handlers::JsReplHandler; use crate::tools::handlers::JsReplResetHandler; use crate::tools::handlers::ListDirHandler; @@ -3029,20 +2975,6 @@ pub(crate) fn build_specs_with_discoverable_tools( builder.register_handler("apply_patch", apply_patch_handler); } - if config - .experimental_supported_tools - .contains(&"grep_files".to_string()) - { - let grep_files_handler = Arc::new(GrepFilesHandler); - push_tool_spec( - &mut builder, - create_grep_files_tool(), - /*supports_parallel_tool_calls*/ true, - config.code_mode_enabled, - ); - builder.register_handler("grep_files", grep_files_handler); - } - if config .experimental_supported_tools .contains(&"read_file".to_string()) diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index 36a0990393..f12aee6fa2 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -1665,21 +1665,13 @@ fn test_parallel_support_flags() { assert!(find_tool(&tools, "exec_command").supports_parallel_tool_calls); assert!(!find_tool(&tools, "write_stdin").supports_parallel_tool_calls); - assert!(find_tool(&tools, "grep_files").supports_parallel_tool_calls); - assert!(find_tool(&tools, "list_dir").supports_parallel_tool_calls); - assert!(find_tool(&tools, "read_file").supports_parallel_tool_calls); } #[test] fn test_test_model_info_includes_sync_tool() { let _config = test_config(); let mut model_info = model_info_from_models_json("gpt-5-codex"); - model_info.experimental_supported_tools = vec![ - "test_sync_tool".to_string(), - "read_file".to_string(), - "grep_files".to_string(), - "list_dir".to_string(), - ]; + model_info.experimental_supported_tools = vec!["test_sync_tool".to_string()]; let features = Features::with_defaults(); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { @@ -1698,17 +1690,6 @@ fn test_test_model_info_includes_sync_tool() { .iter() .any(|tool| tool_name(&tool.spec) == "test_sync_tool") ); - assert!( - tools - .iter() - .any(|tool| tool_name(&tool.spec) == "read_file") - ); - assert!( - tools - .iter() - .any(|tool| tool_name(&tool.spec) == "grep_files") - ); - assert!(tools.iter().any(|tool| tool_name(&tool.spec) == "list_dir")); } #[test] diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index c61631e5bf..7a7ae7af4b 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -616,12 +616,7 @@ fn ensure_test_model_catalog(config: &mut Config) -> Result<()> { .unwrap_or_else(|| panic!("missing bundled model gpt-5.1-codex")); model.slug = TEST_MODEL_WITH_EXPERIMENTAL_TOOLS.to_string(); model.display_name = TEST_MODEL_WITH_EXPERIMENTAL_TOOLS.to_string(); - model.experimental_supported_tools = vec![ - "test_sync_tool".to_string(), - "read_file".to_string(), - "grep_files".to_string(), - "list_dir".to_string(), - ]; + model.experimental_supported_tools = vec!["test_sync_tool".to_string()]; config.model_catalog = Some(ModelsResponse { models: vec![model], }); diff --git a/codex-rs/core/tests/suite/grep_files.rs b/codex-rs/core/tests/suite/grep_files.rs deleted file mode 100644 index 1b8cc110ef..0000000000 --- a/codex-rs/core/tests/suite/grep_files.rs +++ /dev/null @@ -1,145 +0,0 @@ -#![cfg(not(target_os = "windows"))] - -use anyhow::Result; -use core_test_support::responses::mount_function_call_agent_response; -use core_test_support::responses::start_mock_server; -use core_test_support::skip_if_no_network; -use core_test_support::test_codex::TestCodex; -use core_test_support::test_codex::test_codex; -use std::collections::HashSet; -use std::path::Path; -use std::process::Command as StdCommand; - -const MODEL_WITH_TOOL: &str = "test-gpt-5.1-codex"; - -fn ripgrep_available() -> bool { - StdCommand::new("rg") - .arg("--version") - .output() - .map(|output| output.status.success()) - .unwrap_or(false) -} - -macro_rules! skip_if_ripgrep_missing { - ($ret:expr $(,)?) => {{ - if !ripgrep_available() { - eprintln!("rg not available in PATH; skipping test"); - return $ret; - } - }}; -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn grep_files_tool_collects_matches() -> Result<()> { - skip_if_no_network!(Ok(())); - skip_if_ripgrep_missing!(Ok(())); - - let server = start_mock_server().await; - let test = build_test_codex(&server).await?; - - let search_dir = test.cwd.path().join("src"); - std::fs::create_dir_all(&search_dir)?; - let alpha = search_dir.join("alpha.rs"); - let beta = search_dir.join("beta.rs"); - let gamma = search_dir.join("gamma.txt"); - std::fs::write(&alpha, "alpha needle\n")?; - std::fs::write(&beta, "beta needle\n")?; - std::fs::write(&gamma, "needle in text but excluded\n")?; - - let call_id = "grep-files-collect"; - let arguments = serde_json::json!({ - "pattern": "needle", - "path": search_dir.to_string_lossy(), - "include": "*.rs", - }) - .to_string(); - - let mocks = - mount_function_call_agent_response(&server, call_id, &arguments, "grep_files").await; - test.submit_turn("please find uses of needle").await?; - - let req = mocks.completion.single_request(); - let (content_opt, success_opt) = req - .function_call_output_content_and_success(call_id) - .expect("tool output present"); - let content = content_opt.expect("content present"); - let success = success_opt.unwrap_or(true); - assert!( - success, - "expected success for matches, got content={content}" - ); - - let entries = collect_file_names(&content); - assert_eq!(entries.len(), 2, "content: {content}"); - assert!( - entries.contains("alpha.rs"), - "missing alpha.rs in {entries:?}" - ); - assert!( - entries.contains("beta.rs"), - "missing beta.rs in {entries:?}" - ); - assert!( - !entries.contains("gamma.txt"), - "txt file should be filtered out: {entries:?}" - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn grep_files_tool_reports_empty_results() -> Result<()> { - skip_if_no_network!(Ok(())); - skip_if_ripgrep_missing!(Ok(())); - - let server = start_mock_server().await; - let test = build_test_codex(&server).await?; - - let search_dir = test.cwd.path().join("logs"); - std::fs::create_dir_all(&search_dir)?; - std::fs::write(search_dir.join("output.txt"), "no hits here")?; - - let call_id = "grep-files-empty"; - let arguments = serde_json::json!({ - "pattern": "needle", - "path": search_dir.to_string_lossy(), - "limit": 5, - }) - .to_string(); - - let mocks = - mount_function_call_agent_response(&server, call_id, &arguments, "grep_files").await; - test.submit_turn("search again").await?; - - let req = mocks.completion.single_request(); - let (content_opt, success_opt) = req - .function_call_output_content_and_success(call_id) - .expect("tool output present"); - let content = content_opt.expect("content present"); - if let Some(success) = success_opt { - assert!(!success, "expected success=false content={content}"); - } - assert_eq!(content, "No matches found."); - - Ok(()) -} - -#[allow(clippy::expect_used)] -async fn build_test_codex(server: &wiremock::MockServer) -> Result { - let mut builder = test_codex().with_model(MODEL_WITH_TOOL); - builder.build(server).await -} - -fn collect_file_names(content: &str) -> HashSet { - content - .lines() - .filter_map(|line| { - if line.trim().is_empty() { - return None; - } - Path::new(line) - .file_name() - .map(|name| name.to_string_lossy().into_owned()) - }) - .collect() -} diff --git a/codex-rs/core/tests/suite/list_dir.rs b/codex-rs/core/tests/suite/list_dir.rs deleted file mode 100644 index 802080d846..0000000000 --- a/codex-rs/core/tests/suite/list_dir.rs +++ /dev/null @@ -1,167 +0,0 @@ -#![cfg(not(target_os = "windows"))] - -use core_test_support::responses::mount_function_call_agent_response; -use core_test_support::responses::start_mock_server; -use core_test_support::skip_if_no_network; -use core_test_support::test_codex::test_codex; -use pretty_assertions::assert_eq; -use serde_json::json; - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[ignore = "disabled until we enable list_dir tool"] -async fn list_dir_tool_returns_entries() -> anyhow::Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let test = test_codex().build(&server).await?; - - let dir_path = test.cwd.path().join("sample_dir"); - std::fs::create_dir(&dir_path)?; - std::fs::write(dir_path.join("alpha.txt"), "first file")?; - std::fs::create_dir(dir_path.join("nested"))?; - let dir_path = dir_path.to_string_lossy().to_string(); - - let call_id = "list-dir-call"; - let arguments = json!({ - "dir_path": dir_path, - "offset": 1, - "limit": 2, - }) - .to_string(); - - let mocks = mount_function_call_agent_response(&server, call_id, &arguments, "list_dir").await; - test.submit_turn("list directory contents").await?; - let req = mocks.completion.single_request(); - let (content_opt, _) = req - .function_call_output_content_and_success(call_id) - .expect("function_call_output present"); - let output = content_opt.expect("output content present in tool output"); - assert_eq!(output, "E1: [file] alpha.txt\nE2: [dir] nested"); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[ignore = "disabled until we enable list_dir tool"] -async fn list_dir_tool_depth_one_omits_children() -> anyhow::Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let test = test_codex().build(&server).await?; - - let dir_path = test.cwd.path().join("depth_one"); - std::fs::create_dir(&dir_path)?; - std::fs::write(dir_path.join("alpha.txt"), "alpha")?; - std::fs::create_dir(dir_path.join("nested"))?; - std::fs::write(dir_path.join("nested").join("beta.txt"), "beta")?; - let dir_path = dir_path.to_string_lossy().to_string(); - - let call_id = "list-dir-depth1"; - let arguments = json!({ - "dir_path": dir_path, - "offset": 1, - "limit": 10, - "depth": 1, - }) - .to_string(); - - let mocks = mount_function_call_agent_response(&server, call_id, &arguments, "list_dir").await; - test.submit_turn("list directory contents depth one") - .await?; - let req = mocks.completion.single_request(); - let (content_opt, _) = req - .function_call_output_content_and_success(call_id) - .expect("function_call_output present"); - let output = content_opt.expect("output content present in tool output"); - assert_eq!(output, "E1: [file] alpha.txt\nE2: [dir] nested"); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[ignore = "disabled until we enable list_dir tool"] -async fn list_dir_tool_depth_two_includes_children_only() -> anyhow::Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let test = test_codex().build(&server).await?; - - let dir_path = test.cwd.path().join("depth_two"); - std::fs::create_dir(&dir_path)?; - std::fs::write(dir_path.join("alpha.txt"), "alpha")?; - let nested = dir_path.join("nested"); - std::fs::create_dir(&nested)?; - std::fs::write(nested.join("beta.txt"), "beta")?; - let deeper = nested.join("grand"); - std::fs::create_dir(&deeper)?; - std::fs::write(deeper.join("gamma.txt"), "gamma")?; - let dir_path_string = dir_path.to_string_lossy().to_string(); - - let call_id = "list-dir-depth2"; - let arguments = json!({ - "dir_path": dir_path_string, - "offset": 1, - "limit": 10, - "depth": 2, - }) - .to_string(); - - let mocks = mount_function_call_agent_response(&server, call_id, &arguments, "list_dir").await; - test.submit_turn("list directory contents depth two") - .await?; - let req = mocks.completion.single_request(); - let (content_opt, _) = req - .function_call_output_content_and_success(call_id) - .expect("function_call_output present"); - let output = content_opt.expect("output content present in tool output"); - assert_eq!( - output, - "E1: [file] alpha.txt\nE2: [dir] nested\nE3: [file] nested/beta.txt\nE4: [dir] nested/grand" - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[ignore = "disabled until we enable list_dir tool"] -async fn list_dir_tool_depth_three_includes_grandchildren() -> anyhow::Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let test = test_codex().build(&server).await?; - - let dir_path = test.cwd.path().join("depth_three"); - std::fs::create_dir(&dir_path)?; - std::fs::write(dir_path.join("alpha.txt"), "alpha")?; - let nested = dir_path.join("nested"); - std::fs::create_dir(&nested)?; - std::fs::write(nested.join("beta.txt"), "beta")?; - let deeper = nested.join("grand"); - std::fs::create_dir(&deeper)?; - std::fs::write(deeper.join("gamma.txt"), "gamma")?; - let dir_path_string = dir_path.to_string_lossy().to_string(); - - let call_id = "list-dir-depth3"; - let arguments = json!({ - "dir_path": dir_path_string, - "offset": 1, - "limit": 10, - "depth": 3, - }) - .to_string(); - - let mocks = mount_function_call_agent_response(&server, call_id, &arguments, "list_dir").await; - test.submit_turn("list directory contents depth three") - .await?; - let req = mocks.completion.single_request(); - let (content_opt, _) = req - .function_call_output_content_and_success(call_id) - .expect("function_call_output present"); - let output = content_opt.expect("output content present in tool output"); - assert_eq!( - output, - "E1: [file] alpha.txt\nE2: [dir] nested\nE3: [file] nested/beta.txt\nE4: [dir] nested/grand\nE5: [file] nested/grand/gamma.txt" - ); - - Ok(()) -} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 5f7e50f061..f4891d58c5 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -75,7 +75,6 @@ mod deprecation_notice; mod exec; mod exec_policy; mod fork_thread; -mod grep_files; mod hierarchical_agents; #[cfg(not(target_os = "windows"))] mod hooks; @@ -83,7 +82,6 @@ mod image_rollout; mod items; mod js_repl; mod json_result; -mod list_dir; mod live_cli; mod live_reload; mod memories; @@ -101,7 +99,6 @@ mod personality_migration; mod plugins; mod prompt_caching; mod quota_exceeded; -mod read_file; mod realtime_conversation; mod remote_env; mod remote_models; diff --git a/codex-rs/core/tests/suite/read_file.rs b/codex-rs/core/tests/suite/read_file.rs deleted file mode 100644 index 57be21f9f7..0000000000 --- a/codex-rs/core/tests/suite/read_file.rs +++ /dev/null @@ -1,42 +0,0 @@ -#![cfg(not(target_os = "windows"))] - -use core_test_support::responses::mount_function_call_agent_response; -use core_test_support::responses::start_mock_server; -use core_test_support::skip_if_no_network; -use core_test_support::test_codex::test_codex; -use pretty_assertions::assert_eq; -use serde_json::json; - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[ignore = "disabled until we enable read_file tool"] -async fn read_file_tool_returns_requested_lines() -> anyhow::Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let test = test_codex().build(&server).await?; - - let file_path = test.cwd.path().join("sample.txt"); - std::fs::write(&file_path, "first\nsecond\nthird\nfourth\n")?; - let file_path = file_path.to_string_lossy().to_string(); - - let call_id = "read-file-call"; - let arguments = json!({ - "file_path": file_path, - "offset": 2, - "limit": 2, - }) - .to_string(); - - let mocks = mount_function_call_agent_response(&server, call_id, &arguments, "read_file").await; - - test.submit_turn("please inspect sample.txt").await?; - - let req = mocks.completion.single_request(); - let (output_text_opt, _) = req - .function_call_output_content_and_success(call_id) - .expect("output present"); - let output_text = output_text_opt.expect("output text present"); - assert_eq!(output_text, "L2: second\nL3: third"); - - Ok(()) -} From 504aeb0e09bbc40771d4f23fc89c8571c27289cd Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Wed, 25 Mar 2026 09:02:22 -0700 Subject: [PATCH 08/19] 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 402dd51ddd..6e42f18e4c 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 03226cced4..d519bd5f67 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 06c6eae1e6..b7d7b55ab1 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 168965e1ea..1e874b4d66 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 1ed668d56f..e468bfceb5 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 a81970756b..d87dd070d5 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 12720f2437..34399a000c 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 e9050024eb..b576ff3eba 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 7d3af80249..f67c532341 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 539663c90a..c785b04618 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 e744fd5478..bbb39da113 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 50bf2ed843..64e27c42cd 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 89c22528e8..3e6d409e76 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 7e6ecaca10..744db4886b 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 2e0d7c4add..321b314abb 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 e04a018a84..a929401b96 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 7ad122c404..1b72f64610 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 4cea541be3..866831d8b1 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 e118d38095..b90717e715 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 40e7439ccc..64830fa727 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 875fcd486b..910dcbb470 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 b380a7107d..43b986e0d8 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 14dc222ffe..9613a8303f 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 4a6aa26ff4..77ed20dd8e 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 0a29fb1200..266aa2a1ce 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 413e171d41..c759554009 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 9e92a6b00f..02b6bbe601 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 37b7d015b3..df97d7128d 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 4e107232d0..9df94e828f 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 a760249d61..8631a20955 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 0b31793402..484bd189ef 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 7a7ae7af4b..7b0d412786 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 5da71f556b..869acc49f1 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 1517d9968c..df8d6e4fe4 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 7da4d4d774..5f01e10d58 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 e1c45d6418..8a3fadfbb0 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 9be83e6bb0..1bcd068614 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 b1e6a694ca..e609005436 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 81d3d1e6da..7aa05f9720 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 801ff76274..c0fe8b5352 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 732d6b29eb..4041ca1d19 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 56e542f597..43ccc78657 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 4c3c30e81c..a4a7155ed0 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 120b7237ee..588884639d 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 5888b3b1b9..837ef05043 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 e1314e27f0..41e9b49897 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 8192724da0..157e5feec9 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 039148a718..2fcf2e3dc3 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 127ac9f667..fe3cab73b5 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 a7546230bf..cadd3383a3 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 e09073d138..e14f27fdcc 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 16ab60bd72..10f26445e5 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 0000000000..790fc805ea --- /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 b1773d0ac2..e00d5ebc05 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 df73ef20a3..7a636ededc 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 120b7237ee..588884639d 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 c3c2ef7692..6eefc52a87 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 38937406b7..445d684469 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 790a7c6f55..4a36eb58c6 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 d092d06e2f..ec8e428947 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 4b5a6474bd..d7556154d5 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 7272125560..90c045fb9c 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 a224eb7803..fbfbca9128 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 0000000000..790fc805ea --- /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 1ba1c40cfb..69691ac752 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"); From f190a95a4f0d7195c1c5cff48157b6c451be7c27 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 25 Mar 2026 16:07:04 +0000 Subject: [PATCH 09/19] feat: rendering library v1 (#15778) The goal will be to replace askama --- codex-rs/Cargo.lock | 7 + codex-rs/Cargo.toml | 4 +- codex-rs/utils/template/BUILD.bazel | 6 + codex-rs/utils/template/Cargo.toml | 11 + codex-rs/utils/template/README.md | 41 +++ codex-rs/utils/template/src/lib.rs | 442 ++++++++++++++++++++++++++++ 6 files changed, 510 insertions(+), 1 deletion(-) create mode 100644 codex-rs/utils/template/BUILD.bazel create mode 100644 codex-rs/utils/template/Cargo.toml create mode 100644 codex-rs/utils/template/README.md create mode 100644 codex-rs/utils/template/src/lib.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 9ecf9b1de4..33244c1185 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3006,6 +3006,13 @@ dependencies = [ "regex-lite", ] +[[package]] +name = "codex-utils-template" +version = "0.0.0" +dependencies = [ + "pretty_assertions", +] + [[package]] name = "codex-v8-poc" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 5854c1c46d..3d2ee99482 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -70,6 +70,7 @@ members = [ "utils/path-utils", "utils/fuzzy-match", "utils/stream-parser", + "utils/template", "codex-client", "codex-api", "state", @@ -166,6 +167,7 @@ codex-utils-rustls-provider = { path = "utils/rustls-provider" } codex-utils-sandbox-summary = { path = "utils/sandbox-summary" } codex-utils-sleep-inhibitor = { path = "utils/sleep-inhibitor" } codex-utils-stream-parser = { path = "utils/stream-parser" } +codex-utils-template = { path = "utils/template" } codex-utils-string = { path = "utils/string" } codex-windows-sandbox = { path = "windows-sandbox-rs" } core_test_support = { path = "core/tests/common" } @@ -383,7 +385,7 @@ ignored = [ "icu_provider", "openssl-sys", "codex-utils-readiness", - "codex-secrets", + "codex-utils-template", "codex-v8-poc", ] diff --git a/codex-rs/utils/template/BUILD.bazel b/codex-rs/utils/template/BUILD.bazel new file mode 100644 index 0000000000..4b3376de4e --- /dev/null +++ b/codex-rs/utils/template/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "template", + crate_name = "codex_utils_template", +) diff --git a/codex-rs/utils/template/Cargo.toml b/codex-rs/utils/template/Cargo.toml new file mode 100644 index 0000000000..60a0c5b984 --- /dev/null +++ b/codex-rs/utils/template/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "codex-utils-template" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dev-dependencies] +pretty_assertions = { workspace = true } diff --git a/codex-rs/utils/template/README.md b/codex-rs/utils/template/README.md new file mode 100644 index 0000000000..3197e115eb --- /dev/null +++ b/codex-rs/utils/template/README.md @@ -0,0 +1,41 @@ +# codex-utils-template + +Small, strict string templating for prompt and text assets. + +Supported syntax: + +- `{{ name }}` placeholder interpolation +- `{{{{` for a literal `{{` +- `}}}}` for a literal `}}` + +The library is intentionally strict: + +- parsing fails on malformed placeholders +- rendering fails on missing values +- rendering fails on duplicate values +- rendering fails on extra values not used by the template + +## Example + +```rust +use codex_utils_template::Template; +use codex_utils_template::render; + +let template = Template::parse( + "Hello, {{ name }}.\nLiteral braces: {{{{ and }}}}.\nMode: {{ mode }}", +)?; + +let rendered = template.render([ + ("name", "Codex"), + ("mode", "strict"), +])?; + +assert_eq!( + rendered, + "Hello, Codex.\nLiteral braces: {{ and }}.\nMode: strict" +); + +let one_shot = render("Hi {{ who }}!", [("who", "there")])?; +assert_eq!(one_shot, "Hi there!"); +# Ok::<(), Box>(()) +``` diff --git a/codex-rs/utils/template/src/lib.rs b/codex-rs/utils/template/src/lib.rs new file mode 100644 index 0000000000..62d2314ffb --- /dev/null +++ b/codex-rs/utils/template/src/lib.rs @@ -0,0 +1,442 @@ +//! Minimal strict templating for prompt and text assets. +//! +//! Supported syntax: +//! - `{{ name }}` placeholder interpolation +//! - `{{{{` for a literal `{{` +//! - `}}}}` for a literal `}}` + +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use std::error::Error; +use std::fmt; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TemplateParseError { + EmptyPlaceholder { start: usize }, + NestedPlaceholder { start: usize }, + UnmatchedClosingDelimiter { start: usize }, + UnterminatedPlaceholder { start: usize }, +} + +impl fmt::Display for TemplateParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptyPlaceholder { start } => { + write!(f, "template placeholder at byte {start} is empty") + } + Self::NestedPlaceholder { start } => { + write!( + f, + "template placeholder starting at byte {start} contains a nested `{{`" + ) + } + Self::UnmatchedClosingDelimiter { start } => { + write!(f, "template contains an unmatched `}}` at byte {start}") + } + Self::UnterminatedPlaceholder { start } => { + write!( + f, + "template placeholder starting at byte {start} is missing `}}`" + ) + } + } + } +} + +impl Error for TemplateParseError {} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TemplateRenderError { + DuplicateValue { name: String }, + ExtraValue { name: String }, + MissingValue { name: String }, +} + +impl fmt::Display for TemplateRenderError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::DuplicateValue { name } => { + write!(f, "template value `{name}` was provided more than once") + } + Self::ExtraValue { name } => { + write!(f, "template value `{name}` is not used by this template") + } + Self::MissingValue { name } => { + write!(f, "template placeholder `{name}` is missing a value") + } + } + } +} + +impl Error for TemplateRenderError {} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TemplateError { + Parse(TemplateParseError), + Render(TemplateRenderError), +} + +impl fmt::Display for TemplateError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Parse(err) => err.fmt(f), + Self::Render(err) => err.fmt(f), + } + } +} + +impl Error for TemplateError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Parse(err) => Some(err), + Self::Render(err) => Some(err), + } + } +} + +impl From for TemplateError { + fn from(value: TemplateParseError) -> Self { + Self::Parse(value) + } +} + +impl From for TemplateError { + fn from(value: TemplateRenderError) -> Self { + Self::Render(value) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Segment { + Literal(String), + Placeholder(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Template { + placeholders: BTreeSet, + segments: Vec, +} + +impl Template { + pub fn parse(source: &str) -> Result { + let mut placeholders = BTreeSet::new(); + let mut segments = Vec::new(); + let mut literal_start = 0usize; + let mut cursor = 0usize; + + while cursor < source.len() { + let rest = &source[cursor..]; + if rest.starts_with("{{{{") { + push_literal(&mut segments, &source[literal_start..cursor]); + push_literal(&mut segments, "{{"); + cursor += "{{{{".len(); + literal_start = cursor; + continue; + } + if rest.starts_with("}}}}") { + push_literal(&mut segments, &source[literal_start..cursor]); + push_literal(&mut segments, "}}"); + cursor += "}}}}".len(); + literal_start = cursor; + continue; + } + if rest.starts_with("{{") { + push_literal(&mut segments, &source[literal_start..cursor]); + let (placeholder, next_cursor) = parse_placeholder(source, cursor)?; + placeholders.insert(placeholder.clone()); + segments.push(Segment::Placeholder(placeholder)); + cursor = next_cursor; + literal_start = cursor; + continue; + } + if rest.starts_with("}}") { + return Err(TemplateParseError::UnmatchedClosingDelimiter { start: cursor }); + } + + let Some(ch) = rest.chars().next() else { + break; + }; + cursor += ch.len_utf8(); + } + + push_literal(&mut segments, &source[literal_start..]); + Ok(Self { + placeholders, + segments, + }) + } + + pub fn placeholders(&self) -> impl ExactSizeIterator { + self.placeholders.iter().map(String::as_str) + } + + pub fn render(&self, variables: I) -> Result + where + I: IntoIterator, + K: AsRef, + V: AsRef, + { + let variables = build_variable_map(variables)?; + + for placeholder in &self.placeholders { + if !variables.contains_key(placeholder.as_str()) { + return Err(TemplateRenderError::MissingValue { + name: placeholder.clone(), + }); + } + } + + for name in variables.keys() { + if !self.placeholders.contains(name.as_str()) { + return Err(TemplateRenderError::ExtraValue { name: name.clone() }); + } + } + + let mut rendered = String::new(); + for segment in &self.segments { + match segment { + Segment::Literal(literal) => rendered.push_str(literal), + Segment::Placeholder(name) => { + let Some(value) = variables.get(name.as_str()) else { + return Err(TemplateRenderError::MissingValue { name: name.clone() }); + }; + rendered.push_str(value); + } + } + } + Ok(rendered) + } +} + +pub fn render(template: &str, variables: I) -> Result +where + I: IntoIterator, + K: AsRef, + V: AsRef, +{ + Template::parse(template)? + .render(variables) + .map_err(Into::into) +} + +fn push_literal(segments: &mut Vec, literal: &str) { + if literal.is_empty() { + return; + } + + if let Some(Segment::Literal(existing)) = segments.last_mut() { + existing.push_str(literal); + } else { + segments.push(Segment::Literal(literal.to_string())); + } +} + +fn parse_placeholder(source: &str, start: usize) -> Result<(String, usize), TemplateParseError> { + let placeholder_start = start + "{{".len(); + let mut cursor = placeholder_start; + + while cursor < source.len() { + let rest = &source[cursor..]; + if rest.starts_with("{{") { + return Err(TemplateParseError::NestedPlaceholder { start }); + } + if rest.starts_with("}}") { + let placeholder = source[placeholder_start..cursor].trim(); + if placeholder.is_empty() { + return Err(TemplateParseError::EmptyPlaceholder { start }); + } + return Ok((placeholder.to_string(), cursor + "}}".len())); + } + + let Some(ch) = rest.chars().next() else { + break; + }; + cursor += ch.len_utf8(); + } + + Err(TemplateParseError::UnterminatedPlaceholder { start }) +} + +fn build_variable_map( + variables: I, +) -> Result, TemplateRenderError> +where + I: IntoIterator, + K: AsRef, + V: AsRef, +{ + let mut map = BTreeMap::new(); + for (name, value) in variables { + let name = name.as_ref().to_string(); + if map + .insert(name.clone(), value.as_ref().to_string()) + .is_some() + { + return Err(TemplateRenderError::DuplicateValue { name }); + } + } + Ok(map) +} + +#[cfg(test)] +mod tests { + use super::Template; + use super::TemplateError; + use super::TemplateParseError; + use super::TemplateRenderError; + use super::render; + use pretty_assertions::assert_eq; + + #[test] + fn render_replaces_placeholders_with_and_without_whitespace() { + let rendered = render( + "Hello, {{ name }}. You are in {{place}}. {{ name }} is repeated.", + [("name", "Codex"), ("place", "codex-rs")], + ) + .unwrap(); + + assert_eq!( + rendered, + "Hello, Codex. You are in codex-rs. Codex is repeated." + ); + } + + #[test] + fn parsed_templates_can_be_reused() { + let template = Template::parse("{{greeting}}, {{ name }}!").unwrap(); + + assert_eq!( + template.render([("greeting", "Hello"), ("name", "Codex")]), + Ok("Hello, Codex!".to_string()) + ); + assert_eq!( + template.render([("greeting", "Hi"), ("name", "builder")]), + Ok("Hi, builder!".to_string()) + ); + } + + #[test] + fn placeholders_are_sorted_and_unique() { + let template = Template::parse("{{ b }} {{ a }} {{ b }}").unwrap(); + + assert_eq!(template.placeholders().collect::>(), vec!["a", "b"]); + } + + #[test] + fn render_supports_multiline_templates_and_adjacent_placeholders() { + let rendered = render( + "Line 1: {{first}}{{second}}\nLine 2: {{ third }}", + [("first", "A"), ("second", "B"), ("third", "C")], + ) + .unwrap(); + + assert_eq!(rendered, "Line 1: AB\nLine 2: C"); + } + + #[test] + fn render_supports_literal_delimiter_escapes() { + let rendered = render( + "literal open: {{{{, literal close: }}}}, value: {{ name }}", + [("name", "Codex")], + ) + .unwrap(); + + assert_eq!( + rendered, + "literal open: {{, literal close: }}, value: Codex" + ); + } + + #[test] + fn parse_errors_when_placeholder_is_empty() { + let err = Template::parse("Hello, {{ }}.").unwrap_err(); + + assert_eq!(err, TemplateParseError::EmptyPlaceholder { start: 7 }); + } + + #[test] + fn parse_errors_when_placeholder_is_unterminated() { + let err = Template::parse("Hello, {{ name.").unwrap_err(); + + assert_eq!( + err, + TemplateParseError::UnterminatedPlaceholder { start: 7 } + ); + } + + #[test] + fn parse_errors_when_placeholder_is_nested() { + let err = Template::parse("Hello, {{ outer {{ inner }} }}.").unwrap_err(); + + assert_eq!(err, TemplateParseError::NestedPlaceholder { start: 7 }); + } + + #[test] + fn parse_errors_when_closing_delimiter_is_unmatched() { + let err = Template::parse("Hello, }} world.").unwrap_err(); + + assert_eq!( + err, + TemplateParseError::UnmatchedClosingDelimiter { start: 7 } + ); + } + + #[test] + fn render_errors_when_placeholder_is_missing() { + let template = Template::parse("Hello, {{ name }}.").unwrap(); + + assert_eq!( + template.render(Vec::<(&str, &str)>::new()), + Err(TemplateRenderError::MissingValue { + name: "name".to_string() + }) + ); + } + + #[test] + fn render_errors_when_extra_value_is_provided() { + let template = Template::parse("Hello, {{ name }}.").unwrap(); + + assert_eq!( + template.render([("name", "Codex"), ("unused", "extra")]), + Err(TemplateRenderError::ExtraValue { + name: "unused".to_string() + }) + ); + } + + #[test] + fn render_errors_when_duplicate_value_is_provided() { + let template = Template::parse("Hello, {{ name }}.").unwrap(); + + assert_eq!( + template.render([("name", "Codex"), ("name", "other")]), + Err(TemplateRenderError::DuplicateValue { + name: "name".to_string() + }) + ); + } + + #[test] + fn render_function_wraps_parse_errors() { + let err = render("Hello, }} world.", [("name", "Codex")]).unwrap_err(); + + assert_eq!( + err, + TemplateError::Parse(TemplateParseError::UnmatchedClosingDelimiter { start: 7 }) + ); + } + + #[test] + fn render_function_wraps_render_errors() { + let err = render("Hello, {{ name }}.", [("extra", "Codex")]).unwrap_err(); + + assert_eq!( + err, + TemplateError::Render(TemplateRenderError::MissingValue { + name: "name".to_string() + }) + ); + } +} From c6ffe9abab04bd3349ecc49fffc0fbf9551826e6 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Wed, 25 Mar 2026 13:14:39 -0300 Subject: [PATCH 10/19] fix(tui): avoid duplicate live reasoning summaries (#15758) ## TL;DR Fix duplicated reasoning summaries in `tui_app_server`. image During live turns, reasoning text is already rendered incrementally from `ReasoningSummaryTextDelta`. When the same reasoning item later arrives via `ItemCompleted`, we should only finalize the reasoning block, not render the same summary again. ## What changed - only replay rendered reasoning summaries from completed `ThreadItem::Reasoning` items - kept live completed reasoning items as finalize-only - added a regression test covering the live streaming + completion path ## Why Without this, the first reasoning summary often appears twice in the transcript when `model_reasoning_summary = "detailed"` and `features.tui_app_server = true`. --- codex-rs/tui_app_server/src/chatwidget.rs | 12 +++-- .../tui_app_server/src/chatwidget/tests.rs | 51 +++++++++++++++++++ 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/codex-rs/tui_app_server/src/chatwidget.rs b/codex-rs/tui_app_server/src/chatwidget.rs index 7a636ededc..f3c272d62b 100644 --- a/codex-rs/tui_app_server/src/chatwidget.rs +++ b/codex-rs/tui_app_server/src/chatwidget.rs @@ -5675,13 +5675,15 @@ impl ChatWidget { ThreadItem::Reasoning { summary, content, .. } => { - for delta in summary { - self.on_agent_reasoning_delta(delta); - } - if self.config.show_raw_agent_reasoning { - for delta in content { + if from_replay { + for delta in summary { self.on_agent_reasoning_delta(delta); } + if self.config.show_raw_agent_reasoning { + for delta in content { + self.on_agent_reasoning_delta(delta); + } + } } self.on_agent_reasoning_final(); } diff --git a/codex-rs/tui_app_server/src/chatwidget/tests.rs b/codex-rs/tui_app_server/src/chatwidget/tests.rs index 6eefc52a87..98509303ea 100644 --- a/codex-rs/tui_app_server/src/chatwidget/tests.rs +++ b/codex-rs/tui_app_server/src/chatwidget/tests.rs @@ -47,6 +47,7 @@ use codex_app_server_protocol::PluginMarketplaceEntry; use codex_app_server_protocol::PluginReadResponse; use codex_app_server_protocol::PluginSource; use codex_app_server_protocol::PluginSummary; +use codex_app_server_protocol::ReasoningSummaryTextDeltaNotification; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SkillSummary; use codex_app_server_protocol::ThreadClosedNotification; @@ -5135,6 +5136,56 @@ async fn replayed_reasoning_item_shows_raw_reasoning_when_enabled() { assert!(rendered.contains("Raw reasoning")); } +#[tokio::test] +async fn live_reasoning_summary_is_not_rendered_twice_when_item_completes() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.show_welcome_banner = false; + + chat.handle_server_notification( + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: "thread-1".to_string(), + turn: AppServerTurn { + id: "turn-1".to_string(), + items: Vec::new(), + status: AppServerTurnStatus::InProgress, + error: None, + }, + }), + None, + ); + let _ = drain_insert_history(&mut rx); + + chat.handle_server_notification( + ServerNotification::ReasoningSummaryTextDelta(ReasoningSummaryTextDeltaNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "reasoning-1".to_string(), + delta: "Summary only".to_string(), + summary_index: 0, + }), + None, + ); + + chat.handle_server_notification( + ServerNotification::ItemCompleted(ItemCompletedNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item: AppServerThreadItem::Reasoning { + id: "reasoning-1".to_string(), + summary: vec!["Summary only".to_string()], + content: Vec::new(), + }, + }), + None, + ); + + let rendered = match rx.try_recv() { + Ok(AppEvent::InsertHistoryCell(cell)) => lines_to_single_string(&cell.transcript_lines(80)), + other => panic!("expected InsertHistoryCell, got {other:?}"), + }; + assert_eq!(rendered.matches("Summary only").count(), 1); +} + #[test] fn rendered_user_message_event_from_inputs_matches_flattened_user_message_shape() { let local_image = PathBuf::from("/tmp/local.png"); From 14c35a16a8a41cc16c5e36c2c4287b7b2db6e975 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 25 Mar 2026 16:27:32 +0000 Subject: [PATCH 11/19] chore: remove read_file handler (#15773) Co-authored-by: Codex --- codex-rs/core/src/tools/handlers/mod.rs | 2 - codex-rs/core/src/tools/handlers/read_file.rs | 489 ------------------ codex-rs/core/src/tools/spec.rs | 120 ----- 3 files changed, 611 deletions(-) delete mode 100644 codex-rs/core/src/tools/handlers/read_file.rs diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index 1e9f48820b..ba0a0eb50d 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -10,7 +10,6 @@ pub(crate) mod multi_agents; pub(crate) mod multi_agents_common; pub(crate) mod multi_agents_v2; mod plan; -mod read_file; mod request_permissions; mod request_user_input; mod shell; @@ -46,7 +45,6 @@ pub use list_dir::ListDirHandler; pub use mcp::McpHandler; pub use mcp_resource::McpResourceHandler; pub use plan::PlanHandler; -pub use read_file::ReadFileHandler; pub use request_permissions::RequestPermissionsHandler; pub(crate) use request_permissions::request_permissions_tool_description; pub use request_user_input::RequestUserInputHandler; diff --git a/codex-rs/core/src/tools/handlers/read_file.rs b/codex-rs/core/src/tools/handlers/read_file.rs deleted file mode 100644 index b868edf5b9..0000000000 --- a/codex-rs/core/src/tools/handlers/read_file.rs +++ /dev/null @@ -1,489 +0,0 @@ -use std::collections::VecDeque; -use std::path::PathBuf; - -use async_trait::async_trait; -use codex_utils_string::take_bytes_at_char_boundary; -use serde::Deserialize; - -use crate::function_tool::FunctionCallError; -use crate::tools::context::FunctionToolOutput; -use crate::tools::context::ToolInvocation; -use crate::tools::context::ToolPayload; -use crate::tools::handlers::parse_arguments; -use crate::tools::registry::ToolHandler; -use crate::tools::registry::ToolKind; - -pub struct ReadFileHandler; - -const MAX_LINE_LENGTH: usize = 500; -const TAB_WIDTH: usize = 4; - -// TODO(jif) add support for block comments -const COMMENT_PREFIXES: &[&str] = &["#", "//", "--"]; - -/// JSON arguments accepted by the `read_file` tool handler. -#[derive(Deserialize)] -struct ReadFileArgs { - /// Absolute path to the file that will be read. - file_path: String, - /// 1-indexed line number to start reading from; defaults to 1. - #[serde(default = "defaults::offset")] - offset: usize, - /// Maximum number of lines to return; defaults to 2000. - #[serde(default = "defaults::limit")] - limit: usize, - /// Determines whether the handler reads a simple slice or indentation-aware block. - #[serde(default)] - mode: ReadMode, - /// Optional indentation configuration used when `mode` is `Indentation`. - #[serde(default)] - indentation: Option, -} - -#[derive(Deserialize, Default)] -#[serde(rename_all = "snake_case")] -enum ReadMode { - #[default] - Slice, - Indentation, -} -/// Additional configuration for indentation-aware reads. -#[derive(Deserialize, Clone)] -struct IndentationArgs { - /// Optional explicit anchor line; defaults to `offset` when omitted. - #[serde(default)] - anchor_line: Option, - /// Maximum indentation depth to collect; `0` means unlimited. - #[serde(default = "defaults::max_levels")] - max_levels: usize, - /// Whether to include sibling blocks at the same indentation level. - #[serde(default = "defaults::include_siblings")] - include_siblings: bool, - /// Whether to include header lines above the anchor block. This made on a best effort basis. - #[serde(default = "defaults::include_header")] - include_header: bool, - /// Optional hard cap on returned lines; defaults to the global `limit`. - #[serde(default)] - max_lines: Option, -} - -#[derive(Clone, Debug)] -struct LineRecord { - number: usize, - raw: String, - display: String, - indent: usize, -} - -impl LineRecord { - fn trimmed(&self) -> &str { - self.raw.trim_start() - } - - fn is_blank(&self) -> bool { - self.trimmed().is_empty() - } - - fn is_comment(&self) -> bool { - COMMENT_PREFIXES - .iter() - .any(|prefix| self.raw.trim().starts_with(prefix)) - } -} - -#[async_trait] -impl ToolHandler for ReadFileHandler { - type Output = FunctionToolOutput; - - fn kind(&self) -> ToolKind { - ToolKind::Function - } - - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { payload, .. } = invocation; - - let arguments = match payload { - ToolPayload::Function { arguments } => arguments, - _ => { - return Err(FunctionCallError::RespondToModel( - "read_file handler received unsupported payload".to_string(), - )); - } - }; - - let args: ReadFileArgs = parse_arguments(&arguments)?; - - let ReadFileArgs { - file_path, - offset, - limit, - mode, - indentation, - } = args; - - if offset == 0 { - return Err(FunctionCallError::RespondToModel( - "offset must be a 1-indexed line number".to_string(), - )); - } - - if limit == 0 { - return Err(FunctionCallError::RespondToModel( - "limit must be greater than zero".to_string(), - )); - } - - let path = PathBuf::from(&file_path); - if !path.is_absolute() { - return Err(FunctionCallError::RespondToModel( - "file_path must be an absolute path".to_string(), - )); - } - - let collected = match mode { - ReadMode::Slice => slice::read(&path, offset, limit).await?, - ReadMode::Indentation => { - let indentation = indentation.unwrap_or_default(); - indentation::read_block(&path, offset, limit, indentation).await? - } - }; - Ok(FunctionToolOutput::from_text( - collected.join("\n"), - Some(true), - )) - } -} - -mod slice { - use crate::function_tool::FunctionCallError; - use crate::tools::handlers::read_file::format_line; - use std::path::Path; - use tokio::fs::File; - use tokio::io::AsyncBufReadExt; - use tokio::io::BufReader; - - pub async fn read( - path: &Path, - offset: usize, - limit: usize, - ) -> Result, FunctionCallError> { - let file = File::open(path).await.map_err(|err| { - FunctionCallError::RespondToModel(format!("failed to read file: {err}")) - })?; - - let mut reader = BufReader::new(file); - let mut collected = Vec::new(); - let mut seen = 0usize; - let mut buffer = Vec::new(); - - loop { - buffer.clear(); - let bytes_read = reader.read_until(b'\n', &mut buffer).await.map_err(|err| { - FunctionCallError::RespondToModel(format!("failed to read file: {err}")) - })?; - - if bytes_read == 0 { - break; - } - - if buffer.last() == Some(&b'\n') { - buffer.pop(); - if buffer.last() == Some(&b'\r') { - buffer.pop(); - } - } - - seen += 1; - - if seen < offset { - continue; - } - - if collected.len() == limit { - break; - } - - let formatted = format_line(&buffer); - collected.push(format!("L{seen}: {formatted}")); - - if collected.len() == limit { - break; - } - } - - if seen < offset { - return Err(FunctionCallError::RespondToModel( - "offset exceeds file length".to_string(), - )); - } - - Ok(collected) - } -} - -mod indentation { - use crate::function_tool::FunctionCallError; - use crate::tools::handlers::read_file::IndentationArgs; - use crate::tools::handlers::read_file::LineRecord; - use crate::tools::handlers::read_file::TAB_WIDTH; - use crate::tools::handlers::read_file::format_line; - use crate::tools::handlers::read_file::trim_empty_lines; - use std::collections::VecDeque; - use std::path::Path; - use tokio::fs::File; - use tokio::io::AsyncBufReadExt; - use tokio::io::BufReader; - - pub async fn read_block( - path: &Path, - offset: usize, - limit: usize, - options: IndentationArgs, - ) -> Result, FunctionCallError> { - let anchor_line = options.anchor_line.unwrap_or(offset); - if anchor_line == 0 { - return Err(FunctionCallError::RespondToModel( - "anchor_line must be a 1-indexed line number".to_string(), - )); - } - - let guard_limit = options.max_lines.unwrap_or(limit); - if guard_limit == 0 { - return Err(FunctionCallError::RespondToModel( - "max_lines must be greater than zero".to_string(), - )); - } - - let collected = collect_file_lines(path).await?; - if collected.is_empty() || anchor_line > collected.len() { - return Err(FunctionCallError::RespondToModel( - "anchor_line exceeds file length".to_string(), - )); - } - - let anchor_index = anchor_line - 1; - let effective_indents = compute_effective_indents(&collected); - let anchor_indent = effective_indents[anchor_index]; - - // Compute the min indent - let min_indent = if options.max_levels == 0 { - 0 - } else { - anchor_indent.saturating_sub(options.max_levels * TAB_WIDTH) - }; - - // Cap requested lines by guard_limit and file length - let final_limit = limit.min(guard_limit).min(collected.len()); - - if final_limit == 1 { - return Ok(vec![format!( - "L{}: {}", - collected[anchor_index].number, collected[anchor_index].display - )]); - } - - // Cursors - let mut i: isize = anchor_index as isize - 1; // up (inclusive) - let mut j: usize = anchor_index + 1; // down (inclusive) - let mut i_counter_min_indent = 0; - let mut j_counter_min_indent = 0; - - let mut out = VecDeque::with_capacity(limit); - out.push_back(&collected[anchor_index]); - - while out.len() < final_limit { - let mut progressed = 0; - - // Up. - if i >= 0 { - let iu = i as usize; - if effective_indents[iu] >= min_indent { - out.push_front(&collected[iu]); - progressed += 1; - i -= 1; - - // We do not include the siblings (not applied to comments). - if effective_indents[iu] == min_indent && !options.include_siblings { - let allow_header_comment = - options.include_header && collected[iu].is_comment(); - let can_take_line = allow_header_comment || i_counter_min_indent == 0; - - if can_take_line { - i_counter_min_indent += 1; - } else { - // This line shouldn't have been taken. - out.pop_front(); - progressed -= 1; - i = -1; // consider using Option or a control flag instead of a sentinel - } - } - - // Short-cut. - if out.len() >= final_limit { - break; - } - } else { - // Stop moving up. - i = -1; - } - } - - // Down. - if j < collected.len() { - let ju = j; - if effective_indents[ju] >= min_indent { - out.push_back(&collected[ju]); - progressed += 1; - j += 1; - - // We do not include the siblings (applied to comments). - if effective_indents[ju] == min_indent && !options.include_siblings { - if j_counter_min_indent > 0 { - // This line shouldn't have been taken. - out.pop_back(); - progressed -= 1; - j = collected.len(); - } - j_counter_min_indent += 1; - } - } else { - // Stop moving down. - j = collected.len(); - } - } - - if progressed == 0 { - break; - } - } - - // Trim empty lines - trim_empty_lines(&mut out); - - Ok(out - .into_iter() - .map(|record| format!("L{}: {}", record.number, record.display)) - .collect()) - } - - async fn collect_file_lines(path: &Path) -> Result, FunctionCallError> { - let file = File::open(path).await.map_err(|err| { - FunctionCallError::RespondToModel(format!("failed to read file: {err}")) - })?; - - let mut reader = BufReader::new(file); - let mut buffer = Vec::new(); - let mut lines = Vec::new(); - let mut number = 0usize; - - loop { - buffer.clear(); - let bytes_read = reader.read_until(b'\n', &mut buffer).await.map_err(|err| { - FunctionCallError::RespondToModel(format!("failed to read file: {err}")) - })?; - - if bytes_read == 0 { - break; - } - - if buffer.last() == Some(&b'\n') { - buffer.pop(); - if buffer.last() == Some(&b'\r') { - buffer.pop(); - } - } - - number += 1; - let raw = String::from_utf8_lossy(&buffer).into_owned(); - let indent = measure_indent(&raw); - let display = format_line(&buffer); - lines.push(LineRecord { - number, - raw, - display, - indent, - }); - } - - Ok(lines) - } - - fn compute_effective_indents(records: &[LineRecord]) -> Vec { - let mut effective = Vec::with_capacity(records.len()); - let mut previous_indent = 0usize; - for record in records { - if record.is_blank() { - effective.push(previous_indent); - } else { - previous_indent = record.indent; - effective.push(previous_indent); - } - } - effective - } - - fn measure_indent(line: &str) -> usize { - line.chars() - .take_while(|c| matches!(c, ' ' | '\t')) - .map(|c| if c == '\t' { TAB_WIDTH } else { 1 }) - .sum() - } -} - -fn format_line(bytes: &[u8]) -> String { - let decoded = String::from_utf8_lossy(bytes); - if decoded.len() > MAX_LINE_LENGTH { - take_bytes_at_char_boundary(&decoded, MAX_LINE_LENGTH).to_string() - } else { - decoded.into_owned() - } -} - -fn trim_empty_lines(out: &mut VecDeque<&LineRecord>) { - while matches!(out.front(), Some(line) if line.raw.trim().is_empty()) { - out.pop_front(); - } - while matches!(out.back(), Some(line) if line.raw.trim().is_empty()) { - out.pop_back(); - } -} - -mod defaults { - use super::*; - - impl Default for IndentationArgs { - fn default() -> Self { - Self { - anchor_line: None, - max_levels: max_levels(), - include_siblings: include_siblings(), - include_header: include_header(), - max_lines: None, - } - } - } - - pub fn offset() -> usize { - 1 - } - - pub fn limit() -> usize { - 2000 - } - - pub fn max_levels() -> usize { - 0 - } - - pub fn include_siblings() -> bool { - false - } - - pub fn include_header() -> bool { - true - } -} - -#[cfg(test)] -#[path = "read_file_tests.rs"] -mod tests; diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index d16b2567ac..120493dcbb 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -2046,111 +2046,6 @@ fn format_plugin_summary(plugin: &DiscoverablePluginInfo) -> String { } } -fn create_read_file_tool() -> ToolSpec { - let indentation_properties = BTreeMap::from([ - ( - "anchor_line".to_string(), - JsonSchema::Number { - description: Some( - "Anchor line to center the indentation lookup on (defaults to offset)." - .to_string(), - ), - }, - ), - ( - "max_levels".to_string(), - JsonSchema::Number { - description: Some( - "How many parent indentation levels (smaller indents) to include.".to_string(), - ), - }, - ), - ( - "include_siblings".to_string(), - JsonSchema::Boolean { - description: Some( - "When true, include additional blocks that share the anchor indentation." - .to_string(), - ), - }, - ), - ( - "include_header".to_string(), - JsonSchema::Boolean { - description: Some( - "Include doc comments or attributes directly above the selected block." - .to_string(), - ), - }, - ), - ( - "max_lines".to_string(), - JsonSchema::Number { - description: Some( - "Hard cap on the number of lines returned when using indentation mode." - .to_string(), - ), - }, - ), - ]); - - let properties = BTreeMap::from([ - ( - "file_path".to_string(), - JsonSchema::String { - description: Some("Absolute path to the file".to_string()), - }, - ), - ( - "offset".to_string(), - JsonSchema::Number { - description: Some( - "The line number to start reading from. Must be 1 or greater.".to_string(), - ), - }, - ), - ( - "limit".to_string(), - JsonSchema::Number { - description: Some("The maximum number of lines to return.".to_string()), - }, - ), - ( - "mode".to_string(), - JsonSchema::String { - description: Some( - "Optional mode selector: \"slice\" for simple ranges (default) or \"indentation\" \ - to expand around an anchor line." - .to_string(), - ), - }, - ), - ( - "indentation".to_string(), - JsonSchema::Object { - properties: indentation_properties, - required: None, - additional_properties: Some(false.into()), - }, - ), - ]); - - ToolSpec::Function(ResponsesApiTool { - name: "read_file".to_string(), - description: - "Reads a local file with 1-indexed line numbers, supporting slice and indentation-aware block modes." - .to_string(), - strict: false, - defer_loading: None, - parameters: JsonSchema::Object { - properties, - required: Some(vec!["file_path".to_string()]), - additional_properties: Some(false.into()), - }, - output_schema: None, - }) -} - fn create_list_dir_tool() -> ToolSpec { let properties = BTreeMap::from([ ( @@ -2704,7 +2599,6 @@ pub(crate) fn build_specs_with_discoverable_tools( use crate::tools::handlers::McpHandler; use crate::tools::handlers::McpResourceHandler; use crate::tools::handlers::PlanHandler; - use crate::tools::handlers::ReadFileHandler; use crate::tools::handlers::RequestPermissionsHandler; use crate::tools::handlers::RequestUserInputHandler; use crate::tools::handlers::ShellCommandHandler; @@ -2975,20 +2869,6 @@ pub(crate) fn build_specs_with_discoverable_tools( builder.register_handler("apply_patch", apply_patch_handler); } - if config - .experimental_supported_tools - .contains(&"read_file".to_string()) - { - let read_file_handler = Arc::new(ReadFileHandler); - push_tool_spec( - &mut builder, - create_read_file_tool(), - /*supports_parallel_tool_calls*/ true, - config.code_mode_enabled, - ); - builder.register_handler("read_file", read_file_handler); - } - if config .experimental_supported_tools .iter() From 303d0190c5f3818146c917e58ece725178e49ebf Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 25 Mar 2026 16:30:04 +0000 Subject: [PATCH 12/19] feat: add multi-thread log query (#15776) Required for multi-agent v2 --- codex-rs/state/src/runtime/logs.rs | 189 ++++++++++++++++++++++++++--- 1 file changed, 171 insertions(+), 18 deletions(-) diff --git a/codex-rs/state/src/runtime/logs.rs b/codex-rs/state/src/runtime/logs.rs index 6c6009e96b..b77678fc07 100644 --- a/codex-rs/state/src/runtime/logs.rs +++ b/codex-rs/state/src/runtime/logs.rs @@ -313,28 +313,46 @@ WHERE id IN ( Ok(rows) } - /// Query per-thread feedback logs, capped to the per-thread SQLite retention budget. - pub async fn query_feedback_logs(&self, thread_id: &str) -> anyhow::Result> { + /// Query feedback logs for a set of threads, capped to the SQLite retention budget. + pub async fn query_feedback_logs_for_threads( + &self, + thread_ids: &[&str], + ) -> anyhow::Result> { + if thread_ids.is_empty() { + return Ok(Vec::new()); + } + let max_bytes = usize::try_from(LOG_PARTITION_SIZE_LIMIT_BYTES).unwrap_or(usize::MAX); // Bound the fetched rows in SQL first so over-retained partitions do not have to load // every row into memory, then apply the exact whole-line byte cap after formatting. - let rows = sqlx::query_as::<_, FeedbackLogRow>( + let requested_threads = vec!["(?)"; thread_ids.len()].join(", "); + let query = format!( r#" -WITH latest_process AS ( - SELECT process_uuid - FROM logs - WHERE thread_id = ? AND process_uuid IS NOT NULL - ORDER BY ts DESC, ts_nanos DESC, id DESC - LIMIT 1 +WITH requested_threads(thread_id) AS ( + VALUES {requested_threads} +), +latest_processes AS ( + SELECT ( + SELECT process_uuid + FROM logs + WHERE logs.thread_id = requested_threads.thread_id AND process_uuid IS NOT NULL + ORDER BY ts DESC, ts_nanos DESC, id DESC + LIMIT 1 + ) AS process_uuid + FROM requested_threads ), feedback_logs AS ( SELECT ts, ts_nanos, level, feedback_log_body, estimated_bytes, id FROM logs WHERE feedback_log_body IS NOT NULL AND ( - thread_id = ? + thread_id IN (SELECT thread_id FROM requested_threads) OR ( thread_id IS NULL - AND process_uuid IN (SELECT process_uuid FROM latest_process) + AND process_uuid IN ( + SELECT process_uuid + FROM latest_processes + WHERE process_uuid IS NOT NULL + ) ) ) ), @@ -354,13 +372,16 @@ SELECT ts, ts_nanos, level, feedback_log_body FROM bounded_feedback_logs WHERE cumulative_estimated_bytes <= ? ORDER BY ts DESC, ts_nanos DESC, id DESC -"#, - ) - .bind(thread_id) - .bind(thread_id) - .bind(LOG_PARTITION_SIZE_LIMIT_BYTES) - .fetch_all(self.logs_pool.as_ref()) - .await?; +"# + ); + let mut sql = sqlx::query_as::<_, FeedbackLogRow>(query.as_str()); + for thread_id in thread_ids { + sql = sql.bind(thread_id); + } + let rows = sql + .bind(LOG_PARTITION_SIZE_LIMIT_BYTES) + .fetch_all(self.logs_pool.as_ref()) + .await?; let mut lines = Vec::new(); let mut total_bytes = 0usize; @@ -382,6 +403,11 @@ ORDER BY ts DESC, ts_nanos DESC, id DESC Ok(ordered_bytes) } + /// Query per-thread feedback logs, capped to the per-thread SQLite retention budget. + pub async fn query_feedback_logs(&self, thread_id: &str) -> anyhow::Result> { + self.query_feedback_logs_for_threads(&[thread_id]).await + } + /// Return the max log id matching optional filters. pub async fn max_log_id(&self, query: &LogQuery) -> anyhow::Result { let mut builder = @@ -1521,4 +1547,131 @@ mod tests { let _ = tokio::fs::remove_dir_all(codex_home).await; } + + #[tokio::test] + async fn query_feedback_logs_for_threads_merges_requested_threads_and_threadless_rows() { + let codex_home = unique_temp_dir(); + let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string()) + .await + .expect("initialize runtime"); + + runtime + .insert_logs(&[ + LogEntry { + ts: 1, + ts_nanos: 0, + level: "INFO".to_string(), + target: "cli".to_string(), + message: Some("thread-1".to_string()), + feedback_log_body: None, + thread_id: Some("thread-1".to_string()), + process_uuid: Some("proc-1".to_string()), + file: None, + line: None, + module_path: None, + }, + LogEntry { + ts: 2, + ts_nanos: 0, + level: "INFO".to_string(), + target: "cli".to_string(), + message: Some("thread-2".to_string()), + feedback_log_body: None, + thread_id: Some("thread-2".to_string()), + process_uuid: Some("proc-2".to_string()), + file: None, + line: None, + module_path: None, + }, + LogEntry { + ts: 3, + ts_nanos: 0, + level: "INFO".to_string(), + target: "cli".to_string(), + message: Some("threadless-proc-1".to_string()), + feedback_log_body: None, + thread_id: None, + process_uuid: Some("proc-1".to_string()), + file: None, + line: None, + module_path: None, + }, + LogEntry { + ts: 4, + ts_nanos: 0, + level: "INFO".to_string(), + target: "cli".to_string(), + message: Some("threadless-proc-2".to_string()), + feedback_log_body: None, + thread_id: None, + process_uuid: Some("proc-2".to_string()), + file: None, + line: None, + module_path: None, + }, + LogEntry { + ts: 5, + ts_nanos: 0, + level: "INFO".to_string(), + target: "cli".to_string(), + message: Some("thread-3".to_string()), + feedback_log_body: None, + thread_id: Some("thread-3".to_string()), + process_uuid: Some("proc-3".to_string()), + file: None, + line: None, + module_path: None, + }, + LogEntry { + ts: 6, + ts_nanos: 0, + level: "INFO".to_string(), + target: "cli".to_string(), + message: Some("threadless-proc-3".to_string()), + feedback_log_body: None, + thread_id: None, + process_uuid: Some("proc-3".to_string()), + file: None, + line: None, + module_path: None, + }, + ]) + .await + .expect("insert test logs"); + + let bytes = runtime + .query_feedback_logs_for_threads(&["thread-1", "thread-2"]) + .await + .expect("query feedback logs"); + + assert_eq!( + String::from_utf8(bytes).expect("valid utf-8"), + [ + format_feedback_log_line(1, 0, "INFO", "thread-1"), + format_feedback_log_line(2, 0, "INFO", "thread-2"), + format_feedback_log_line(3, 0, "INFO", "threadless-proc-1"), + format_feedback_log_line(4, 0, "INFO", "threadless-proc-2"), + ] + .concat() + ); + + let _ = tokio::fs::remove_dir_all(codex_home).await; + } + + #[tokio::test] + async fn query_feedback_logs_for_threads_returns_empty_for_empty_thread_list() { + let codex_home = unique_temp_dir(); + let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string()) + .await + .expect("initialize runtime"); + + let bytes = runtime + .query_feedback_logs_for_threads(&[]) + .await + .expect("query feedback logs"); + + assert_eq!(bytes, Vec::::new()); + + let _ = tokio::fs::remove_dir_all(codex_home).await; + } } From fba3c79885188b9b88bbceee9e9b872933393aee Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 25 Mar 2026 10:43:49 -0700 Subject: [PATCH 13/19] Extract codex-instructions crate (#15744) ## Summary - extract instruction fragment and user-instruction types into codex-instructions - update codex-core to consume the new crate ## Testing - CI --------- Co-authored-by: Codex --- codex-rs/Cargo.lock | 10 +++ codex-rs/Cargo.toml | 2 + codex-rs/core/Cargo.toml | 1 + codex-rs/core/src/contextual_user_message.rs | 61 +------------------ .../core/src/contextual_user_message_tests.rs | 1 + codex-rs/core/src/instructions/mod.rs | 8 +-- codex-rs/instructions/BUILD.bazel | 16 +++++ codex-rs/instructions/Cargo.toml | 20 ++++++ codex-rs/instructions/src/fragment.rs | 61 +++++++++++++++++++ codex-rs/instructions/src/lib.rs | 11 ++++ .../src}/user_instructions.rs | 15 +++-- .../src}/user_instructions_tests.rs | 4 ++ 12 files changed, 139 insertions(+), 71 deletions(-) create mode 100644 codex-rs/instructions/BUILD.bazel create mode 100644 codex-rs/instructions/Cargo.toml create mode 100644 codex-rs/instructions/src/fragment.rs create mode 100644 codex-rs/instructions/src/lib.rs rename codex-rs/{core/src/instructions => instructions/src}/user_instructions.rs (79%) rename codex-rs/{core/src/instructions => instructions/src}/user_instructions_tests.rs (94%) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 33244c1185..b57b6164bd 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1883,6 +1883,7 @@ dependencies = [ "codex-features", "codex-git-utils", "codex-hooks", + "codex-instructions", "codex-login", "codex-network-proxy", "codex-otel", @@ -2174,6 +2175,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "codex-instructions" +version = "0.0.0" +dependencies = [ + "codex-protocol", + "pretty_assertions", + "serde", +] + [[package]] name = "codex-keyring-store" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 3d2ee99482..126351131f 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -25,6 +25,7 @@ members = [ "skills", "core", "hooks", + "instructions", "secrets", "exec", "exec-server", @@ -123,6 +124,7 @@ codex-features = { path = "features" } codex-file-search = { path = "file-search" } codex-git-utils = { path = "git-utils" } codex-hooks = { path = "hooks" } +codex-instructions = { path = "instructions" } codex-keyring-store = { path = "keyring-store" } codex-linux-sandbox = { path = "linux-sandbox" } codex-lmstudio = { path = "lmstudio" } diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index a3868e4358..d335dc6cc5 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -42,6 +42,7 @@ codex-skills = { workspace = true } codex-execpolicy = { workspace = true } codex-git-utils = { workspace = true } codex-hooks = { workspace = true } +codex-instructions = { workspace = true } codex-network-proxy = { workspace = true } codex-otel = { workspace = true } codex-artifacts = { workspace = true } diff --git a/codex-rs/core/src/contextual_user_message.rs b/codex-rs/core/src/contextual_user_message.rs index 4df05f0da1..68a86cc885 100644 --- a/codex-rs/core/src/contextual_user_message.rs +++ b/codex-rs/core/src/contextual_user_message.rs @@ -1,14 +1,12 @@ +use codex_instructions::AGENTS_MD_FRAGMENT; +use codex_instructions::ContextualUserFragmentDefinition; +use codex_instructions::SKILL_FRAGMENT; use codex_protocol::items::HookPromptItem; use codex_protocol::items::parse_hook_prompt_fragment; use codex_protocol::models::ContentItem; -use codex_protocol::models::ResponseItem; use codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG; use codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG; -pub(crate) const AGENTS_MD_START_MARKER: &str = "# AGENTS.md instructions for "; -pub(crate) const AGENTS_MD_END_MARKER: &str = ""; -pub(crate) const SKILL_OPEN_TAG: &str = ""; -pub(crate) const SKILL_CLOSE_TAG: &str = ""; pub(crate) const USER_SHELL_COMMAND_OPEN_TAG: &str = ""; pub(crate) const USER_SHELL_COMMAND_CLOSE_TAG: &str = ""; pub(crate) const TURN_ABORTED_OPEN_TAG: &str = ""; @@ -16,64 +14,11 @@ pub(crate) const TURN_ABORTED_CLOSE_TAG: &str = ""; pub(crate) const SUBAGENT_NOTIFICATION_OPEN_TAG: &str = ""; pub(crate) const SUBAGENT_NOTIFICATION_CLOSE_TAG: &str = ""; -#[derive(Clone, Copy)] -pub(crate) struct ContextualUserFragmentDefinition { - start_marker: &'static str, - end_marker: &'static str, -} - -impl ContextualUserFragmentDefinition { - pub(crate) const fn new(start_marker: &'static str, end_marker: &'static str) -> Self { - Self { - start_marker, - end_marker, - } - } - - pub(crate) fn matches_text(&self, text: &str) -> bool { - let trimmed = text.trim_start(); - let starts_with_marker = trimmed - .get(..self.start_marker.len()) - .is_some_and(|candidate| candidate.eq_ignore_ascii_case(self.start_marker)); - let trimmed = trimmed.trim_end(); - let ends_with_marker = trimmed - .get(trimmed.len().saturating_sub(self.end_marker.len())..) - .is_some_and(|candidate| candidate.eq_ignore_ascii_case(self.end_marker)); - starts_with_marker && ends_with_marker - } - - pub(crate) const fn start_marker(&self) -> &'static str { - self.start_marker - } - - pub(crate) const fn end_marker(&self) -> &'static str { - self.end_marker - } - - pub(crate) fn wrap(&self, body: String) -> String { - format!("{}\n{}\n{}", self.start_marker, body, self.end_marker) - } - - pub(crate) fn into_message(self, text: String) -> ResponseItem { - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { text }], - end_turn: None, - phase: None, - } - } -} - -pub(crate) const AGENTS_MD_FRAGMENT: ContextualUserFragmentDefinition = - ContextualUserFragmentDefinition::new(AGENTS_MD_START_MARKER, AGENTS_MD_END_MARKER); pub(crate) const ENVIRONMENT_CONTEXT_FRAGMENT: ContextualUserFragmentDefinition = ContextualUserFragmentDefinition::new( ENVIRONMENT_CONTEXT_OPEN_TAG, ENVIRONMENT_CONTEXT_CLOSE_TAG, ); -pub(crate) const SKILL_FRAGMENT: ContextualUserFragmentDefinition = - ContextualUserFragmentDefinition::new(SKILL_OPEN_TAG, SKILL_CLOSE_TAG); pub(crate) const USER_SHELL_COMMAND_FRAGMENT: ContextualUserFragmentDefinition = ContextualUserFragmentDefinition::new( USER_SHELL_COMMAND_OPEN_TAG, diff --git a/codex-rs/core/src/contextual_user_message_tests.rs b/codex-rs/core/src/contextual_user_message_tests.rs index f71ca35f6e..93db860ea9 100644 --- a/codex-rs/core/src/contextual_user_message_tests.rs +++ b/codex-rs/core/src/contextual_user_message_tests.rs @@ -1,6 +1,7 @@ use super::*; use codex_protocol::items::HookPromptFragment; use codex_protocol::items::build_hook_prompt_message; +use codex_protocol::models::ResponseItem; #[test] fn detects_environment_context_fragment() { diff --git a/codex-rs/core/src/instructions/mod.rs b/codex-rs/core/src/instructions/mod.rs index 9f1d95d2f6..0ef48ff620 100644 --- a/codex-rs/core/src/instructions/mod.rs +++ b/codex-rs/core/src/instructions/mod.rs @@ -1,5 +1,3 @@ -mod user_instructions; - -pub(crate) use user_instructions::SkillInstructions; -pub use user_instructions::USER_INSTRUCTIONS_PREFIX; -pub(crate) use user_instructions::UserInstructions; +pub(crate) use codex_instructions::SkillInstructions; +pub use codex_instructions::USER_INSTRUCTIONS_PREFIX; +pub(crate) use codex_instructions::UserInstructions; diff --git a/codex-rs/instructions/BUILD.bazel b/codex-rs/instructions/BUILD.bazel new file mode 100644 index 0000000000..d3cdbd19d1 --- /dev/null +++ b/codex-rs/instructions/BUILD.bazel @@ -0,0 +1,16 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "instructions", + crate_name = "codex_instructions", + compile_data = glob( + include = ["**"], + exclude = [ + "BUILD.bazel", + "Cargo.toml", + ], + allow_empty = True, + ) + [ + "//codex-rs:node-version.txt", + ], +) diff --git a/codex-rs/instructions/Cargo.toml b/codex-rs/instructions/Cargo.toml new file mode 100644 index 0000000000..cdaa3d7368 --- /dev/null +++ b/codex-rs/instructions/Cargo.toml @@ -0,0 +1,20 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-instructions" +version.workspace = true + +[lib] +doctest = false +name = "codex_instructions" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +codex-protocol = { workspace = true } +serde = { workspace = true, features = ["derive"] } + +[dev-dependencies] +pretty_assertions = { workspace = true } diff --git a/codex-rs/instructions/src/fragment.rs b/codex-rs/instructions/src/fragment.rs new file mode 100644 index 0000000000..590dac379b --- /dev/null +++ b/codex-rs/instructions/src/fragment.rs @@ -0,0 +1,61 @@ +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; + +pub(crate) const AGENTS_MD_START_MARKER: &str = "# AGENTS.md instructions for "; +pub(crate) const AGENTS_MD_END_MARKER: &str = ""; +pub(crate) const SKILL_OPEN_TAG: &str = ""; +pub(crate) const SKILL_CLOSE_TAG: &str = ""; + +#[derive(Clone, Copy)] +pub struct ContextualUserFragmentDefinition { + start_marker: &'static str, + end_marker: &'static str, +} + +impl ContextualUserFragmentDefinition { + pub const fn new(start_marker: &'static str, end_marker: &'static str) -> Self { + Self { + start_marker, + end_marker, + } + } + + pub fn matches_text(&self, text: &str) -> bool { + let trimmed = text.trim_start(); + let starts_with_marker = trimmed + .get(..self.start_marker.len()) + .is_some_and(|candidate| candidate.eq_ignore_ascii_case(self.start_marker)); + let trimmed = trimmed.trim_end(); + let ends_with_marker = trimmed + .get(trimmed.len().saturating_sub(self.end_marker.len())..) + .is_some_and(|candidate| candidate.eq_ignore_ascii_case(self.end_marker)); + starts_with_marker && ends_with_marker + } + + pub const fn start_marker(&self) -> &'static str { + self.start_marker + } + + pub const fn end_marker(&self) -> &'static str { + self.end_marker + } + + pub fn wrap(&self, body: String) -> String { + format!("{}\n{}\n{}", self.start_marker, body, self.end_marker) + } + + pub fn into_message(self, text: String) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { text }], + end_turn: None, + phase: None, + } + } +} + +pub const AGENTS_MD_FRAGMENT: ContextualUserFragmentDefinition = + ContextualUserFragmentDefinition::new(AGENTS_MD_START_MARKER, AGENTS_MD_END_MARKER); +pub const SKILL_FRAGMENT: ContextualUserFragmentDefinition = + ContextualUserFragmentDefinition::new(SKILL_OPEN_TAG, SKILL_CLOSE_TAG); diff --git a/codex-rs/instructions/src/lib.rs b/codex-rs/instructions/src/lib.rs new file mode 100644 index 0000000000..1b1f81550c --- /dev/null +++ b/codex-rs/instructions/src/lib.rs @@ -0,0 +1,11 @@ +//! User and skill instruction payloads and contextual user fragment markers for Codex prompts. + +mod fragment; +mod user_instructions; + +pub use fragment::AGENTS_MD_FRAGMENT; +pub use fragment::ContextualUserFragmentDefinition; +pub use fragment::SKILL_FRAGMENT; +pub use user_instructions::SkillInstructions; +pub use user_instructions::USER_INSTRUCTIONS_PREFIX; +pub use user_instructions::UserInstructions; diff --git a/codex-rs/core/src/instructions/user_instructions.rs b/codex-rs/instructions/src/user_instructions.rs similarity index 79% rename from codex-rs/core/src/instructions/user_instructions.rs rename to codex-rs/instructions/src/user_instructions.rs index a0389c9ff8..4fd266e766 100644 --- a/codex-rs/core/src/instructions/user_instructions.rs +++ b/codex-rs/instructions/src/user_instructions.rs @@ -3,20 +3,21 @@ use serde::Serialize; use codex_protocol::models::ResponseItem; -use crate::contextual_user_message::AGENTS_MD_FRAGMENT; -use crate::contextual_user_message::SKILL_FRAGMENT; +use crate::fragment::AGENTS_MD_FRAGMENT; +use crate::fragment::AGENTS_MD_START_MARKER; +use crate::fragment::SKILL_FRAGMENT; -pub const USER_INSTRUCTIONS_PREFIX: &str = "# AGENTS.md instructions for "; +pub const USER_INSTRUCTIONS_PREFIX: &str = AGENTS_MD_START_MARKER; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename = "user_instructions", rename_all = "snake_case")] -pub(crate) struct UserInstructions { +pub struct UserInstructions { pub directory: String, pub text: String, } impl UserInstructions { - pub(crate) fn serialize_to_text(&self) -> String { + pub fn serialize_to_text(&self) -> String { format!( "{prefix}{directory}\n\n\n{contents}\n{suffix}", prefix = AGENTS_MD_FRAGMENT.start_marker(), @@ -35,14 +36,12 @@ impl From for ResponseItem { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename = "skill_instructions", rename_all = "snake_case")] -pub(crate) struct SkillInstructions { +pub struct SkillInstructions { pub name: String, pub path: String, pub contents: String, } -impl SkillInstructions {} - impl From for ResponseItem { fn from(si: SkillInstructions) -> Self { SKILL_FRAGMENT.into_message(SKILL_FRAGMENT.wrap(format!( diff --git a/codex-rs/core/src/instructions/user_instructions_tests.rs b/codex-rs/instructions/src/user_instructions_tests.rs similarity index 94% rename from codex-rs/core/src/instructions/user_instructions_tests.rs rename to codex-rs/instructions/src/user_instructions_tests.rs index 58442600a8..75f35d11b0 100644 --- a/codex-rs/core/src/instructions/user_instructions_tests.rs +++ b/codex-rs/instructions/src/user_instructions_tests.rs @@ -1,7 +1,11 @@ use super::*; use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; use pretty_assertions::assert_eq; +use crate::fragment::AGENTS_MD_FRAGMENT; +use crate::fragment::SKILL_FRAGMENT; + #[test] fn test_user_instructions() { let user_instructions = UserInstructions { From 6b10e186c4d1b544745d241416c9d94bb83a9fef Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Wed, 25 Mar 2026 11:05:07 -0700 Subject: [PATCH 14/19] Add non-interactive resume filter option (#15339) ## Summary - add `codex resume --include-non-interactive` to include non-interactive sessions in the picker and `--last` - keep current-provider and cwd filtering behavior unchanged - replace the picker API boolean with a `SessionSourceFilter` enum to avoid a boolean trap ## Tests - `cargo test -p codex-cli` - `cargo test -p codex-tui` - `just fmt` - `just fix -p codex-cli` - `just fix -p codex-tui` --- codex-rs/cli/src/main.rs | 19 ++++++++++++++ codex-rs/tui/src/app.rs | 1 + codex-rs/tui/src/cli.rs | 4 +++ codex-rs/tui/src/lib.rs | 21 +++++++++++++-- codex-rs/tui/src/resume_picker.rs | 43 +++++++++++++++++++++++++++---- 5 files changed, 81 insertions(+), 7 deletions(-) diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 8446e457b2..82ede69320 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -208,6 +208,10 @@ struct ResumeCommand { #[arg(long = "all", default_value_t = false)] all: bool, + /// Include non-interactive sessions in the resume picker and --last selection. + #[arg(long = "include-non-interactive", default_value_t = false)] + include_non_interactive: bool, + #[clap(flatten)] remote: InteractiveRemoteOptions, @@ -691,6 +695,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { session_id, last, all, + include_non_interactive, remote, config_overrides, })) => { @@ -700,6 +705,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { session_id, last, all, + include_non_interactive, config_overrides, ); let exit_info = run_interactive_tui( @@ -1169,6 +1175,7 @@ fn finalize_resume_interactive( session_id: Option, last: bool, show_all: bool, + include_non_interactive: bool, resume_cli: TuiCli, ) -> TuiCli { // Start with the parsed interactive CLI so resume shares the same @@ -1178,6 +1185,7 @@ fn finalize_resume_interactive( interactive.resume_last = last; interactive.resume_session_id = resume_session_id; interactive.resume_show_all = show_all; + interactive.resume_include_non_interactive = include_non_interactive; // Merge resume-scoped flags and overrides with highest precedence. merge_interactive_cli_flags(&mut interactive, resume_cli); @@ -1290,6 +1298,7 @@ mod tests { session_id, last, all, + include_non_interactive, remote: _, config_overrides: resume_cli, }) = subcommand.expect("resume present") @@ -1303,6 +1312,7 @@ mod tests { session_id, last, all, + include_non_interactive, resume_cli, ) } @@ -1498,6 +1508,15 @@ mod tests { assert!(interactive.resume_show_all); } + #[test] + fn resume_include_non_interactive_flag_sets_source_filter_override() { + let interactive = + finalize_resume_from_args(["codex", "resume", "--include-non-interactive"].as_ref()); + + assert!(interactive.resume_picker); + assert!(interactive.resume_include_non_interactive); + } + #[test] fn resume_merges_option_flags_and_full_auto() { let interactive = finalize_resume_from_args( diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 43ccc78657..a606520277 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2819,6 +2819,7 @@ impl App { tui, &self.config, /*show_all*/ false, + crate::resume_picker::SessionSourceFilter::InteractiveOnly, ) .await? { diff --git a/codex-rs/tui/src/cli.rs b/codex-rs/tui/src/cli.rs index 86bea97abe..e3087af2ae 100644 --- a/codex-rs/tui/src/cli.rs +++ b/codex-rs/tui/src/cli.rs @@ -32,6 +32,10 @@ pub struct Cli { #[clap(skip)] pub resume_show_all: bool, + /// Internal: include non-interactive sessions in resume listings. + #[clap(skip)] + pub resume_include_non_interactive: bool, + // Internal controls set by the top-level `codex fork` subcommand. // These are not exposed as user flags on the base `codex` command. #[clap(skip)] diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 2fcf2e3dc3..43182b5240 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -845,12 +845,17 @@ async fn run_ratatui_app( } else { Some(config.cwd.as_path()) }; + let allowed_sources = if cli.resume_include_non_interactive { + &[][..] + } else { + INTERACTIVE_SESSION_SOURCES.as_slice() + }; match RolloutRecorder::find_latest_thread_path( &config, /*page_size*/ 1, /*cursor*/ None, ThreadSortKey::UpdatedAt, - INTERACTIVE_SESSION_SOURCES.as_slice(), + allowed_sources, Some(provider_filter.as_slice()), &config.model_provider_id, filter_cwd, @@ -888,7 +893,19 @@ async fn run_ratatui_app( _ => resume_picker::SessionSelection::StartFresh, } } else if cli.resume_picker { - match resume_picker::run_resume_picker(&mut tui, &config, cli.resume_show_all).await? { + let source_filter = if cli.resume_include_non_interactive { + resume_picker::SessionSourceFilter::IncludeNonInteractive + } else { + resume_picker::SessionSourceFilter::InteractiveOnly + }; + match resume_picker::run_resume_picker( + &mut tui, + &config, + cli.resume_show_all, + source_filter, + ) + .await? + { resume_picker::SessionSelection::Exit => { terminal_restore_guard.restore_silently(); session_log::log_session_end(); diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 1a74fcd83a..1ac3492ebf 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -60,6 +60,21 @@ pub enum SessionPickerAction { Fork, } +#[derive(Clone, Copy, Debug)] +pub enum SessionSourceFilter { + InteractiveOnly, + IncludeNonInteractive, +} + +impl SessionSourceFilter { + fn allowed_sources(self) -> &'static [codex_protocol::protocol::SessionSource] { + match self { + SessionSourceFilter::InteractiveOnly => INTERACTIVE_SESSION_SOURCES.as_slice(), + SessionSourceFilter::IncludeNonInteractive => &[], + } + } +} + impl SessionPickerAction { fn title(self) -> &'static str { match self { @@ -116,15 +131,24 @@ enum BackgroundEvent { /// new sessions appear during pagination. /// /// Filtering happens in two layers: -/// 1. Provider and source filtering at the backend (only interactive CLI sessions -/// for the current model provider). +/// 1. Provider and source filtering at the backend (interactive sessions for the +/// current model provider by default, optionally including non-interactive +/// sessions). /// 2. Working-directory filtering at the picker (unless `--all` is passed). pub async fn run_resume_picker( tui: &mut Tui, config: &Config, show_all: bool, + source_filter: SessionSourceFilter, ) -> Result { - run_session_picker(tui, config, show_all, SessionPickerAction::Resume).await + run_session_picker( + tui, + config, + show_all, + source_filter, + SessionPickerAction::Resume, + ) + .await } pub async fn run_fork_picker( @@ -132,13 +156,21 @@ pub async fn run_fork_picker( config: &Config, show_all: bool, ) -> Result { - run_session_picker(tui, config, show_all, SessionPickerAction::Fork).await + run_session_picker( + tui, + config, + show_all, + SessionSourceFilter::InteractiveOnly, + SessionPickerAction::Fork, + ) + .await } async fn run_session_picker( tui: &mut Tui, config: &Config, show_all: bool, + source_filter: SessionSourceFilter, action: SessionPickerAction, ) -> Result { let alt = AltScreenGuard::enter(tui); @@ -154,6 +186,7 @@ async fn run_session_picker( let config = config.clone(); let loader_tx = bg_tx.clone(); + let allowed_sources = source_filter.allowed_sources(); let page_loader: PageLoader = Arc::new(move |request: PageLoadRequest| { let tx = loader_tx.clone(); let config = config.clone(); @@ -164,7 +197,7 @@ async fn run_session_picker( PAGE_SIZE, request.cursor.as_ref(), request.sort_key, - INTERACTIVE_SESSION_SOURCES.as_slice(), + allowed_sources, Some(provider_filter.as_slice()), request.default_provider.as_str(), /*search_term*/ None, From ad74543a6f6e5590ae8839142259df571405da28 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 25 Mar 2026 11:05:35 -0700 Subject: [PATCH 15/19] Extract codex-utils-plugins crate (#15746) ## Summary - extract shared plugin path and manifest helpers into codex-utils-plugins - update codex-core to consume the utility crate ## Testing - CI --------- Co-authored-by: Codex --- codex-rs/Cargo.lock | 10 +++ codex-rs/Cargo.toml | 2 + codex-rs/core/Cargo.toml | 1 + codex-rs/core/src/mention_syntax.rs | 6 +- codex-rs/core/src/plugins/manifest.rs | 3 +- codex-rs/utils/plugins/BUILD.bazel | 6 ++ codex-rs/utils/plugins/Cargo.toml | 21 ++++++ codex-rs/utils/plugins/src/lib.rs | 7 ++ codex-rs/utils/plugins/src/mention_syntax.rs | 7 ++ .../utils/plugins/src/plugin_namespace.rs | 70 +++++++++++++++++++ 10 files changed, 127 insertions(+), 6 deletions(-) create mode 100644 codex-rs/utils/plugins/BUILD.bazel create mode 100644 codex-rs/utils/plugins/Cargo.toml create mode 100644 codex-rs/utils/plugins/src/lib.rs create mode 100644 codex-rs/utils/plugins/src/mention_syntax.rs create mode 100644 codex-rs/utils/plugins/src/plugin_namespace.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index b57b6164bd..d9f7512fcc 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1905,6 +1905,7 @@ dependencies = [ "codex-utils-image", "codex-utils-output-truncation", "codex-utils-path", + "codex-utils-plugins", "codex-utils-pty", "codex-utils-readiness", "codex-utils-stream-parser", @@ -2947,6 +2948,15 @@ dependencies = [ "tempfile", ] +[[package]] +name = "codex-utils-plugins" +version = "0.0.0" +dependencies = [ + "serde", + "serde_json", + "tempfile", +] + [[package]] name = "codex-utils-pty" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 126351131f..571e037902 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -69,6 +69,7 @@ members = [ "utils/oss", "utils/output-truncation", "utils/path-utils", + "utils/plugins", "utils/fuzzy-match", "utils/stream-parser", "utils/template", @@ -163,6 +164,7 @@ codex-utils-json-to-toml = { path = "utils/json-to-toml" } codex-utils-oss = { path = "utils/oss" } codex-utils-output-truncation = { path = "utils/output-truncation" } codex-utils-path = { path = "utils/path-utils" } +codex-utils-plugins = { path = "utils/plugins" } codex-utils-pty = { path = "utils/pty" } codex-utils-readiness = { path = "utils/readiness" } codex-utils-rustls-provider = { path = "utils/rustls-provider" } diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index d335dc6cc5..ebff813abf 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -58,6 +58,7 @@ codex-utils-image = { workspace = true } codex-utils-home-dir = { workspace = true } codex-utils-output-truncation = { workspace = true } codex-utils-path = { workspace = true } +codex-utils-plugins = { workspace = true } codex-utils-pty = { workspace = true } codex-utils-readiness = { workspace = true } codex-secrets = { workspace = true } diff --git a/codex-rs/core/src/mention_syntax.rs b/codex-rs/core/src/mention_syntax.rs index e58f419b54..690cd1a2f5 100644 --- a/codex-rs/core/src/mention_syntax.rs +++ b/codex-rs/core/src/mention_syntax.rs @@ -1,4 +1,2 @@ -// Default plaintext sigil for tools. -pub const TOOL_MENTION_SIGIL: char = '$'; -// Plugins use `@` in linked plaintext outside TUI. -pub const PLUGIN_TEXT_MENTION_SIGIL: char = '@'; +pub use codex_utils_plugins::mention_syntax::PLUGIN_TEXT_MENTION_SIGIL; +pub use codex_utils_plugins::mention_syntax::TOOL_MENTION_SIGIL; diff --git a/codex-rs/core/src/plugins/manifest.rs b/codex-rs/core/src/plugins/manifest.rs index 91c7cbbb30..d1b2c648e0 100644 --- a/codex-rs/core/src/plugins/manifest.rs +++ b/codex-rs/core/src/plugins/manifest.rs @@ -1,11 +1,10 @@ use codex_utils_absolute_path::AbsolutePathBuf; +pub(crate) use codex_utils_plugins::PLUGIN_MANIFEST_PATH; use serde::Deserialize; use serde_json::Value as JsonValue; use std::fs; use std::path::Component; use std::path::Path; - -pub(crate) const PLUGIN_MANIFEST_PATH: &str = ".codex-plugin/plugin.json"; const MAX_DEFAULT_PROMPT_COUNT: usize = 3; const MAX_DEFAULT_PROMPT_LEN: usize = 128; diff --git a/codex-rs/utils/plugins/BUILD.bazel b/codex-rs/utils/plugins/BUILD.bazel new file mode 100644 index 0000000000..273e98fc4b --- /dev/null +++ b/codex-rs/utils/plugins/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "plugins", + crate_name = "codex_utils_plugins", +) diff --git a/codex-rs/utils/plugins/Cargo.toml b/codex-rs/utils/plugins/Cargo.toml new file mode 100644 index 0000000000..4e1826c256 --- /dev/null +++ b/codex-rs/utils/plugins/Cargo.toml @@ -0,0 +1,21 @@ + +[package] +edition.workspace = true +license.workspace = true +name = "codex-utils-plugins" +version.workspace = true + +[lib] +doctest = false +name = "codex_utils_plugins" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/codex-rs/utils/plugins/src/lib.rs b/codex-rs/utils/plugins/src/lib.rs new file mode 100644 index 0000000000..5e990eb3cb --- /dev/null +++ b/codex-rs/utils/plugins/src/lib.rs @@ -0,0 +1,7 @@ +//! Plugin path resolution and plaintext mention sigils shared across Codex crates. + +pub mod mention_syntax; +pub mod plugin_namespace; + +pub use plugin_namespace::PLUGIN_MANIFEST_PATH; +pub use plugin_namespace::plugin_namespace_for_skill_path; diff --git a/codex-rs/utils/plugins/src/mention_syntax.rs b/codex-rs/utils/plugins/src/mention_syntax.rs new file mode 100644 index 0000000000..de5d1432b9 --- /dev/null +++ b/codex-rs/utils/plugins/src/mention_syntax.rs @@ -0,0 +1,7 @@ +//! Sigils for tool/plugin mentions in plaintext (shared across Codex crates). + +/// Default plaintext sigil for tools. +pub const TOOL_MENTION_SIGIL: char = '$'; + +/// Plugins use `@` in linked plaintext outside TUI. +pub const PLUGIN_TEXT_MENTION_SIGIL: char = '@'; diff --git a/codex-rs/utils/plugins/src/plugin_namespace.rs b/codex-rs/utils/plugins/src/plugin_namespace.rs new file mode 100644 index 0000000000..6688ae0469 --- /dev/null +++ b/codex-rs/utils/plugins/src/plugin_namespace.rs @@ -0,0 +1,70 @@ +//! Resolve plugin namespace from skill file paths by walking ancestors for `plugin.json`. + +use std::fs; +use std::path::Path; + +/// Relative path from a plugin root to its manifest file. +pub const PLUGIN_MANIFEST_PATH: &str = ".codex-plugin/plugin.json"; + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawPluginManifestName { + #[serde(default)] + name: String, +} + +fn plugin_manifest_name(plugin_root: &Path) -> Option { + let manifest_path = plugin_root.join(PLUGIN_MANIFEST_PATH); + if !manifest_path.is_file() { + return None; + } + let contents = fs::read_to_string(&manifest_path).ok()?; + let RawPluginManifestName { name: raw_name } = serde_json::from_str(&contents).ok()?; + Some( + plugin_root + .file_name() + .and_then(|entry| entry.to_str()) + .filter(|_| raw_name.trim().is_empty()) + .unwrap_or(raw_name.as_str()) + .to_string(), + ) +} + +/// Returns the plugin manifest `name` for the nearest ancestor of `path` that contains a valid +/// plugin manifest (same `name` rules as full manifest loading in codex-core). +pub fn plugin_namespace_for_skill_path(path: &Path) -> Option { + for ancestor in path.ancestors() { + if let Some(name) = plugin_manifest_name(ancestor) { + return Some(name); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::plugin_namespace_for_skill_path; + use std::fs; + use tempfile::tempdir; + + #[test] + fn uses_manifest_name() { + let tmp = tempdir().expect("tempdir"); + let plugin_root = tmp.path().join("plugins/sample"); + let skill_path = plugin_root.join("skills/search/SKILL.md"); + + fs::create_dir_all(skill_path.parent().expect("parent")).expect("mkdir"); + fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("mkdir manifest"); + fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + ) + .expect("write manifest"); + fs::write(&skill_path, "---\ndescription: search\n---\n").expect("write skill"); + + assert_eq!( + plugin_namespace_for_skill_path(&skill_path), + Some("sample".to_string()) + ); + } +} From 2bb1027e37fd2ff7b5a72de8b5f5d8ce8cca2cba Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 25 Mar 2026 11:07:31 -0700 Subject: [PATCH 16/19] Extract codex-plugin crate (#15747) ## Summary - extract plugin identifiers and load-outcome types into codex-plugin - update codex-core to consume the new plugin crate ## Testing - CI --------- Co-authored-by: Codex --- codex-rs/Cargo.lock | 12 ++ codex-rs/Cargo.toml | 2 + codex-rs/core/Cargo.toml | 1 + codex-rs/core/src/plugins/manager.rs | 216 +++------------------ codex-rs/core/src/plugins/manager_tests.rs | 54 ++---- codex-rs/core/src/plugins/marketplace.rs | 4 +- codex-rs/core/src/plugins/mod.rs | 20 +- codex-rs/core/src/plugins/store.rs | 66 +------ codex-rs/core/src/plugins/store_tests.rs | 1 + codex-rs/plugin/BUILD.bazel | 15 ++ codex-rs/plugin/Cargo.toml | 18 ++ codex-rs/plugin/src/lib.rs | 55 ++++++ codex-rs/plugin/src/load_outcome.rs | 163 ++++++++++++++++ codex-rs/plugin/src/plugin_id.rs | 64 ++++++ 14 files changed, 394 insertions(+), 297 deletions(-) create mode 100644 codex-rs/plugin/BUILD.bazel create mode 100644 codex-rs/plugin/Cargo.toml create mode 100644 codex-rs/plugin/src/lib.rs create mode 100644 codex-rs/plugin/src/load_outcome.rs create mode 100644 codex-rs/plugin/src/plugin_id.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index d9f7512fcc..b9760de416 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1887,6 +1887,7 @@ dependencies = [ "codex-login", "codex-network-proxy", "codex-otel", + "codex-plugin", "codex-protocol", "codex-rmcp-client", "codex-rollout", @@ -2394,6 +2395,17 @@ dependencies = [ "zip", ] +[[package]] +name = "codex-plugin" +version = "0.0.0" +dependencies = [ + "codex-utils-absolute-path", + "codex-utils-plugins", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "codex-process-hardening" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 571e037902..4b23f48658 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -80,6 +80,7 @@ members = [ "codex-experimental-api-macros", "test-macros", "package-manager", + "plugin", "artifacts", ] resolver = "2" @@ -134,6 +135,7 @@ codex-mcp-server = { path = "mcp-server" } codex-network-proxy = { path = "network-proxy" } codex-ollama = { path = "ollama" } codex-otel = { path = "otel" } +codex-plugin = { path = "plugin" } codex-process-hardening = { path = "process-hardening" } codex-protocol = { path = "protocol" } codex-rollout = { path = "rollout" } diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index ebff813abf..9e01e6662f 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -46,6 +46,7 @@ codex-instructions = { workspace = true } codex-network-proxy = { workspace = true } codex-otel = { workspace = true } codex-artifacts = { workspace = true } +codex-plugin = { workspace = true } codex-protocol = { workspace = true } codex-rollout = { workspace = true } codex-rmcp-client = { workspace = true } diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index d2d2e1b1c2..261cdf88dc 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -1,3 +1,5 @@ +use super::LoadedPlugin; +use super::PluginLoadOutcome; use super::PluginManifestPaths; use super::curated_plugins_repo_path; use super::load_plugin_manifest; @@ -20,14 +22,11 @@ use super::remote::fetch_remote_featured_plugin_ids; use super::remote::fetch_remote_plugin_status; use super::remote::uninstall_remote_plugin; use super::startup_sync::start_startup_remote_plugin_sync_once; -use super::store::PluginId; -use super::store::PluginIdError; use super::store::PluginInstallResult as StorePluginInstallResult; use super::store::PluginStore; use super::store::PluginStoreError; use super::sync_openai_plugins_repo; use crate::AuthManager; -use crate::analytics_client::AnalyticsEventsClient; use crate::auth::CodexAuth; use crate::config::Config; use crate::config::ConfigService; @@ -46,6 +45,12 @@ use crate::skills::loader::load_skills_from_roots; use codex_app_server_protocol::ConfigValueWriteParams; use codex_app_server_protocol::MergeStrategy; use codex_features::Feature; +use codex_plugin::AppConnectorId; +use codex_plugin::PluginCapabilitySummary; +use codex_plugin::PluginId; +use codex_plugin::PluginIdError; +use codex_plugin::PluginTelemetryMetadata; +use codex_plugin::prompt_safe_plugin_description; use codex_protocol::protocol::Product; use codex_protocol::protocol::SkillScope; use codex_utils_absolute_path::AbsolutePathBuf; @@ -68,12 +73,13 @@ use toml_edit::value; use tracing::info; use tracing::warn; +use crate::AnalyticsEventsClient; + const DEFAULT_SKILLS_DIR_NAME: &str = "skills"; const DEFAULT_MCP_CONFIG_FILE: &str = ".mcp.json"; const DEFAULT_APP_CONFIG_FILE: &str = ".app.json"; pub const OPENAI_CURATED_MARKETPLACE_NAME: &str = "openai-curated"; static CURATED_REPO_SYNC_STARTED: AtomicBool = AtomicBool::new(false); -const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024; const FEATURED_PLUGIN_IDS_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(60 * 60 * 3); @@ -114,9 +120,6 @@ fn featured_plugin_ids_cache_key( } } -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct AppConnectorId(pub String); - #[derive(Debug, Clone, PartialEq, Eq)] pub struct PluginInstallRequest { pub plugin_name: String, @@ -185,89 +188,6 @@ pub struct ConfiguredMarketplaceListOutcome { pub errors: Vec, } -#[derive(Debug, Clone, PartialEq)] -pub struct LoadedPlugin { - pub config_name: String, - pub manifest_name: Option, - pub manifest_description: Option, - pub root: AbsolutePathBuf, - pub enabled: bool, - pub skill_roots: Vec, - pub disabled_skill_paths: HashSet, - pub has_enabled_skills: bool, - pub mcp_servers: HashMap, - pub apps: Vec, - pub error: Option, -} - -impl LoadedPlugin { - fn is_active(&self) -> bool { - self.enabled && self.error.is_none() - } -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct PluginCapabilitySummary { - pub config_name: String, - pub display_name: String, - pub description: Option, - pub has_skills: bool, - pub mcp_server_names: Vec, - pub app_connector_ids: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PluginTelemetryMetadata { - pub plugin_id: PluginId, - pub capability_summary: Option, -} - -impl PluginTelemetryMetadata { - pub fn from_plugin_id(plugin_id: &PluginId) -> Self { - Self { - plugin_id: plugin_id.clone(), - capability_summary: None, - } - } -} - -impl PluginCapabilitySummary { - fn from_plugin(plugin: &LoadedPlugin) -> Option { - if !plugin.is_active() { - return None; - } - - let mut mcp_server_names: Vec = plugin.mcp_servers.keys().cloned().collect(); - mcp_server_names.sort_unstable(); - - let summary = Self { - config_name: plugin.config_name.clone(), - display_name: plugin - .manifest_name - .clone() - .unwrap_or_else(|| plugin.config_name.clone()), - description: prompt_safe_plugin_description(plugin.manifest_description.as_deref()), - has_skills: plugin.has_enabled_skills, - mcp_server_names, - app_connector_ids: plugin.apps.clone(), - }; - - (summary.has_skills - || !summary.mcp_server_names.is_empty() - || !summary.app_connector_ids.is_empty()) - .then_some(summary) - } - - pub fn telemetry_metadata(&self) -> Option { - PluginId::parse(&self.config_name) - .ok() - .map(|plugin_id| PluginTelemetryMetadata { - plugin_id, - capability_summary: Some(self.clone()), - }) - } -} - impl From for PluginCapabilitySummary { fn from(value: PluginDetail) -> Self { let has_skills = value.skills.iter().any(|skill| { @@ -286,95 +206,6 @@ impl From for PluginCapabilitySummary { } } -fn prompt_safe_plugin_description(description: Option<&str>) -> Option { - let description = description? - .split_whitespace() - .collect::>() - .join(" "); - if description.is_empty() { - return None; - } - - Some( - description - .chars() - .take(MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN) - .collect(), - ) -} - -#[derive(Debug, Clone, PartialEq)] -pub struct PluginLoadOutcome { - plugins: Vec, - capability_summaries: Vec, -} - -impl Default for PluginLoadOutcome { - fn default() -> Self { - Self::from_plugins(Vec::new()) - } -} - -impl PluginLoadOutcome { - fn from_plugins(plugins: Vec) -> Self { - let capability_summaries = plugins - .iter() - .filter_map(PluginCapabilitySummary::from_plugin) - .collect::>(); - Self { - plugins, - capability_summaries, - } - } - - pub fn effective_skill_roots(&self) -> Vec { - let mut skill_roots: Vec = self - .plugins - .iter() - .filter(|plugin| plugin.is_active()) - .flat_map(|plugin| plugin.skill_roots.iter().cloned()) - .collect(); - skill_roots.sort_unstable(); - skill_roots.dedup(); - skill_roots - } - - pub fn effective_mcp_servers(&self) -> HashMap { - let mut mcp_servers = HashMap::new(); - for plugin in self.plugins.iter().filter(|plugin| plugin.is_active()) { - for (name, config) in &plugin.mcp_servers { - mcp_servers - .entry(name.clone()) - .or_insert_with(|| config.clone()); - } - } - mcp_servers - } - - pub fn effective_apps(&self) -> Vec { - let mut apps = Vec::new(); - let mut seen_connector_ids = std::collections::HashSet::new(); - - for plugin in self.plugins.iter().filter(|plugin| plugin.is_active()) { - for connector_id in &plugin.apps { - if seen_connector_ids.insert(connector_id.clone()) { - apps.push(connector_id.clone()); - } - } - } - - apps - } - - pub fn capability_summaries(&self) -> &[PluginCapabilitySummary] { - &self.capability_summaries - } - - pub fn plugins(&self) -> &[LoadedPlugin] { - &self.plugins - } -} - #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct RemotePluginSyncResult { /// Plugin ids newly installed into the local plugin cache. @@ -575,6 +406,19 @@ impl PluginsManager { *cached_enabled_outcome = None; } + /// Resolve plugin skill roots for a config layer stack without touching the plugins cache. + pub fn effective_skill_roots_for_layer_stack( + &self, + config_layer_stack: &ConfigLayerStack, + plugins_feature_enabled: bool, + ) -> Vec { + if !plugins_feature_enabled { + return Vec::new(); + } + load_plugins_from_layer_stack(config_layer_stack, &self.store, self.restriction_product) + .effective_skill_roots() + } + fn cached_enabled_outcome(&self) -> Option { match self.cached_enabled_outcome.read() { Ok(cache) => cache.clone(), @@ -1174,7 +1018,7 @@ impl PluginsManager { } }) .collect::>(); - configured_curated_plugin_ids.sort_unstable_by_key(super::store::PluginId::as_key); + configured_curated_plugin_ids.sort_unstable_by_key(PluginId::as_key); self.start_curated_repo_sync(configured_curated_plugin_ids); start_startup_remote_plugin_sync_once( Arc::clone(self), @@ -1341,7 +1185,7 @@ impl PluginUninstallError { fn log_plugin_load_errors(outcome: &PluginLoadOutcome) { for plugin in outcome - .plugins + .plugins() .iter() .filter(|plugin| plugin.error.is_some()) { @@ -1413,16 +1257,6 @@ pub(crate) fn load_plugins_from_layer_stack( PluginLoadOutcome::from_plugins(plugins) } -pub(crate) fn plugin_namespace_for_skill_path(path: &Path) -> Option { - for ancestor in path.ancestors() { - if let Some(manifest) = load_plugin_manifest(ancestor) { - return Some(manifest.name); - } - } - - None -} - fn refresh_curated_plugin_cache( codex_home: &Path, plugin_version: &str, diff --git a/codex-rs/core/src/plugins/manager_tests.rs b/codex-rs/core/src/plugins/manager_tests.rs index 3d61c39375..884beee66d 100644 --- a/codex-rs/core/src/plugins/manager_tests.rs +++ b/codex-rs/core/src/plugins/manager_tests.rs @@ -7,7 +7,9 @@ use crate::config_loader::ConfigLayerEntry; use crate::config_loader::ConfigLayerStack; use crate::config_loader::ConfigRequirements; use crate::config_loader::ConfigRequirementsToml; +use crate::plugins::LoadedPlugin; use crate::plugins::MarketplacePluginInstallPolicy; +use crate::plugins::PluginLoadOutcome; use crate::plugins::test_support::TEST_CURATED_PLUGIN_SHA; use crate::plugins::test_support::write_curated_plugin_sha_with as write_curated_plugin_sha; use crate::plugins::test_support::write_file; @@ -26,6 +28,8 @@ use wiremock::matchers::method; use wiremock::matchers::path; use wiremock::matchers::query_param; +const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024; + fn write_plugin(root: &Path, dir_name: &str, manifest_name: &str) { let plugin_root = root.join(dir_name); fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap(); @@ -130,7 +134,7 @@ fn load_plugins_loads_default_skills_and_mcp_servers() { let outcome = load_plugins_from_config(&plugin_config_toml(true, true), codex_home.path()); assert_eq!( - outcome.plugins, + outcome.plugins(), vec![LoadedPlugin { config_name: "sample@test".to_string(), manifest_name: Some("sample".to_string()), @@ -220,10 +224,10 @@ enabled = true let skill_path = dunce::canonicalize(skill_path).expect("skill path should canonicalize"); assert_eq!( - outcome.plugins[0].disabled_skill_paths, + outcome.plugins()[0].disabled_skill_paths, HashSet::from([skill_path]) ); - assert!(!outcome.plugins[0].has_enabled_skills); + assert!(!outcome.plugins()[0].has_enabled_skills); assert!(outcome.capability_summaries().is_empty()); } @@ -256,8 +260,8 @@ enabled = true "#; let outcome = load_plugins_from_config(config_toml, codex_home.path()); - assert!(outcome.plugins[0].disabled_skill_paths.is_empty()); - assert!(outcome.plugins[0].has_enabled_skills); + assert!(outcome.plugins()[0].disabled_skill_paths.is_empty()); + assert!(outcome.plugins()[0].has_enabled_skills); assert_eq!( outcome.capability_summaries(), &[PluginCapabilitySummary { @@ -338,7 +342,7 @@ fn capability_summary_sanitizes_plugin_descriptions_to_one_line() { let outcome = load_plugins_from_config(&plugin_config_toml(true, true), codex_home.path()); assert_eq!( - outcome.plugins[0].manifest_description.as_deref(), + outcome.plugins()[0].manifest_description.as_deref(), Some("Plugin that\n includes the sample\tserver") ); assert_eq!( @@ -373,7 +377,7 @@ fn capability_summary_truncates_overlong_plugin_descriptions() { let outcome = load_plugins_from_config(&plugin_config_toml(true, true), codex_home.path()); assert_eq!( - outcome.plugins[0].manifest_description.as_deref(), + outcome.plugins()[0].manifest_description.as_deref(), Some(too_long.as_str()) ); assert_eq!( @@ -453,14 +457,14 @@ fn load_plugins_uses_manifest_configured_component_paths() { let outcome = load_plugins_from_config(&plugin_config_toml(true, true), codex_home.path()); assert_eq!( - outcome.plugins[0].skill_roots, + outcome.plugins()[0].skill_roots, vec![ plugin_root.join("custom-skills"), plugin_root.join("skills") ] ); assert_eq!( - outcome.plugins[0].mcp_servers, + outcome.plugins()[0].mcp_servers, HashMap::from([( "custom".to_string(), McpServerConfig { @@ -483,7 +487,7 @@ fn load_plugins_uses_manifest_configured_component_paths() { )]) ); assert_eq!( - outcome.plugins[0].apps, + outcome.plugins()[0].apps, vec![AppConnectorId("connector_custom".to_string())] ); } @@ -559,11 +563,11 @@ fn load_plugins_ignores_manifest_component_paths_without_dot_slash() { let outcome = load_plugins_from_config(&plugin_config_toml(true, true), codex_home.path()); assert_eq!( - outcome.plugins[0].skill_roots, + outcome.plugins()[0].skill_roots, vec![plugin_root.join("skills")] ); assert_eq!( - outcome.plugins[0].mcp_servers, + outcome.plugins()[0].mcp_servers, HashMap::from([( "default".to_string(), McpServerConfig { @@ -586,7 +590,7 @@ fn load_plugins_ignores_manifest_component_paths_without_dot_slash() { )]) ); assert_eq!( - outcome.plugins[0].apps, + outcome.plugins()[0].apps, vec![AppConnectorId("connector_default".to_string())] ); } @@ -618,7 +622,7 @@ fn load_plugins_preserves_disabled_plugins_without_effective_contributions() { let outcome = load_plugins_from_config(&plugin_config_toml(false, true), codex_home.path()); assert_eq!( - outcome.plugins, + outcome.plugins(), vec![LoadedPlugin { config_name: "sample@test".to_string(), manifest_name: None, @@ -805,24 +809,6 @@ fn capability_index_filters_inactive_and_zero_capability_plugins() { ); } -#[test] -fn plugin_namespace_for_skill_path_uses_manifest_name() { - let codex_home = TempDir::new().unwrap(); - let plugin_root = codex_home.path().join("plugins/sample"); - let skill_path = plugin_root.join("skills/search/SKILL.md"); - - write_file( - &plugin_root.join(".codex-plugin/plugin.json"), - r#"{"name":"sample"}"#, - ); - write_file(&skill_path, "---\ndescription: search\n---\n"); - - assert_eq!( - plugin_namespace_for_skill_path(&skill_path), - Some("sample".to_string()) - ); -} - #[test] fn load_plugins_returns_empty_when_feature_disabled() { let codex_home = TempDir::new().unwrap(); @@ -880,9 +866,9 @@ fn load_plugins_rejects_invalid_plugin_keys() { codex_home.path(), ); - assert_eq!(outcome.plugins.len(), 1); + assert_eq!(outcome.plugins().len(), 1); assert_eq!( - outcome.plugins[0].error.as_deref(), + outcome.plugins()[0].error.as_deref(), Some("invalid plugin key `sample`; expected @") ); assert!(outcome.effective_skill_roots().is_empty()); diff --git a/codex-rs/core/src/plugins/marketplace.rs b/codex-rs/core/src/plugins/marketplace.rs index 5563f0c3aa..ee775262aa 100644 --- a/codex-rs/core/src/plugins/marketplace.rs +++ b/codex-rs/core/src/plugins/marketplace.rs @@ -1,10 +1,10 @@ use super::PluginManifestInterface; use super::load_plugin_manifest; -use super::store::PluginId; -use super::store::PluginIdError; use codex_app_server_protocol::PluginAuthPolicy; use codex_app_server_protocol::PluginInstallPolicy; use codex_git_utils::get_git_repo_root; +use codex_plugin::PluginId; +use codex_plugin::PluginIdError; use codex_protocol::protocol::Product; use codex_utils_absolute_path::AbsolutePathBuf; use dirs::home_dir; diff --git a/codex-rs/core/src/plugins/mod.rs b/codex-rs/core/src/plugins/mod.rs index 34403f4d1f..c16ebfcc58 100644 --- a/codex-rs/core/src/plugins/mod.rs +++ b/codex-rs/core/src/plugins/mod.rs @@ -1,3 +1,5 @@ +use crate::config::types::McpServerConfig; + mod discoverable; mod injection; mod manager; @@ -11,31 +13,36 @@ mod store; pub(crate) mod test_support; mod toggles; +pub use codex_plugin::AppConnectorId; +pub use codex_plugin::EffectiveSkillRoots; +pub use codex_plugin::PluginCapabilitySummary; +pub use codex_plugin::PluginId; +pub use codex_plugin::PluginIdError; +pub use codex_plugin::PluginTelemetryMetadata; + +pub type LoadedPlugin = codex_plugin::LoadedPlugin; +pub type PluginLoadOutcome = codex_plugin::PluginLoadOutcome; + +pub(crate) use codex_plugin::plugin_namespace_for_skill_path; pub(crate) use discoverable::list_tool_suggest_discoverable_plugins; pub(crate) use injection::build_plugin_injections; -pub use manager::AppConnectorId; pub use manager::ConfiguredMarketplace; pub use manager::ConfiguredMarketplaceListOutcome; pub use manager::ConfiguredMarketplacePlugin; -pub use manager::LoadedPlugin; pub use manager::OPENAI_CURATED_MARKETPLACE_NAME; -pub use manager::PluginCapabilitySummary; pub use manager::PluginDetail; pub use manager::PluginInstallError; pub use manager::PluginInstallOutcome; pub use manager::PluginInstallRequest; -pub use manager::PluginLoadOutcome; pub use manager::PluginReadOutcome; pub use manager::PluginReadRequest; pub use manager::PluginRemoteSyncError; -pub use manager::PluginTelemetryMetadata; pub use manager::PluginUninstallError; pub use manager::PluginsManager; pub use manager::RemotePluginSyncResult; pub use manager::installed_plugin_telemetry_metadata; pub use manager::load_plugin_apps; pub use manager::load_plugin_mcp_servers; -pub(crate) use manager::plugin_namespace_for_skill_path; pub use manager::plugin_telemetry_metadata_from_root; pub use manifest::PluginManifestInterface; pub(crate) use manifest::PluginManifestPaths; @@ -53,5 +60,4 @@ pub(crate) use render::render_plugins_section; pub(crate) use startup_sync::curated_plugins_repo_path; pub(crate) use startup_sync::read_curated_plugins_sha; pub(crate) use startup_sync::sync_openai_plugins_repo; -pub use store::PluginId; pub use toggles::collect_plugin_enabled_candidates; diff --git a/codex-rs/core/src/plugins/store.rs b/codex-rs/core/src/plugins/store.rs index 97b0ccefe1..faa7fc0812 100644 --- a/codex-rs/core/src/plugins/store.rs +++ b/codex-rs/core/src/plugins/store.rs @@ -1,6 +1,8 @@ use super::load_plugin_manifest; -use super::manifest::PLUGIN_MANIFEST_PATH; +use codex_plugin::PluginId; +use codex_plugin::validate_plugin_segment; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_plugins::PLUGIN_MANIFEST_PATH; use std::fs; use std::io; use std::path::Path; @@ -9,53 +11,6 @@ use std::path::PathBuf; pub(crate) const DEFAULT_PLUGIN_VERSION: &str = "local"; pub(crate) const PLUGINS_CACHE_DIR: &str = "plugins/cache"; -#[derive(Debug, thiserror::Error)] -pub enum PluginIdError { - #[error("{0}")] - Invalid(String), -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PluginId { - pub plugin_name: String, - pub marketplace_name: String, -} - -impl PluginId { - pub fn new(plugin_name: String, marketplace_name: String) -> Result { - validate_plugin_segment(&plugin_name, "plugin name").map_err(PluginIdError::Invalid)?; - validate_plugin_segment(&marketplace_name, "marketplace name") - .map_err(PluginIdError::Invalid)?; - Ok(Self { - plugin_name, - marketplace_name, - }) - } - - pub fn parse(plugin_key: &str) -> Result { - let Some((plugin_name, marketplace_name)) = plugin_key.rsplit_once('@') else { - return Err(PluginIdError::Invalid(format!( - "invalid plugin key `{plugin_key}`; expected @" - ))); - }; - if plugin_name.is_empty() || marketplace_name.is_empty() { - return Err(PluginIdError::Invalid(format!( - "invalid plugin key `{plugin_key}`; expected @" - ))); - } - - Self::new(plugin_name.to_string(), marketplace_name.to_string()).map_err(|err| match err { - PluginIdError::Invalid(message) => { - PluginIdError::Invalid(format!("{message} in `{plugin_key}`")) - } - }) - } - - pub fn as_key(&self) -> String { - format!("{}@{}", self.plugin_name, self.marketplace_name) - } -} - #[derive(Debug, Clone, PartialEq, Eq)] pub struct PluginInstallResult { pub plugin_id: PluginId, @@ -221,21 +176,6 @@ fn plugin_name_for_source(source_path: &Path) -> Result Result<(), String> { - if segment.is_empty() { - return Err(format!("invalid {kind}: must not be empty")); - } - if !segment - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') - { - return Err(format!( - "invalid {kind}: only ASCII letters, digits, `_`, and `-` are allowed" - )); - } - Ok(()) -} - fn remove_existing_target(path: &Path) -> Result<(), PluginStoreError> { if !path.exists() { return Ok(()); diff --git a/codex-rs/core/src/plugins/store_tests.rs b/codex-rs/core/src/plugins/store_tests.rs index 61c3460255..e75e71c568 100644 --- a/codex-rs/core/src/plugins/store_tests.rs +++ b/codex-rs/core/src/plugins/store_tests.rs @@ -1,4 +1,5 @@ use super::*; +use codex_plugin::PluginId; use pretty_assertions::assert_eq; use tempfile::tempdir; diff --git a/codex-rs/plugin/BUILD.bazel b/codex-rs/plugin/BUILD.bazel new file mode 100644 index 0000000000..292606ff06 --- /dev/null +++ b/codex-rs/plugin/BUILD.bazel @@ -0,0 +1,15 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "plugin", + crate_name = "codex_plugin", + compile_data = glob( + include = ["**"], + exclude = [ + "**/* *", + "BUILD.bazel", + "Cargo.toml", + ], + allow_empty = True, + ), +) diff --git a/codex-rs/plugin/Cargo.toml b/codex-rs/plugin/Cargo.toml new file mode 100644 index 0000000000..b72d74682c --- /dev/null +++ b/codex-rs/plugin/Cargo.toml @@ -0,0 +1,18 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-plugin" +version.workspace = true + +[lib] +doctest = false +name = "codex_plugin" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +codex-utils-absolute-path = { workspace = true } +codex-utils-plugins = { workspace = true } +thiserror = { workspace = true } diff --git a/codex-rs/plugin/src/lib.rs b/codex-rs/plugin/src/lib.rs new file mode 100644 index 0000000000..7b56cd3faf --- /dev/null +++ b/codex-rs/plugin/src/lib.rs @@ -0,0 +1,55 @@ +//! Shared plugin identifiers and telemetry-facing summaries. + +pub use codex_utils_plugins::PLUGIN_MANIFEST_PATH; +pub use codex_utils_plugins::mention_syntax; +pub use codex_utils_plugins::plugin_namespace_for_skill_path; + +mod load_outcome; +mod plugin_id; + +pub use load_outcome::EffectiveSkillRoots; +pub use load_outcome::LoadedPlugin; +pub use load_outcome::PluginLoadOutcome; +pub use load_outcome::prompt_safe_plugin_description; +pub use plugin_id::PluginId; +pub use plugin_id::PluginIdError; +pub use plugin_id::validate_plugin_segment; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct AppConnectorId(pub String); + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PluginCapabilitySummary { + pub config_name: String, + pub display_name: String, + pub description: Option, + pub has_skills: bool, + pub mcp_server_names: Vec, + pub app_connector_ids: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginTelemetryMetadata { + pub plugin_id: PluginId, + pub capability_summary: Option, +} + +impl PluginTelemetryMetadata { + pub fn from_plugin_id(plugin_id: &PluginId) -> Self { + Self { + plugin_id: plugin_id.clone(), + capability_summary: None, + } + } +} + +impl PluginCapabilitySummary { + pub fn telemetry_metadata(&self) -> Option { + PluginId::parse(&self.config_name) + .ok() + .map(|plugin_id| PluginTelemetryMetadata { + plugin_id, + capability_summary: Some(self.clone()), + }) + } +} diff --git a/codex-rs/plugin/src/load_outcome.rs b/codex-rs/plugin/src/load_outcome.rs new file mode 100644 index 0000000000..9faebaebe9 --- /dev/null +++ b/codex-rs/plugin/src/load_outcome.rs @@ -0,0 +1,163 @@ +use std::collections::HashMap; +use std::collections::HashSet; +use std::path::PathBuf; + +use codex_utils_absolute_path::AbsolutePathBuf; + +use crate::AppConnectorId; +use crate::PluginCapabilitySummary; + +const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024; + +/// A plugin that was loaded from disk, including merged MCP server definitions. +#[derive(Debug, Clone, PartialEq)] +pub struct LoadedPlugin { + pub config_name: String, + pub manifest_name: Option, + pub manifest_description: Option, + pub root: AbsolutePathBuf, + pub enabled: bool, + pub skill_roots: Vec, + pub disabled_skill_paths: HashSet, + pub has_enabled_skills: bool, + pub mcp_servers: HashMap, + pub apps: Vec, + pub error: Option, +} + +impl LoadedPlugin { + pub fn is_active(&self) -> bool { + self.enabled && self.error.is_none() + } +} + +fn plugin_capability_summary_from_loaded( + plugin: &LoadedPlugin, +) -> Option { + if !plugin.is_active() { + return None; + } + + let mut mcp_server_names: Vec = plugin.mcp_servers.keys().cloned().collect(); + mcp_server_names.sort_unstable(); + + let summary = PluginCapabilitySummary { + config_name: plugin.config_name.clone(), + display_name: plugin + .manifest_name + .clone() + .unwrap_or_else(|| plugin.config_name.clone()), + description: prompt_safe_plugin_description(plugin.manifest_description.as_deref()), + has_skills: plugin.has_enabled_skills, + mcp_server_names, + app_connector_ids: plugin.apps.clone(), + }; + + (summary.has_skills + || !summary.mcp_server_names.is_empty() + || !summary.app_connector_ids.is_empty()) + .then_some(summary) +} + +/// Normalizes plugin descriptions for inclusion in model-facing capability summaries. +pub fn prompt_safe_plugin_description(description: Option<&str>) -> Option { + let description = description? + .split_whitespace() + .collect::>() + .join(" "); + if description.is_empty() { + return None; + } + + Some( + description + .chars() + .take(MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN) + .collect(), + ) +} + +/// Outcome of loading configured plugins (skills roots, MCP, apps, errors). +#[derive(Debug, Clone, PartialEq)] +pub struct PluginLoadOutcome { + plugins: Vec>, + capability_summaries: Vec, +} + +impl Default for PluginLoadOutcome { + fn default() -> Self { + Self::from_plugins(Vec::new()) + } +} + +impl PluginLoadOutcome { + pub fn from_plugins(plugins: Vec>) -> Self { + let capability_summaries = plugins + .iter() + .filter_map(plugin_capability_summary_from_loaded) + .collect::>(); + Self { + plugins, + capability_summaries, + } + } + + pub fn effective_skill_roots(&self) -> Vec { + let mut skill_roots: Vec = self + .plugins + .iter() + .filter(|plugin| plugin.is_active()) + .flat_map(|plugin| plugin.skill_roots.iter().cloned()) + .collect(); + skill_roots.sort_unstable(); + skill_roots.dedup(); + skill_roots + } + + pub fn effective_mcp_servers(&self) -> HashMap { + let mut mcp_servers = HashMap::new(); + for plugin in self.plugins.iter().filter(|plugin| plugin.is_active()) { + for (name, config) in &plugin.mcp_servers { + mcp_servers + .entry(name.clone()) + .or_insert_with(|| config.clone()); + } + } + mcp_servers + } + + pub fn effective_apps(&self) -> Vec { + let mut apps = Vec::new(); + let mut seen_connector_ids = HashSet::new(); + + for plugin in self.plugins.iter().filter(|plugin| plugin.is_active()) { + for connector_id in &plugin.apps { + if seen_connector_ids.insert(connector_id.clone()) { + apps.push(connector_id.clone()); + } + } + } + + apps + } + + pub fn capability_summaries(&self) -> &[PluginCapabilitySummary] { + &self.capability_summaries + } + + pub fn plugins(&self) -> &[LoadedPlugin] { + &self.plugins + } +} + +/// Implemented by [`PluginLoadOutcome`] so callers (e.g. skills) can depend on `codex-plugin` +/// without naming the MCP config type parameter. +pub trait EffectiveSkillRoots { + fn effective_skill_roots(&self) -> Vec; +} + +impl EffectiveSkillRoots for PluginLoadOutcome { + fn effective_skill_roots(&self) -> Vec { + PluginLoadOutcome::effective_skill_roots(self) + } +} diff --git a/codex-rs/plugin/src/plugin_id.rs b/codex-rs/plugin/src/plugin_id.rs new file mode 100644 index 0000000000..075116322b --- /dev/null +++ b/codex-rs/plugin/src/plugin_id.rs @@ -0,0 +1,64 @@ +//! Stable plugin identifier parsing and validation shared with the plugin cache. + +#[derive(Debug, thiserror::Error)] +pub enum PluginIdError { + #[error("{0}")] + Invalid(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginId { + pub plugin_name: String, + pub marketplace_name: String, +} + +impl PluginId { + pub fn new(plugin_name: String, marketplace_name: String) -> Result { + validate_plugin_segment(&plugin_name, "plugin name").map_err(PluginIdError::Invalid)?; + validate_plugin_segment(&marketplace_name, "marketplace name") + .map_err(PluginIdError::Invalid)?; + Ok(Self { + plugin_name, + marketplace_name, + }) + } + + pub fn parse(plugin_key: &str) -> Result { + let Some((plugin_name, marketplace_name)) = plugin_key.rsplit_once('@') else { + return Err(PluginIdError::Invalid(format!( + "invalid plugin key `{plugin_key}`; expected @" + ))); + }; + if plugin_name.is_empty() || marketplace_name.is_empty() { + return Err(PluginIdError::Invalid(format!( + "invalid plugin key `{plugin_key}`; expected @" + ))); + } + + Self::new(plugin_name.to_string(), marketplace_name.to_string()).map_err(|err| match err { + PluginIdError::Invalid(message) => { + PluginIdError::Invalid(format!("{message} in `{plugin_key}`")) + } + }) + } + + pub fn as_key(&self) -> String { + format!("{}@{}", self.plugin_name, self.marketplace_name) + } +} + +/// Validates a single path segment used in plugin IDs and cache layout. +pub fn validate_plugin_segment(segment: &str, kind: &str) -> Result<(), String> { + if segment.is_empty() { + return Err(format!("invalid {kind}: must not be empty")); + } + if !segment + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') + { + return Err(format!( + "invalid {kind}: only ASCII letters, digits, `_`, and `-` are allowed" + )); + } + Ok(()) +} From d273efc0f31615ffdfe23481829d3dbb1e729861 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 25 Mar 2026 11:08:05 -0700 Subject: [PATCH 17/19] Extract codex-analytics crate (#15748) ## Summary - move the analytics events client into codex-analytics - update codex-core and app-server callsites to use the new crate ## Testing - CI --------- Co-authored-by: Codex --- codex-rs/Cargo.lock | 17 ++ codex-rs/Cargo.toml | 2 + codex-rs/analytics/BUILD.bazel | 6 + codex-rs/analytics/Cargo.toml | 30 +++ .../src/analytics_client.rs | 239 ++++++++++-------- .../src/analytics_client_tests.rs | 17 +- codex-rs/analytics/src/lib.rs | 8 + codex-rs/app-server/src/config_api.rs | 13 +- codex-rs/app-server/src/message_processor.rs | 7 +- codex-rs/core/Cargo.toml | 1 + codex-rs/core/src/codex.rs | 11 +- codex-rs/core/src/codex_tests.rs | 6 +- codex-rs/core/src/lib.rs | 3 +- codex-rs/core/src/mcp_tool_call.rs | 6 +- codex-rs/core/src/skills/injection.rs | 8 +- codex-rs/core/src/skills/invocation_utils.rs | 6 +- codex-rs/core/src/state/service.rs | 2 +- 17 files changed, 244 insertions(+), 138 deletions(-) create mode 100644 codex-rs/analytics/BUILD.bazel create mode 100644 codex-rs/analytics/Cargo.toml rename codex-rs/{core => analytics}/src/analytics_client.rs (78%) rename codex-rs/{core => analytics}/src/analytics_client_tests.rs (95%) create mode 100644 codex-rs/analytics/src/lib.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index b9760de416..df44a7f83b 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1384,6 +1384,22 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9b18233253483ce2f65329a24072ec414db782531bdbb7d0bbc4bd2ce6b7e21" +[[package]] +name = "codex-analytics" +version = "0.0.0" +dependencies = [ + "codex-git-utils", + "codex-login", + "codex-plugin", + "codex-protocol", + "pretty_assertions", + "serde", + "serde_json", + "sha1", + "tokio", + "tracing", +] + [[package]] name = "codex-ansi-escape" version = "0.0.0" @@ -1869,6 +1885,7 @@ dependencies = [ "chardetng", "chrono", "clap", + "codex-analytics", "codex-api", "codex-app-server-protocol", "codex-apply-patch", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 4b23f48658..10b9485d20 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [ + "analytics", "backend-client", "ansi-escape", "async-utils", @@ -98,6 +99,7 @@ license = "Apache-2.0" # Internal app_test_support = { path = "app-server/tests/common" } codex-ansi-escape = { path = "ansi-escape" } +codex-analytics = { path = "analytics" } codex-api = { path = "codex-api" } codex-artifacts = { path = "artifacts" } codex-code-mode = { path = "code-mode" } diff --git a/codex-rs/analytics/BUILD.bazel b/codex-rs/analytics/BUILD.bazel new file mode 100644 index 0000000000..aec07c8746 --- /dev/null +++ b/codex-rs/analytics/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "analytics", + crate_name = "codex_analytics", +) diff --git a/codex-rs/analytics/Cargo.toml b/codex-rs/analytics/Cargo.toml new file mode 100644 index 0000000000..9633eebb14 --- /dev/null +++ b/codex-rs/analytics/Cargo.toml @@ -0,0 +1,30 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-analytics" +version.workspace = true + +[lib] +doctest = false +name = "codex_analytics" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +codex-git-utils = { workspace = true } +codex-login = { workspace = true } +codex-plugin = { workspace = true } +codex-protocol = { workspace = true } +serde = { workspace = true, features = ["derive"] } +sha1 = { workspace = true } +tokio = { workspace = true, features = [ + "macros", + "rt-multi-thread", +] } +tracing = { workspace = true, features = ["log"] } + +[dev-dependencies] +pretty_assertions = { workspace = true } +serde_json = { workspace = true } diff --git a/codex-rs/core/src/analytics_client.rs b/codex-rs/analytics/src/analytics_client.rs similarity index 78% rename from codex-rs/core/src/analytics_client.rs rename to codex-rs/analytics/src/analytics_client.rs index 922f16de58..ac8d10b6c1 100644 --- a/codex-rs/core/src/analytics_client.rs +++ b/codex-rs/analytics/src/analytics_client.rs @@ -1,9 +1,9 @@ -use crate::AuthManager; -use crate::config::Config; -use crate::default_client::create_client; -use crate::plugins::PluginTelemetryMetadata; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; +use codex_login::AuthManager; +use codex_login::default_client::create_client; +use codex_login::default_client::originator; +use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SkillScope; use serde::Serialize; use sha1::Digest; @@ -17,13 +17,13 @@ use std::time::Duration; use tokio::sync::mpsc; #[derive(Clone)] -pub(crate) struct TrackEventsContext { - pub(crate) model_slug: String, - pub(crate) thread_id: String, - pub(crate) turn_id: String, +pub struct TrackEventsContext { + pub model_slug: String, + pub thread_id: String, + pub turn_id: String, } -pub(crate) fn build_track_events_context( +pub fn build_track_events_context( model_slug: String, thread_id: String, turn_id: String, @@ -36,24 +36,24 @@ pub(crate) fn build_track_events_context( } #[derive(Clone, Debug)] -pub(crate) struct SkillInvocation { - pub(crate) skill_name: String, - pub(crate) skill_scope: SkillScope, - pub(crate) skill_path: PathBuf, - pub(crate) invocation_type: InvocationType, +pub struct SkillInvocation { + pub skill_name: String, + pub skill_scope: SkillScope, + pub skill_path: PathBuf, + pub invocation_type: InvocationType, } #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "lowercase")] -pub(crate) enum InvocationType { +pub enum InvocationType { Explicit, Implicit, } -pub(crate) struct AppInvocation { - pub(crate) connector_id: Option, - pub(crate) app_name: Option, - pub(crate) invocation_type: Option, +pub struct AppInvocation { + pub connector_id: Option, + pub app_name: Option, + pub invocation_type: Option, } #[derive(Clone)] @@ -66,38 +66,38 @@ pub(crate) struct AnalyticsEventsQueue { #[derive(Clone)] pub struct AnalyticsEventsClient { queue: AnalyticsEventsQueue, - config: Arc, + analytics_enabled: Option, } impl AnalyticsEventsQueue { - pub(crate) fn new(auth_manager: Arc) -> Self { + pub(crate) fn new(auth_manager: Arc, base_url: String) -> Self { let (sender, mut receiver) = mpsc::channel(ANALYTICS_EVENTS_QUEUE_SIZE); tokio::spawn(async move { while let Some(job) = receiver.recv().await { match job { TrackEventsJob::SkillInvocations(job) => { - send_track_skill_invocations(&auth_manager, job).await; + send_track_skill_invocations(&auth_manager, &base_url, job).await; } TrackEventsJob::AppMentioned(job) => { - send_track_app_mentioned(&auth_manager, job).await; + send_track_app_mentioned(&auth_manager, &base_url, job).await; } TrackEventsJob::AppUsed(job) => { - send_track_app_used(&auth_manager, job).await; + send_track_app_used(&auth_manager, &base_url, job).await; } TrackEventsJob::PluginUsed(job) => { - send_track_plugin_used(&auth_manager, job).await; + send_track_plugin_used(&auth_manager, &base_url, job).await; } TrackEventsJob::PluginInstalled(job) => { - send_track_plugin_installed(&auth_manager, job).await; + send_track_plugin_installed(&auth_manager, &base_url, job).await; } TrackEventsJob::PluginUninstalled(job) => { - send_track_plugin_uninstalled(&auth_manager, job).await; + send_track_plugin_uninstalled(&auth_manager, &base_url, job).await; } TrackEventsJob::PluginEnabled(job) => { - send_track_plugin_enabled(&auth_manager, job).await; + send_track_plugin_enabled(&auth_manager, &base_url, job).await; } TrackEventsJob::PluginDisabled(job) => { - send_track_plugin_disabled(&auth_manager, job).await; + send_track_plugin_disabled(&auth_manager, &base_url, job).await; } } } @@ -147,60 +147,51 @@ impl AnalyticsEventsQueue { } impl AnalyticsEventsClient { - pub fn new(config: Arc, auth_manager: Arc) -> Self { + pub fn new( + auth_manager: Arc, + base_url: String, + analytics_enabled: Option, + ) -> Self { Self { - queue: AnalyticsEventsQueue::new(Arc::clone(&auth_manager)), - config, + queue: AnalyticsEventsQueue::new(Arc::clone(&auth_manager), base_url), + analytics_enabled, } } - pub(crate) fn track_skill_invocations( + pub fn track_skill_invocations( &self, tracking: TrackEventsContext, invocations: Vec, ) { track_skill_invocations( &self.queue, - Arc::clone(&self.config), + self.analytics_enabled, Some(tracking), invocations, ); } - pub(crate) fn track_app_mentioned( - &self, - tracking: TrackEventsContext, - mentions: Vec, - ) { + pub fn track_app_mentioned(&self, tracking: TrackEventsContext, mentions: Vec) { track_app_mentioned( &self.queue, - Arc::clone(&self.config), + self.analytics_enabled, Some(tracking), mentions, ); } - pub(crate) fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { - track_app_used(&self.queue, Arc::clone(&self.config), Some(tracking), app); + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { + track_app_used(&self.queue, self.analytics_enabled, Some(tracking), app); } - pub(crate) fn track_plugin_used( - &self, - tracking: TrackEventsContext, - plugin: PluginTelemetryMetadata, - ) { - track_plugin_used( - &self.queue, - Arc::clone(&self.config), - Some(tracking), - plugin, - ); + pub fn track_plugin_used(&self, tracking: TrackEventsContext, plugin: PluginTelemetryMetadata) { + track_plugin_used(&self.queue, self.analytics_enabled, Some(tracking), plugin); } pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { track_plugin_management( &self.queue, - Arc::clone(&self.config), + self.analytics_enabled, PluginManagementEventType::Installed, plugin, ); @@ -209,7 +200,7 @@ impl AnalyticsEventsClient { pub fn track_plugin_uninstalled(&self, plugin: PluginTelemetryMetadata) { track_plugin_management( &self.queue, - Arc::clone(&self.config), + self.analytics_enabled, PluginManagementEventType::Uninstalled, plugin, ); @@ -218,7 +209,7 @@ impl AnalyticsEventsClient { pub fn track_plugin_enabled(&self, plugin: PluginTelemetryMetadata) { track_plugin_management( &self.queue, - Arc::clone(&self.config), + self.analytics_enabled, PluginManagementEventType::Enabled, plugin, ); @@ -227,7 +218,7 @@ impl AnalyticsEventsClient { pub fn track_plugin_disabled(&self, plugin: PluginTelemetryMetadata) { track_plugin_management( &self.queue, - Arc::clone(&self.config), + self.analytics_enabled, PluginManagementEventType::Disabled, plugin, ); @@ -246,31 +237,31 @@ enum TrackEventsJob { } struct TrackSkillInvocationsJob { - config: Arc, + analytics_enabled: Option, tracking: TrackEventsContext, invocations: Vec, } struct TrackAppMentionedJob { - config: Arc, + analytics_enabled: Option, tracking: TrackEventsContext, mentions: Vec, } struct TrackAppUsedJob { - config: Arc, + analytics_enabled: Option, tracking: TrackEventsContext, app: AppInvocation, } struct TrackPluginUsedJob { - config: Arc, + analytics_enabled: Option, tracking: TrackEventsContext, plugin: PluginTelemetryMetadata, } struct TrackPluginManagementJob { - config: Arc, + analytics_enabled: Option, plugin: PluginTelemetryMetadata, } @@ -379,11 +370,11 @@ struct CodexPluginUsedEventRequest { pub(crate) fn track_skill_invocations( queue: &AnalyticsEventsQueue, - config: Arc, + analytics_enabled: Option, tracking: Option, invocations: Vec, ) { - if config.analytics_enabled == Some(false) { + if analytics_enabled == Some(false) { return; } let Some(tracking) = tracking else { @@ -393,7 +384,7 @@ pub(crate) fn track_skill_invocations( return; } let job = TrackEventsJob::SkillInvocations(TrackSkillInvocationsJob { - config, + analytics_enabled, tracking, invocations, }); @@ -402,11 +393,11 @@ pub(crate) fn track_skill_invocations( pub(crate) fn track_app_mentioned( queue: &AnalyticsEventsQueue, - config: Arc, + analytics_enabled: Option, tracking: Option, mentions: Vec, ) { - if config.analytics_enabled == Some(false) { + if analytics_enabled == Some(false) { return; } let Some(tracking) = tracking else { @@ -416,7 +407,7 @@ pub(crate) fn track_app_mentioned( return; } let job = TrackEventsJob::AppMentioned(TrackAppMentionedJob { - config, + analytics_enabled, tracking, mentions, }); @@ -425,11 +416,11 @@ pub(crate) fn track_app_mentioned( pub(crate) fn track_app_used( queue: &AnalyticsEventsQueue, - config: Arc, + analytics_enabled: Option, tracking: Option, app: AppInvocation, ) { - if config.analytics_enabled == Some(false) { + if analytics_enabled == Some(false) { return; } let Some(tracking) = tracking else { @@ -439,7 +430,7 @@ pub(crate) fn track_app_used( return; } let job = TrackEventsJob::AppUsed(TrackAppUsedJob { - config, + analytics_enabled, tracking, app, }); @@ -448,11 +439,11 @@ pub(crate) fn track_app_used( pub(crate) fn track_plugin_used( queue: &AnalyticsEventsQueue, - config: Arc, + analytics_enabled: Option, tracking: Option, plugin: PluginTelemetryMetadata, ) { - if config.analytics_enabled == Some(false) { + if analytics_enabled == Some(false) { return; } let Some(tracking) = tracking else { @@ -462,7 +453,7 @@ pub(crate) fn track_plugin_used( return; } let job = TrackEventsJob::PluginUsed(TrackPluginUsedJob { - config, + analytics_enabled, tracking, plugin, }); @@ -471,14 +462,17 @@ pub(crate) fn track_plugin_used( fn track_plugin_management( queue: &AnalyticsEventsQueue, - config: Arc, + analytics_enabled: Option, event_type: PluginManagementEventType, plugin: PluginTelemetryMetadata, ) { - if config.analytics_enabled == Some(false) { + if analytics_enabled == Some(false) { return; } - let job = TrackPluginManagementJob { config, plugin }; + let job = TrackPluginManagementJob { + analytics_enabled, + plugin, + }; let job = match event_type { PluginManagementEventType::Installed => TrackEventsJob::PluginInstalled(job), PluginManagementEventType::Uninstalled => TrackEventsJob::PluginUninstalled(job), @@ -488,9 +482,13 @@ fn track_plugin_management( queue.try_send(job); } -async fn send_track_skill_invocations(auth_manager: &AuthManager, job: TrackSkillInvocationsJob) { +async fn send_track_skill_invocations( + auth_manager: &AuthManager, + base_url: &str, + job: TrackSkillInvocationsJob, +) { let TrackSkillInvocationsJob { - config, + analytics_enabled, tracking, invocations, } = job; @@ -525,7 +523,7 @@ async fn send_track_skill_invocations(auth_manager: &AuthManager, job: TrackSkil thread_id: Some(tracking.thread_id.clone()), invoke_type: Some(invocation.invocation_type), model_slug: Some(tracking.model_slug.clone()), - product_client_id: Some(crate::default_client::originator().value), + product_client_id: Some(originator().value), repo_url, skill_scope: Some(skill_scope.to_string()), }, @@ -533,12 +531,16 @@ async fn send_track_skill_invocations(auth_manager: &AuthManager, job: TrackSkil )); } - send_track_events(auth_manager, config, events).await; + send_track_events(auth_manager, analytics_enabled, base_url, events).await; } -async fn send_track_app_mentioned(auth_manager: &AuthManager, job: TrackAppMentionedJob) { +async fn send_track_app_mentioned( + auth_manager: &AuthManager, + base_url: &str, + job: TrackAppMentionedJob, +) { let TrackAppMentionedJob { - config, + analytics_enabled, tracking, mentions, } = job; @@ -553,12 +555,12 @@ async fn send_track_app_mentioned(auth_manager: &AuthManager, job: TrackAppMenti }) .collect::>(); - send_track_events(auth_manager, config, events).await; + send_track_events(auth_manager, analytics_enabled, base_url, events).await; } -async fn send_track_app_used(auth_manager: &AuthManager, job: TrackAppUsedJob) { +async fn send_track_app_used(auth_manager: &AuthManager, base_url: &str, job: TrackAppUsedJob) { let TrackAppUsedJob { - config, + analytics_enabled, tracking, app, } = job; @@ -568,12 +570,16 @@ async fn send_track_app_used(auth_manager: &AuthManager, job: TrackAppUsedJob) { event_params, })]; - send_track_events(auth_manager, config, events).await; + send_track_events(auth_manager, analytics_enabled, base_url, events).await; } -async fn send_track_plugin_used(auth_manager: &AuthManager, job: TrackPluginUsedJob) { +async fn send_track_plugin_used( + auth_manager: &AuthManager, + base_url: &str, + job: TrackPluginUsedJob, +) { let TrackPluginUsedJob { - config, + analytics_enabled, tracking, plugin, } = job; @@ -582,31 +588,52 @@ async fn send_track_plugin_used(auth_manager: &AuthManager, job: TrackPluginUsed event_params: codex_plugin_used_metadata(&tracking, plugin), })]; - send_track_events(auth_manager, config, events).await; + send_track_events(auth_manager, analytics_enabled, base_url, events).await; } -async fn send_track_plugin_installed(auth_manager: &AuthManager, job: TrackPluginManagementJob) { - send_track_plugin_management_event(auth_manager, job, "codex_plugin_installed").await; +async fn send_track_plugin_installed( + auth_manager: &AuthManager, + base_url: &str, + job: TrackPluginManagementJob, +) { + send_track_plugin_management_event(auth_manager, base_url, job, "codex_plugin_installed").await; } -async fn send_track_plugin_uninstalled(auth_manager: &AuthManager, job: TrackPluginManagementJob) { - send_track_plugin_management_event(auth_manager, job, "codex_plugin_uninstalled").await; +async fn send_track_plugin_uninstalled( + auth_manager: &AuthManager, + base_url: &str, + job: TrackPluginManagementJob, +) { + send_track_plugin_management_event(auth_manager, base_url, job, "codex_plugin_uninstalled") + .await; } -async fn send_track_plugin_enabled(auth_manager: &AuthManager, job: TrackPluginManagementJob) { - send_track_plugin_management_event(auth_manager, job, "codex_plugin_enabled").await; +async fn send_track_plugin_enabled( + auth_manager: &AuthManager, + base_url: &str, + job: TrackPluginManagementJob, +) { + send_track_plugin_management_event(auth_manager, base_url, job, "codex_plugin_enabled").await; } -async fn send_track_plugin_disabled(auth_manager: &AuthManager, job: TrackPluginManagementJob) { - send_track_plugin_management_event(auth_manager, job, "codex_plugin_disabled").await; +async fn send_track_plugin_disabled( + auth_manager: &AuthManager, + base_url: &str, + job: TrackPluginManagementJob, +) { + send_track_plugin_management_event(auth_manager, base_url, job, "codex_plugin_disabled").await; } async fn send_track_plugin_management_event( auth_manager: &AuthManager, + base_url: &str, job: TrackPluginManagementJob, event_type: &'static str, ) { - let TrackPluginManagementJob { config, plugin } = job; + let TrackPluginManagementJob { + analytics_enabled, + plugin, + } = job; let event_params = codex_plugin_metadata(plugin); let event = CodexPluginEventRequest { event_type, @@ -620,7 +647,7 @@ async fn send_track_plugin_management_event( _ => unreachable!("unknown plugin management event type"), }]; - send_track_events(auth_manager, config, events).await; + send_track_events(auth_manager, analytics_enabled, base_url, events).await; } fn codex_app_metadata(tracking: &TrackEventsContext, app: AppInvocation) -> CodexAppMetadata { @@ -629,7 +656,7 @@ fn codex_app_metadata(tracking: &TrackEventsContext, app: AppInvocation) -> Code thread_id: Some(tracking.thread_id.clone()), turn_id: Some(tracking.turn_id.clone()), app_name: app.app_name, - product_client_id: Some(crate::default_client::originator().value), + product_client_id: Some(originator().value), invoke_type: app.invocation_type, model_slug: Some(tracking.model_slug.clone()), } @@ -654,7 +681,7 @@ fn codex_plugin_metadata(plugin: PluginTelemetryMetadata) -> CodexPluginMetadata .map(|connector_id| connector_id.0) .collect() }), - product_client_id: Some(crate::default_client::originator().value), + product_client_id: Some(originator().value), } } @@ -672,9 +699,13 @@ fn codex_plugin_used_metadata( async fn send_track_events( auth_manager: &AuthManager, - config: Arc, + analytics_enabled: Option, + base_url: &str, events: Vec, ) { + if analytics_enabled == Some(false) { + return; + } if events.is_empty() { return; } @@ -692,7 +723,7 @@ async fn send_track_events( return; }; - let base_url = config.chatgpt_base_url.trim_end_matches('/'); + let base_url = base_url.trim_end_matches('/'); let url = format!("{base_url}/codex/analytics-events/events"); let payload = TrackEventsRequest { events }; diff --git a/codex-rs/core/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs similarity index 95% rename from codex-rs/core/src/analytics_client_tests.rs rename to codex-rs/analytics/src/analytics_client_tests.rs index a038aae047..f64da8f589 100644 --- a/codex-rs/core/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -11,10 +11,11 @@ use super::codex_app_metadata; use super::codex_plugin_metadata; use super::codex_plugin_used_metadata; use super::normalize_path_for_skill_id; -use crate::plugins::AppConnectorId; -use crate::plugins::PluginCapabilitySummary; -use crate::plugins::PluginId; -use crate::plugins::PluginTelemetryMetadata; +use codex_login::default_client::originator; +use codex_plugin::AppConnectorId; +use codex_plugin::PluginCapabilitySummary; +use codex_plugin::PluginId; +use codex_plugin::PluginTelemetryMetadata; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashSet; @@ -109,7 +110,7 @@ fn app_mentioned_event_serializes_expected_shape() { "thread_id": "thread-1", "turn_id": "turn-1", "app_name": "Calendar", - "product_client_id": crate::default_client::originator().value, + "product_client_id": originator().value, "invoke_type": "explicit", "model_slug": "gpt-5" } @@ -147,7 +148,7 @@ fn app_used_event_serializes_expected_shape() { "thread_id": "thread-2", "turn_id": "turn-2", "app_name": "Google Drive", - "product_client_id": crate::default_client::originator().value, + "product_client_id": originator().value, "invoke_type": "implicit", "model_slug": "gpt-5" } @@ -210,7 +211,7 @@ fn plugin_used_event_serializes_expected_shape() { "has_skills": true, "mcp_server_count": 2, "connector_ids": ["calendar", "drive"], - "product_client_id": crate::default_client::originator().value, + "product_client_id": originator().value, "thread_id": "thread-3", "turn_id": "turn-3", "model_slug": "gpt-5" @@ -239,7 +240,7 @@ fn plugin_management_event_serializes_expected_shape() { "has_skills": true, "mcp_server_count": 2, "connector_ids": ["calendar", "drive"], - "product_client_id": crate::default_client::originator().value + "product_client_id": originator().value } }) ); diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs new file mode 100644 index 0000000000..e36f5d71b5 --- /dev/null +++ b/codex-rs/analytics/src/lib.rs @@ -0,0 +1,8 @@ +mod analytics_client; + +pub use analytics_client::AnalyticsEventsClient; +pub use analytics_client::AppInvocation; +pub use analytics_client::InvocationType; +pub use analytics_client::SkillInvocation; +pub use analytics_client::TrackEventsContext; +pub use analytics_client::build_track_events_context; diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index 4aa2982a81..2be0bb859c 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -450,6 +450,8 @@ fn config_write_error(code: ConfigWriteErrorCode, message: impl Into) -> mod tests { use super::*; use codex_core::AnalyticsEventsClient; + use codex_core::AuthManager; + use codex_core::CodexAuth; use codex_core::config_loader::NetworkRequirementsToml as CoreNetworkRequirementsToml; use codex_features::Feature; use codex_protocol::protocol::AskForApproval as CoreAskForApproval; @@ -651,6 +653,7 @@ mod tests { .await .expect("load analytics config"), ); + let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); let config_api = ConfigApi::new( codex_home.path().to_path_buf(), Arc::new(RwLock::new(Vec::new())), @@ -659,10 +662,12 @@ mod tests { Arc::new(RwLock::new(CloudRequirementsLoader::default())), reloader.clone(), AnalyticsEventsClient::new( - analytics_config, - codex_core::test_support::auth_manager_from_auth( - codex_core::CodexAuth::from_api_key("test"), - ), + auth_manager, + analytics_config + .chatgpt_base_url + .trim_end_matches('/') + .to_string(), + analytics_config.analytics_enabled, ), ); diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index d070738fba..feca709513 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -222,8 +222,11 @@ impl MessageProcessor { auth_manager.set_external_auth_refresher(Arc::new(ExternalAuthRefreshBridge { outgoing: outgoing.clone(), })); - let analytics_events_client = - AnalyticsEventsClient::new(Arc::clone(&config), Arc::clone(&auth_manager)); + let analytics_events_client = AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 9e01e6662f..42ca739fa2 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -27,6 +27,7 @@ bm25 = { workspace = true } chardetng = { workspace = true } chrono = { workspace = true, features = ["serde"] } clap = { workspace = true, features = ["derive"] } +codex-analytics = { workspace = true } codex-api = { workspace = true } codex-app-server-protocol = { workspace = true } codex-apply-patch = { workspace = true } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 1e874b4d66..2684cc90bf 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -12,10 +12,6 @@ use crate::SandboxState; use crate::agent::AgentControl; use crate::agent::AgentStatus; use crate::agent::agent_status_from_event; -use crate::analytics_client::AnalyticsEventsClient; -use crate::analytics_client::AppInvocation; -use crate::analytics_client::InvocationType; -use crate::analytics_client::build_track_events_context; use crate::apps::render_apps_section; use crate::auth_env_telemetry::collect_auth_env_telemetry; use crate::commit_attribution::commit_message_trailer_instruction; @@ -349,6 +345,10 @@ use crate::turn_timing::record_turn_ttft_metric; use crate::unified_exec::UnifiedExecProcessManager; use crate::util::backoff; use crate::windows_sandbox::WindowsSandboxLevelExt; +use codex_analytics::AnalyticsEventsClient; +use codex_analytics::AppInvocation; +use codex_analytics::InvocationType; +use codex_analytics::build_track_events_context; use codex_async_utils::OrCancelExt; use codex_git_utils::get_git_repo_root; use codex_otel::SessionTelemetry; @@ -1844,8 +1844,9 @@ impl Session { shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&config), Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, ), hooks, rollout: Mutex::new(rollout_recorder), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 34399a000c..82371f5be7 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2668,8 +2668,9 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { shell_zsh_path: None, main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&config), Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, ), hooks: Hooks::new(HooksConfig { legacy_notify_argv: config.notify.clone(), @@ -3503,8 +3504,9 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( shell_zsh_path: None, main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&config), Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, ), hooks: Hooks::new(HooksConfig { legacy_notify_argv: config.notify.clone(), diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 71f4328aed..b4426c9cdf 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -5,7 +5,6 @@ // the TUI or the tracing stack). #![deny(clippy::print_stdout, clippy::print_stderr)] -mod analytics_client; pub mod api_bridge; mod apply_patch; mod apps; @@ -108,9 +107,9 @@ pub type NewConversation = NewThread; #[deprecated(note = "use CodexThread")] pub type CodexConversation = CodexThread; // Re-export common auth types for workspace consumers -pub use analytics_client::AnalyticsEventsClient; pub use auth::AuthManager; pub use auth::CodexAuth; +pub use codex_analytics::AnalyticsEventsClient; mod default_client_forwarding; /// Default Codex HTTP client headers and reqwest construction. diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 18a4843578..d31696db5c 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -8,9 +8,6 @@ use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; use tracing::error; -use crate::analytics_client::AppInvocation; -use crate::analytics_client::InvocationType; -use crate::analytics_client::build_track_events_context; use crate::arc_monitor::ArcMonitorOutcome; use crate::arc_monitor::monitor_action; use crate::codex::Session; @@ -32,6 +29,9 @@ use crate::protocol::McpInvocation; use crate::protocol::McpToolCallBeginEvent; use crate::protocol::McpToolCallEndEvent; use crate::state_db; +use codex_analytics::AppInvocation; +use codex_analytics::InvocationType; +use codex_analytics::build_track_events_context; use codex_features::Feature; use codex_protocol::mcp::CallToolResult; use codex_protocol::openai_models::InputModality; diff --git a/codex-rs/core/src/skills/injection.rs b/codex-rs/core/src/skills/injection.rs index b83be2322c..dac05f8368 100644 --- a/codex-rs/core/src/skills/injection.rs +++ b/codex-rs/core/src/skills/injection.rs @@ -2,14 +2,14 @@ use std::collections::HashMap; use std::collections::HashSet; use std::path::PathBuf; -use crate::analytics_client::AnalyticsEventsClient; -use crate::analytics_client::InvocationType; -use crate::analytics_client::SkillInvocation; -use crate::analytics_client::TrackEventsContext; use crate::instructions::SkillInstructions; use crate::mention_syntax::TOOL_MENTION_SIGIL; use crate::mentions::build_skill_name_counts; use crate::skills::SkillMetadata; +use codex_analytics::AnalyticsEventsClient; +use codex_analytics::InvocationType; +use codex_analytics::SkillInvocation; +use codex_analytics::TrackEventsContext; use codex_otel::SessionTelemetry; use codex_protocol::models::ResponseItem; use codex_protocol::user_input::UserInput; diff --git a/codex-rs/core/src/skills/invocation_utils.rs b/codex-rs/core/src/skills/invocation_utils.rs index 122158bda7..084670fa86 100644 --- a/codex-rs/core/src/skills/invocation_utils.rs +++ b/codex-rs/core/src/skills/invocation_utils.rs @@ -2,13 +2,13 @@ use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; -use crate::analytics_client::InvocationType; -use crate::analytics_client::SkillInvocation; -use crate::analytics_client::build_track_events_context; use crate::codex::Session; use crate::codex::TurnContext; use crate::skills::SkillLoadOutcome; use crate::skills::SkillMetadata; +use codex_analytics::InvocationType; +use codex_analytics::SkillInvocation; +use codex_analytics::build_track_events_context; pub(crate) fn build_implicit_skill_path_indexes( skills: Vec, diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index f12f7ef971..ad980db1a7 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -4,7 +4,6 @@ use std::sync::Arc; use crate::AuthManager; use crate::RolloutRecorder; use crate::agent::AgentControl; -use crate::analytics_client::AnalyticsEventsClient; use crate::client::ModelClient; use crate::config::StartedNetworkProxy; use crate::exec_policy::ExecPolicyManager; @@ -20,6 +19,7 @@ use crate::tools::network_approval::NetworkApprovalService; use crate::tools::runtimes::ExecveSessionApproval; use crate::tools::sandboxing::ApprovalStore; use crate::unified_exec::UnifiedExecProcessManager; +use codex_analytics::AnalyticsEventsClient; use codex_exec_server::Environment; use codex_hooks::Hooks; use codex_otel::SessionTelemetry; From 6566ab7e021b7138672757538407ebd070f2a4ae Mon Sep 17 00:00:00 2001 From: evawong-oai Date: Wed, 25 Mar 2026 11:40:43 -0700 Subject: [PATCH 18/19] Clarify codex_home base for MDM path resolution (#15707) ## Summary Add the follow up code comment Michael asked for at the MDM `managed_config_from_mdm` - a follow up from https://github.com/openai/codex/pull/15351. ## Validation 1. `cargo fmt --all --check` 2. `cargo test -p codex-core managed_preferences_expand_home_directory_in_workspace_write_roots -- --nocapture` 3. `cargo test -p codex-core write_value_succeeds_when_managed_preferences_expand_home_directory_paths -- --nocapture` 4. `./tools/argument-comment-lint/run-prebuilt-linter.sh -p codex-core` --- codex-rs/core/src/config_loader/mod.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index 767ffe635d..dab130dad1 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -285,6 +285,11 @@ pub async fn load_config_layers_state( )); } if let Some(config) = managed_config_from_mdm { + // As a general rule, config from MDM should _not_ include relative + // paths, starting with `./`, but a path starting with `~/` _is_ a + // supported use case. Because resolve_relative_paths_in_config_toml() + // relies on AbsolutePathBufGuard to resolve `~/`, we must supply a + // value for base_dir, so codex_home is as good a value as any. let managed_config = resolve_relative_paths_in_config_toml(config.managed_config, codex_home)?; layers.push(ConfigLayerEntry::new_with_raw_toml( From 79359fb5e78d470c69a7b6e82291608ed685c3bb Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Wed, 25 Mar 2026 15:50:42 -0300 Subject: [PATCH 19/19] fix(tui_app_server): fix remote subagent switching and agent names (#15513) ## TL;DR This PR changes the `tui_app_server` _path_ in the following ways: - add missing feature to show agent names (shows only UUIDs today) - add `Cmd/Alt+Arrows` navigation between agent conversations ## Problem When the TUI connects to a remote app server, collab agent tool-call items (spawn, wait, delegate, etc.) render thread UUIDs instead of human-readable agent names because the `ChatWidget` never receives nickname/role metadata for receiver threads. Separately, keyboard next/previous agent navigation silently does nothing when the local `AgentNavigationState` cache has not yet been populated with subagent threads that the remote server already knows about. Both issues share a root cause: in the remote (app-server) code path the TUI never proactively fetches thread metadata. In the local code path this metadata arrives naturally via spawn events the TUI itself orchestrates, but in the remote path those events were processed by a different client and the TUI only sees the resulting collab tool-call notifications. ## Mental model Collab agent tool-call notifications reference receiver threads by id, but carry no nickname or role. The TUI needs that metadata in two places: 1. **Rendering** -- `ChatWidget` converts `CollabAgentToolCall` items into history cells. Without metadata, agent status lines show raw UUIDs. 2. **Navigation** -- `AgentNavigationState` tracks known threads for the `/agent` picker and keyboard cycling. Without entries for remote subagents, next/previous has nowhere to go. This change closes the gap with two complementary strategies: - **Eager hydration**: when any notification carries `receiver_thread_ids`, the TUI fetches metadata (`thread/read`) for threads it has not yet cached before the notification is rendered. - **Backfill on thread switch**: when the user resumes, forks, or starts a new app-server thread, the TUI fetches the full `thread/loaded/list`, walks the parent-child spawn tree, and registers every descendant subagent in both the navigation cache and the `ChatWidget` metadata map. A new `collab_agent_metadata` side-table in `ChatWidget` stores nickname/role keyed by `ThreadId`, kept in sync by `App` whenever it calls `upsert_agent_picker_thread`. The `replace_chat_widget` helper re-seeds this map from `AgentNavigationState` so that thread switches (which reconstruct the widget) do not lose previously discovered metadata. ## Non-goals - This change does not alter the local (non-app-server) collab code path. That path already receives metadata via spawn events and is unaffected. - No new protocol messages are introduced. The change uses existing `thread/read` and `thread/loaded/list` RPCs. - No changes to how `AgentNavigationState` orders or cycles through threads. The traversal logic is unchanged; only the population of entries is extended. ## Tradeoffs - **Extra RPCs on notification path**: `hydrate_collab_agent_metadata_for_notification` issues a `thread/read` for each unknown receiver thread before the notification is forwarded to rendering. This adds latency on the notification path but only fires once per thread (the result is cached). The alternative -- rendering first and backfilling names later -- would cause visible flicker as UUIDs are replaced with names. - **Backfill fetches all loaded threads**: `backfill_loaded_subagent_threads` fetches the full loaded-thread list and walks the spawn tree even when the user may only care about one subagent. This is simple and correct but O(loaded_threads) per thread switch. For typical session sizes this is negligible; it could become a concern for sessions with hundreds of subagents. - **Metadata duplication**: agent nickname/role is now stored in both `AgentNavigationState` (for picker/label) and `ChatWidget::collab_agent_metadata` (for rendering). The two are kept in sync through `upsert_agent_picker_thread` and `replace_chat_widget`, but there is no compile-time enforcement of this coupling. ## Architecture ### New module: `app::loaded_threads` Pure function `find_loaded_subagent_threads_for_primary` that takes a flat list of `Thread` objects and a primary thread id, then walks the `SessionSource::SubAgent` parent-child edges to collect all transitive descendants. Returns a sorted vec of `LoadedSubagentThread` (thread_id + nickname + role). No async, no side effects -- designed for unit testing. ### New methods on `App` | Method | Purpose | |--------|---------| | `collab_receiver_thread_ids` | Extracts `receiver_thread_ids` from `ItemStarted` / `ItemCompleted` collab notifications | | `hydrate_collab_agent_metadata_for_notification` | Fetches and caches metadata for unknown receiver threads before a notification is rendered | | `backfill_loaded_subagent_threads` | Bulk-fetches all loaded threads and registers descendants of the primary thread | | `adjacent_thread_id_with_backfill` | Attempts navigation, falls back to backfill if the cache has no adjacent entry | | `replace_chat_widget` | Replaces the widget and re-seeds its metadata map from `AgentNavigationState` | ### New state in `ChatWidget` `collab_agent_metadata: HashMap` -- a lookup table that rendering functions consult to attach human-readable names to collab tool-call items. Populated externally by `App` via `set_collab_agent_metadata`. ### New method on `AppServerSession` `thread_loaded_list` -- thin wrapper around `ClientRequest::ThreadLoadedList`. ## Observability - `tracing::warn` on invalid thread ids during hydration and backfill. - `tracing::warn` on failed `thread/read` or `thread/loaded/list` RPCs (with thread id and error). - No new metrics or feature flags. ## Tests - **`loaded_threads::tests::finds_loaded_subagent_tree_for_primary_thread`** -- unit test for the spawn-tree walk: verifies child and grandchild are included, unrelated threads are excluded, and metadata is carried through. - **`app::tests::replace_chat_widget_reseeds_collab_agent_metadata_for_replay`** -- integration test that creates a `ChatWidget`, replaces it via `replace_chat_widget`, replays a collab wait notification, and asserts the rendered history cell contains the agent name rather than a UUID. - **Updated snapshot** `app_server_collab_wait_items_render_history` -- the existing collab wait rendering test now sets metadata before sending notifications, so the snapshot shows `Robie [explorer]` / `Ada [reviewer]` instead of raw thread ids. --------- Co-authored-by: Eric Traut --- codex-rs/tui_app_server/src/app.rs | 331 +++++++++++++++++- .../tui_app_server/src/app/loaded_threads.rs | 208 +++++++++++ .../tui_app_server/src/app_server_session.rs | 18 + codex-rs/tui_app_server/src/chatwidget.rs | 128 ++++++- ...rver_collab_wait_items_render_history.snap | 8 +- .../tui_app_server/src/chatwidget/tests.rs | 11 + 6 files changed, 672 insertions(+), 32 deletions(-) create mode 100644 codex-rs/tui_app_server/src/app/loaded_threads.rs diff --git a/codex-rs/tui_app_server/src/app.rs b/codex-rs/tui_app_server/src/app.rs index e00d5ebc05..3ec29039de 100644 --- a/codex-rs/tui_app_server/src/app.rs +++ b/codex-rs/tui_app_server/src/app.rs @@ -69,6 +69,8 @@ use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::SkillsListResponse; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadLoadedListParams; use codex_app_server_protocol::ThreadRollbackResponse; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnError as AppServerTurnError; @@ -143,11 +145,13 @@ use uuid::Uuid; mod agent_navigation; mod app_server_adapter; mod app_server_requests; +mod loaded_threads; mod pending_interactive_replay; use self::agent_navigation::AgentNavigationDirection; use self::agent_navigation::AgentNavigationState; use self::app_server_requests::PendingAppServerRequests; +use self::loaded_threads::find_loaded_subagent_threads_for_primary; use self::pending_interactive_replay::PendingInteractiveReplayState; const EXTERNAL_EDITOR_HINT: &str = "Save and close external editor to continue."; @@ -200,6 +204,30 @@ fn command_execution_decision_to_review_decision( } } +/// Extracts `receiver_thread_ids` from collab agent tool-call notifications. +/// +/// Only `ItemStarted` and `ItemCompleted` notifications with a `CollabAgentToolCall` item carry +/// receiver thread ids. All other notification variants return `None`. +fn collab_receiver_thread_ids(notification: &ServerNotification) -> Option<&[String]> { + match notification { + ServerNotification::ItemStarted(notification) => match ¬ification.item { + ThreadItem::CollabAgentToolCall { + receiver_thread_ids, + .. + } => Some(receiver_thread_ids), + _ => None, + }, + ServerNotification::ItemCompleted(notification) => match ¬ification.item { + ThreadItem::CollabAgentToolCall { + receiver_thread_ids, + .. + } => Some(receiver_thread_ids), + _ => None, + }, + _ => None, + } +} + fn convert_via_json(value: T) -> Option where T: serde::Serialize, @@ -951,6 +979,7 @@ pub(crate) struct App { active_thread_id: Option, active_thread_rx: Option>, primary_thread_id: Option, + last_subagent_backfill_attempt: Option, primary_session_configured: Option, pending_primary_events: VecDeque, pending_app_server_requests: PendingAppServerRequests, @@ -2265,6 +2294,67 @@ impl App { Ok(()) } + /// Eagerly fetches nickname and role for receiver threads referenced by a collab notification. + /// + /// This runs on every buffered thread notification before it reaches rendering. For each + /// receiver thread id that the navigation cache does not yet have metadata for, it issues a + /// `thread/read` RPC and registers the result in both `AgentNavigationState` and the + /// `ChatWidget` metadata map. Threads that already have a nickname or role cached are skipped, + /// so the cost is at most one RPC per thread over the lifetime of a session. + /// + /// Failures are logged and silently ignored -- the worst outcome is that a rendered item shows + /// a thread id instead of a human-readable name, which is the same behavior the TUI had before + /// this change. + async fn hydrate_collab_agent_metadata_for_notification( + &mut self, + app_server: &mut AppServerSession, + notification: &ServerNotification, + ) { + let Some(receiver_thread_ids) = collab_receiver_thread_ids(notification) else { + return; + }; + + for receiver_thread_id in receiver_thread_ids { + let Ok(thread_id) = ThreadId::from_string(receiver_thread_id) else { + tracing::warn!( + thread_id = receiver_thread_id, + "ignoring collab receiver with invalid thread id during metadata hydration" + ); + continue; + }; + + if self + .agent_navigation + .get(&thread_id) + .is_some_and(|entry| entry.agent_nickname.is_some() || entry.agent_role.is_some()) + { + continue; + } + + match app_server + .thread_read(thread_id, /*include_turns*/ false) + .await + { + Ok(thread) => { + self.ensure_thread_channel(thread_id); + self.upsert_agent_picker_thread( + thread_id, + thread.agent_nickname, + thread.agent_role, + /*is_closed*/ false, + ); + } + Err(err) => { + tracing::warn!( + thread_id = %thread_id, + error = %err, + "failed to hydrate collab receiver thread metadata" + ); + } + } + } + } + async fn infer_session_for_thread_notification( &mut self, thread_id: ThreadId, @@ -2602,6 +2692,11 @@ impl App { agent_role: Option, is_closed: bool, ) { + self.chat_widget.set_collab_agent_metadata( + thread_id, + agent_nickname.clone(), + agent_role.clone(), + ); self.agent_navigation .upsert(thread_id, agent_nickname, agent_role, is_closed); self.sync_active_agent_label(); @@ -2616,6 +2711,24 @@ impl App { self.sync_active_agent_label(); } + /// Replaces the chat widget and re-seeds the new widget's collab metadata from the navigation + /// cache. + /// + /// Thread switches reconstruct the `ChatWidget`, which loses the `collab_agent_metadata` map. + /// This helper copies every known nickname/role from `AgentNavigationState` into the + /// replacement widget so that replayed collab items render agent names immediately. + fn replace_chat_widget(&mut self, mut chat_widget: ChatWidget) { + for (thread_id, entry) in self.agent_navigation.ordered_threads() { + chat_widget.set_collab_agent_metadata( + thread_id, + entry.agent_nickname.clone(), + entry.agent_role.clone(), + ); + } + self.chat_widget = chat_widget; + self.sync_active_agent_label(); + } + async fn select_agent_thread( &mut self, tui: &mut tui::Tui, @@ -2661,8 +2774,7 @@ impl App { self.active_thread_rx = Some(receiver); let init = self.chatwidget_init_for_forked_or_resumed_thread(tui, self.config.clone()); - self.chat_widget = ChatWidget::new_with_app_event(init); - self.sync_active_agent_label(); + self.replace_chat_widget(ChatWidget::new_with_app_event(init)); self.reset_for_thread_switch(tui)?; self.replay_thread_snapshot(snapshot, !is_replay_only); @@ -2697,6 +2809,7 @@ impl App { self.active_thread_id = None; self.active_thread_rx = None; self.primary_thread_id = None; + self.last_subagent_backfill_attempt = None; self.primary_session_configured = None; self.pending_primary_events.clear(); self.pending_app_server_requests.clear(); @@ -2732,7 +2845,7 @@ impl App { match app_server.start_thread(&config).await { Ok(started) => { if let Err(err) = self - .replace_chat_widget_with_app_server_thread(tui, started) + .replace_chat_widget_with_app_server_thread(tui, app_server, started) .await { self.chat_widget.add_error_message(format!( @@ -2760,13 +2873,119 @@ impl App { async fn replace_chat_widget_with_app_server_thread( &mut self, tui: &mut tui::Tui, + app_server: &mut AppServerSession, started: AppServerStartedThread, ) -> Result<()> { - let init = self.chatwidget_init_for_forked_or_resumed_thread(tui, self.config.clone()); - self.chat_widget = ChatWidget::new_with_app_event(init); self.reset_thread_event_state(); + let init = self.chatwidget_init_for_forked_or_resumed_thread(tui, self.config.clone()); + self.replace_chat_widget(ChatWidget::new_with_app_event(init)); self.enqueue_primary_thread_session(started.session, started.turns) + .await?; + self.backfill_loaded_subagent_threads(app_server).await; + Ok(()) + } + + /// Fetches all loaded threads from the app server and registers descendants of the primary + /// thread in the navigation cache and chat widget metadata. + /// + /// Called after `replace_chat_widget_with_app_server_thread` during resume, fork, and new + /// thread creation so that the `/agent` picker and keyboard navigation are pre-populated even + /// if the TUI did not witness the original spawn events. + /// + /// The loaded-thread list is fetched in full (no pagination) and the spawn tree is walked + /// by `find_loaded_subagent_threads_for_primary`. Each discovered subagent is registered via + /// `upsert_agent_picker_thread`, which writes to both `AgentNavigationState` and the + /// `ChatWidget` metadata map. + async fn backfill_loaded_subagent_threads( + &mut self, + app_server: &mut AppServerSession, + ) -> bool { + let Some(primary_thread_id) = self.primary_thread_id else { + return false; + }; + + let loaded_thread_ids = match app_server + .thread_loaded_list(ThreadLoadedListParams { + cursor: None, + limit: None, + }) .await + { + Ok(response) => response.data, + Err(err) => { + tracing::warn!(%err, "failed to list loaded threads for subagent backfill"); + return false; + } + }; + + let mut threads = Vec::new(); + let mut had_read_error = false; + for thread_id in loaded_thread_ids { + let Ok(thread_id) = ThreadId::from_string(&thread_id) else { + tracing::warn!("ignoring loaded thread with invalid id during subagent backfill"); + continue; + }; + + if thread_id == primary_thread_id { + continue; + } + + match app_server + .thread_read(thread_id, /*include_turns*/ false) + .await + { + Ok(thread) => threads.push(thread), + Err(err) => { + had_read_error = true; + tracing::warn!(thread_id = %thread_id, %err, "failed to read loaded thread"); + } + } + } + + for thread in find_loaded_subagent_threads_for_primary(threads, primary_thread_id) { + self.ensure_thread_channel(thread.thread_id); + self.upsert_agent_picker_thread( + thread.thread_id, + thread.agent_nickname, + thread.agent_role, + /*is_closed*/ false, + ); + } + + !had_read_error + } + + /// Returns the adjacent thread id for keyboard navigation, backfilling from the server if the + /// local cache has no neighbor. + /// + /// Tries the fast path first: ask `AgentNavigationState` directly. If it returns `None` (no + /// adjacent entry exists, typically because the cache was never populated with remote + /// subagents), performs a full `backfill_loaded_subagent_threads` and retries. This ensures the + /// first next/previous keypress in a resumed remote session discovers subagents on demand + /// without requiring the user to wait for a proactive fetch. + async fn adjacent_thread_id_with_backfill( + &mut self, + app_server: &mut AppServerSession, + direction: AgentNavigationDirection, + ) -> Option { + let current_thread = self.current_displayed_thread_id(); + if let Some(thread_id) = self + .agent_navigation + .adjacent_thread_id(current_thread, direction) + { + return Some(thread_id); + } + + let primary_thread_id = self.primary_thread_id?; + if self.last_subagent_backfill_attempt == Some(primary_thread_id) { + return None; + } + + if self.backfill_loaded_subagent_threads(app_server).await { + self.last_subagent_backfill_attempt = Some(primary_thread_id); + } + self.agent_navigation + .adjacent_thread_id(self.current_displayed_thread_id(), direction) } fn fresh_session_config(&self) -> Config { @@ -3116,6 +3335,7 @@ impl App { active_thread_id: None, active_thread_rx: None, primary_thread_id: None, + last_subagent_backfill_attempt: None, primary_session_configured: None, pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), @@ -3428,7 +3648,9 @@ impl App { self.file_search .update_search_dir(self.config.cwd.to_path_buf()); match self - .replace_chat_widget_with_app_server_thread(tui, resumed) + .replace_chat_widget_with_app_server_thread( + tui, app_server, resumed, + ) .await { Ok(()) => { @@ -3488,7 +3710,7 @@ impl App { Ok(forked) => { self.shutdown_current_thread(app_server).await; match self - .replace_chat_widget_with_app_server_thread(tui, forked) + .replace_chat_widget_with_app_server_thread(tui, app_server, forked) .await { Ok(()) => { @@ -4962,6 +5184,11 @@ impl App { // thread, so unrelated shutdowns cannot consume this marker. self.pending_shutdown_exit_thread_id = None; } + if let ThreadBufferedEvent::Notification(notification) = &event { + self.hydrate_collab_agent_metadata_for_notification(app_server, notification) + .await; + } + self.handle_thread_event_now(event); if self.backtrack_render_pending { tui.frame_requester().schedule_frame(); @@ -5117,10 +5344,10 @@ impl App { && self.chat_widget.composer_text_with_pending().is_empty() && previous_agent_shortcut_matches(key_event, allow_agent_word_motion_fallback) { - if let Some(thread_id) = self.agent_navigation.adjacent_thread_id( - self.current_displayed_thread_id(), - AgentNavigationDirection::Previous, - ) { + if let Some(thread_id) = self + .adjacent_thread_id_with_backfill(app_server, AgentNavigationDirection::Previous) + .await + { let _ = self.select_agent_thread(tui, app_server, thread_id).await; } return; @@ -5132,10 +5359,10 @@ impl App { && self.chat_widget.composer_text_with_pending().is_empty() && next_agent_shortcut_matches(key_event, allow_agent_word_motion_fallback) { - if let Some(thread_id) = self.agent_navigation.adjacent_thread_id( - self.current_displayed_thread_id(), - AgentNavigationDirection::Next, - ) { + if let Some(thread_id) = self + .adjacent_thread_id_with_backfill(app_server, AgentNavigationDirection::Next) + .await + { let _ = self.select_agent_thread(tui, app_server, thread_id).await; } return; @@ -7864,6 +8091,7 @@ guardian_approval = true active_thread_id: None, active_thread_rx: None, primary_thread_id: None, + last_subagent_backfill_attempt: None, primary_session_configured: None, pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), @@ -7915,6 +8143,7 @@ guardian_approval = true active_thread_id: None, active_thread_rx: None, primary_thread_id: None, + last_subagent_backfill_attempt: None, primary_session_configured: None, pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), @@ -9002,6 +9231,78 @@ guardian_approval = true ); } + #[tokio::test] + async fn replace_chat_widget_reseeds_collab_agent_metadata_for_replay() { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let receiver_thread_id = + ThreadId::from_string("019cff70-2599-75e2-af72-b958ce5dc1cc").expect("valid thread"); + app.agent_navigation.upsert( + receiver_thread_id, + Some("Robie".to_string()), + Some("explorer".to_string()), + false, + ); + + let replacement = ChatWidget::new_with_app_event(ChatWidgetInit { + config: app.config.clone(), + frame_requester: crate::tui::FrameRequester::test_dummy(), + app_event_tx: app.app_event_tx.clone(), + initial_user_message: None, + enhanced_keys_supported: app.enhanced_keys_supported, + has_chatgpt_account: app.chat_widget.has_chatgpt_account(), + model_catalog: app.model_catalog.clone(), + feedback: app.feedback.clone(), + is_first_run: false, + feedback_audience: app.feedback_audience, + status_account_display: app.chat_widget.status_account_display().cloned(), + initial_plan_type: app.chat_widget.current_plan_type(), + model: Some(app.chat_widget.current_model().to_string()), + startup_tooltip_override: None, + status_line_invalid_items_warned: app.status_line_invalid_items_warned.clone(), + session_telemetry: app.session_telemetry.clone(), + }); + app.replace_chat_widget(replacement); + + app.replay_thread_snapshot( + ThreadEventSnapshot { + session: None, + turns: Vec::new(), + events: vec![ThreadBufferedEvent::Notification( + ServerNotification::ItemStarted(codex_app_server_protocol::ItemStartedNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item: ThreadItem::CollabAgentToolCall { + id: "wait-1".to_string(), + tool: codex_app_server_protocol::CollabAgentTool::Wait, + status: codex_app_server_protocol::CollabAgentToolCallStatus::InProgress, + sender_thread_id: ThreadId::new().to_string(), + receiver_thread_ids: vec![receiver_thread_id.to_string()], + prompt: None, + model: None, + reasoning_effort: None, + agents_states: HashMap::new(), + }, + }), + )], + input_state: None, + }, + false, + ); + + let mut saw_named_wait = false; + while let Ok(event) = app_event_rx.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = event { + let transcript = lines_to_single_string(&cell.transcript_lines(80)); + saw_named_wait |= transcript.contains("Robie [explorer]"); + } + } + + assert!( + saw_named_wait, + "expected replayed wait item to keep agent name" + ); + } + #[tokio::test] async fn refreshed_snapshot_session_persists_resumed_turns() { let mut app = make_test_app().await; diff --git a/codex-rs/tui_app_server/src/app/loaded_threads.rs b/codex-rs/tui_app_server/src/app/loaded_threads.rs new file mode 100644 index 0000000000..c6ac09890d --- /dev/null +++ b/codex-rs/tui_app_server/src/app/loaded_threads.rs @@ -0,0 +1,208 @@ +//! Discovers subagent threads that belong to a primary thread by walking spawn-tree edges. +//! +//! When the TUI resumes or switches to an existing thread, it needs to populate +//! `AgentNavigationState` and `ChatWidget` metadata for every subagent that was spawned during +//! that thread's lifetime. The app server exposes a flat list of currently loaded threads via +//! `thread/loaded/list`, but the TUI must figure out which of those are descendants of the +//! primary thread. +//! +//! This module provides the pure, synchronous tree-walk that turns that flat list into the filtered +//! set of descendants. It intentionally has no async, no I/O, and no side effects so it can be +//! unit-tested in isolation. +//! +//! The walk starts from `primary_thread_id` and repeatedly follows +//! `SessionSource::SubAgent(ThreadSpawn { parent_thread_id, .. })` edges until no new children are +//! found. The primary thread itself is never included in the output. + +use codex_app_server_protocol::SessionSource; +use codex_app_server_protocol::Thread; +use codex_protocol::ThreadId; +use codex_protocol::protocol::SubAgentSource; +use std::collections::HashMap; +use std::collections::HashSet; + +/// A subagent thread discovered by the spawn-tree walk, carrying just enough metadata for the +/// TUI to register it in the navigation cache and rendering metadata map. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct LoadedSubagentThread { + pub(crate) thread_id: ThreadId, + pub(crate) agent_nickname: Option, + pub(crate) agent_role: Option, +} + +/// Walks the spawn tree rooted at `primary_thread_id` and returns every descendant subagent. +/// +/// The walk is breadth-first over `SessionSource::SubAgent(ThreadSpawn { parent_thread_id })` edges. +/// Threads whose `source` is not a `ThreadSpawn`, or whose `parent_thread_id` does not chain back +/// to `primary_thread_id`, are excluded. The primary thread itself is never included. +/// +/// Results are sorted by stringified thread id for deterministic output in tests and in the +/// navigation cache. Callers should not rely on this ordering for anything semantic; it exists +/// purely to make snapshot assertions stable. +/// +/// If two threads claim the same parent, both are included. Cycles in the parent chain are not +/// possible because `ThreadId`s are server-assigned UUIDs and the server enforces acyclicity, but +/// the `included` set guards against re-visiting regardless. +pub(crate) fn find_loaded_subagent_threads_for_primary( + threads: Vec, + primary_thread_id: ThreadId, +) -> Vec { + let mut threads_by_id = HashMap::new(); + for thread in threads { + let Ok(thread_id) = ThreadId::from_string(&thread.id) else { + continue; + }; + threads_by_id.insert(thread_id, thread); + } + + let mut included = HashSet::new(); + let mut pending = vec![primary_thread_id]; + while let Some(parent_thread_id) = pending.pop() { + for (thread_id, thread) in &threads_by_id { + if included.contains(thread_id) { + continue; + } + + let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: source_parent_thread_id, + .. + }) = &thread.source + else { + continue; + }; + + if *source_parent_thread_id != parent_thread_id { + continue; + } + + included.insert(*thread_id); + pending.push(*thread_id); + } + } + + let mut loaded_threads: Vec = included + .into_iter() + .filter_map(|thread_id| { + threads_by_id + .remove(&thread_id) + .map(|thread| LoadedSubagentThread { + thread_id, + agent_nickname: thread.agent_nickname, + agent_role: thread.agent_role, + }) + }) + .collect(); + loaded_threads.sort_by_key(|thread| thread.thread_id.to_string()); + loaded_threads +} + +#[cfg(test)] +mod tests { + use super::LoadedSubagentThread; + use super::find_loaded_subagent_threads_for_primary; + use codex_app_server_protocol::SessionSource; + use codex_app_server_protocol::Thread; + use codex_app_server_protocol::ThreadStatus; + use codex_protocol::ThreadId; + use codex_protocol::protocol::SubAgentSource; + use pretty_assertions::assert_eq; + use std::path::PathBuf; + + fn test_thread(thread_id: ThreadId, source: SessionSource) -> Thread { + Thread { + id: thread_id.to_string(), + preview: String::new(), + ephemeral: false, + model_provider: "openai".to_string(), + created_at: 0, + updated_at: 0, + status: ThreadStatus::Idle, + path: None, + cwd: PathBuf::from("/tmp"), + cli_version: "0.0.0".to_string(), + source, + agent_nickname: None, + agent_role: None, + git_info: None, + name: None, + turns: Vec::new(), + } + } + + #[test] + fn finds_loaded_subagent_tree_for_primary_thread() { + let primary_thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000001").expect("valid thread"); + let child_thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000002").expect("valid thread"); + let grandchild_thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000003").expect("valid thread"); + let unrelated_parent_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000004").expect("valid thread"); + let unrelated_child_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000005").expect("valid thread"); + + let mut child = test_thread( + child_thread_id, + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: primary_thread_id, + depth: 1, + agent_path: None, + agent_nickname: Some("Scout".to_string()), + agent_role: Some("explorer".to_string()), + }), + ); + child.agent_nickname = Some("Scout".to_string()); + child.agent_role = Some("explorer".to_string()); + + let mut grandchild = test_thread( + grandchild_thread_id, + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: child_thread_id, + depth: 2, + agent_path: None, + agent_nickname: Some("Atlas".to_string()), + agent_role: Some("worker".to_string()), + }), + ); + grandchild.agent_nickname = Some("Atlas".to_string()); + grandchild.agent_role = Some("worker".to_string()); + + let unrelated_child = test_thread( + unrelated_child_id, + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: unrelated_parent_id, + depth: 1, + agent_path: None, + agent_nickname: Some("Other".to_string()), + agent_role: Some("researcher".to_string()), + }), + ); + + let loaded = find_loaded_subagent_threads_for_primary( + vec![ + test_thread(primary_thread_id, SessionSource::Cli), + child, + grandchild, + unrelated_child, + ], + primary_thread_id, + ); + + assert_eq!( + loaded, + vec![ + LoadedSubagentThread { + thread_id: child_thread_id, + agent_nickname: Some("Scout".to_string()), + agent_role: Some("explorer".to_string()), + }, + LoadedSubagentThread { + thread_id: grandchild_thread_id, + agent_nickname: Some("Atlas".to_string()), + agent_role: Some("worker".to_string()), + }, + ] + ); + } +} diff --git a/codex-rs/tui_app_server/src/app_server_session.rs b/codex-rs/tui_app_server/src/app_server_session.rs index ecdea588ee..bd34404913 100644 --- a/codex-rs/tui_app_server/src/app_server_session.rs +++ b/codex-rs/tui_app_server/src/app_server_session.rs @@ -27,6 +27,8 @@ use codex_app_server_protocol::ThreadForkParams; use codex_app_server_protocol::ThreadForkResponse; use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadListResponse; +use codex_app_server_protocol::ThreadLoadedListParams; +use codex_app_server_protocol::ThreadLoadedListResponse; use codex_app_server_protocol::ThreadReadParams; use codex_app_server_protocol::ThreadReadResponse; use codex_app_server_protocol::ThreadRealtimeAppendAudioParams; @@ -354,6 +356,22 @@ impl AppServerSession { .wrap_err("thread/list failed during TUI session lookup") } + /// Lists thread ids that the app server currently holds in memory. + /// + /// Used by `App::backfill_loaded_subagent_threads` to discover subagent threads that were + /// spawned before the TUI connected. The caller then fetches full metadata per thread via + /// `thread_read` and walks the spawn tree. + pub(crate) async fn thread_loaded_list( + &mut self, + params: ThreadLoadedListParams, + ) -> Result { + let request_id = self.next_request_id(); + self.client + .request_typed(ClientRequest::ThreadLoadedList { request_id, params }) + .await + .wrap_err("failed to list loaded threads from app server") + } + pub(crate) async fn thread_read( &mut self, thread_id: ThreadId, diff --git a/codex-rs/tui_app_server/src/chatwidget.rs b/codex-rs/tui_app_server/src/chatwidget.rs index f3c272d62b..0aea665b67 100644 --- a/codex-rs/tui_app_server/src/chatwidget.rs +++ b/codex-rs/tui_app_server/src/chatwidget.rs @@ -140,6 +140,7 @@ use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; use codex_protocol::protocol::BackgroundEventEvent; #[cfg(test)] use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo; +use codex_protocol::protocol::CollabAgentRef; #[cfg(test)] use codex_protocol::protocol::CollabAgentSpawnBeginEvent; use codex_protocol::protocol::CollabAgentStatusEntry; @@ -757,6 +758,7 @@ pub(crate) struct ChatWidget { // Latest completed user-visible Codex output that `/copy` should place on the clipboard. last_copyable_output: Option, running_commands: HashMap, + collab_agent_metadata: HashMap, pending_collab_spawn_requests: HashMap, suppressed_exec_calls: HashSet, skills_all: Vec, @@ -904,6 +906,18 @@ pub(crate) struct ChatWidget { last_non_retry_error: Option<(String, String)>, } +/// Cached nickname and role for a collab agent thread, used to attach human-readable labels to +/// rendered tool-call items. +/// +/// Populated externally by `App` via `set_collab_agent_metadata` and consulted by the +/// notification-to-core-event conversion helpers. Defaults to empty so that missing metadata +/// degrades to the previous behavior of showing raw thread ids. +#[derive(Clone, Debug, Default)] +struct CollabAgentMetadata { + agent_nickname: Option, + agent_role: Option, +} + #[cfg_attr(not(test), allow(dead_code))] enum CodexOpTarget { Direct(UnboundedSender), @@ -1379,9 +1393,12 @@ fn app_server_collab_state_to_core(state: &AppServerCollabAgentState) -> AgentSt } } +/// Converts app-server collab agent states into the core protocol representation, enriching each +/// entry with cached nickname and role metadata so rendered items show human-readable names. fn app_server_collab_agent_statuses_to_core( receiver_thread_ids: &[String], agents_states: &HashMap, + collab_agent_metadata: &HashMap, ) -> (Vec, HashMap) { let mut agent_statuses = Vec::new(); let mut statuses = HashMap::new(); @@ -1394,10 +1411,14 @@ fn app_server_collab_agent_statuses_to_core( continue; }; let status = app_server_collab_state_to_core(agent_state); + let metadata = collab_agent_metadata + .get(&thread_id) + .cloned() + .unwrap_or_default(); agent_statuses.push(CollabAgentStatusEntry { thread_id, - agent_nickname: None, - agent_role: None, + agent_nickname: metadata.agent_nickname, + agent_role: metadata.agent_role, status: status.clone(), }); statuses.insert(thread_id, status); @@ -1406,6 +1427,31 @@ fn app_server_collab_agent_statuses_to_core( (agent_statuses, statuses) } +/// Builds `CollabAgentRef` entries for every valid receiver thread, attaching cached metadata. +/// +/// Used when converting collab `Wait` tool-call items so the rendered waiting list shows agent +/// names instead of bare thread ids. +fn app_server_collab_receiver_agent_refs( + receiver_thread_ids: &[String], + collab_agent_metadata: &HashMap, +) -> Vec { + receiver_thread_ids + .iter() + .filter_map(|thread_id| { + let thread_id = app_server_collab_thread_id_to_core(thread_id)?; + let metadata = collab_agent_metadata + .get(&thread_id) + .cloned() + .unwrap_or_default(); + Some(CollabAgentRef { + thread_id, + agent_nickname: metadata.agent_nickname, + agent_role: metadata.agent_role, + }) + }) + .collect() +} + fn request_permissions_from_params( params: codex_app_server_protocol::PermissionsRequestApprovalParams, ) -> RequestPermissionsEvent { @@ -1489,6 +1535,35 @@ fn web_search_action_to_core( } impl ChatWidget { + /// Stores or overwrites the cached nickname and role for a collab agent thread. + /// + /// Called by `App::upsert_agent_picker_thread` and `App::replace_chat_widget` to keep the + /// rendering metadata in sync with the navigation cache. Must be called before any + /// notification referencing this thread is processed, otherwise the rendered item will fall + /// back to showing the raw thread id. + pub(crate) fn set_collab_agent_metadata( + &mut self, + thread_id: ThreadId, + agent_nickname: Option, + agent_role: Option, + ) { + self.collab_agent_metadata.insert( + thread_id, + CollabAgentMetadata { + agent_nickname, + agent_role, + }, + ); + } + + /// Returns the cached metadata for a thread, defaulting to empty if none has been registered. + fn collab_agent_metadata(&self, thread_id: ThreadId) -> CollabAgentMetadata { + self.collab_agent_metadata + .get(&thread_id) + .cloned() + .unwrap_or_default() + } + fn realtime_conversation_enabled(&self) -> bool { self.config.features.enabled(Feature::RealtimeConversation) && cfg!(not(target_os = "linux")) @@ -3443,6 +3518,8 @@ impl ChatWidget { let first_receiver = receiver_thread_ids .first() .and_then(|thread_id| app_server_collab_thread_id_to_core(thread_id)); + let first_receiver_metadata = + first_receiver.map(|thread_id| self.collab_agent_metadata(thread_id)); match tool { CollabAgentTool::SpawnAgent => { @@ -3473,8 +3550,12 @@ impl ChatWidget { call_id: id, sender_thread_id, new_thread_id: first_receiver, - new_agent_nickname: None, - new_agent_role: None, + new_agent_nickname: first_receiver_metadata + .as_ref() + .and_then(|metadata| metadata.agent_nickname.clone()), + new_agent_role: first_receiver_metadata + .as_ref() + .and_then(|metadata| metadata.agent_role.clone()), prompt: prompt.unwrap_or_default(), model: String::new(), reasoning_effort: ReasoningEffortConfig::Medium, @@ -3499,8 +3580,12 @@ impl ChatWidget { call_id: id, sender_thread_id, receiver_thread_id, - receiver_agent_nickname: None, - receiver_agent_role: None, + receiver_agent_nickname: first_receiver_metadata + .as_ref() + .and_then(|metadata| metadata.agent_nickname.clone()), + receiver_agent_role: first_receiver_metadata + .as_ref() + .and_then(|metadata| metadata.agent_role.clone()), prompt: prompt.unwrap_or_default(), status: receiver_thread_ids .iter() @@ -3521,8 +3606,12 @@ impl ChatWidget { call_id: id, sender_thread_id, receiver_thread_id, - receiver_agent_nickname: None, - receiver_agent_role: None, + receiver_agent_nickname: first_receiver_metadata + .as_ref() + .and_then(|metadata| metadata.agent_nickname.clone()), + receiver_agent_role: first_receiver_metadata + .as_ref() + .and_then(|metadata| metadata.agent_role.clone()), }, )); } else { @@ -3531,8 +3620,12 @@ impl ChatWidget { call_id: id, sender_thread_id, receiver_thread_id, - receiver_agent_nickname: None, - receiver_agent_role: None, + receiver_agent_nickname: first_receiver_metadata + .as_ref() + .and_then(|metadata| metadata.agent_nickname.clone()), + receiver_agent_role: first_receiver_metadata + .as_ref() + .and_then(|metadata| metadata.agent_role.clone()), status: receiver_thread_ids .iter() .find_map(|thread_id| agents_states.get(thread_id)) @@ -3556,7 +3649,10 @@ impl ChatWidget { app_server_collab_thread_id_to_core(thread_id) }) .collect(), - receiver_agents: Vec::new(), + receiver_agents: app_server_collab_receiver_agent_refs( + &receiver_thread_ids, + &self.collab_agent_metadata, + ), call_id: id, }, )); @@ -3564,6 +3660,7 @@ impl ChatWidget { let (agent_statuses, statuses) = app_server_collab_agent_statuses_to_core( &receiver_thread_ids, &agents_states, + &self.collab_agent_metadata, ); self.on_collab_event(multi_agents::waiting_end( codex_protocol::protocol::CollabWaitingEndEvent { @@ -3584,8 +3681,12 @@ impl ChatWidget { call_id: id, sender_thread_id, receiver_thread_id, - receiver_agent_nickname: None, - receiver_agent_role: None, + receiver_agent_nickname: first_receiver_metadata + .as_ref() + .and_then(|metadata| metadata.agent_nickname.clone()), + receiver_agent_role: first_receiver_metadata + .as_ref() + .and_then(|metadata| metadata.agent_role.clone()), status: receiver_thread_ids .iter() .find_map(|thread_id| agents_states.get(thread_id)) @@ -4319,6 +4420,7 @@ impl ChatWidget { plan_stream_controller: None, last_copyable_output: None, running_commands: HashMap::new(), + collab_agent_metadata: HashMap::new(), pending_collab_spawn_requests: HashMap::new(), suppressed_exec_calls: HashSet::new(), last_unified_wait: None, diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__app_server_collab_wait_items_render_history.snap b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__app_server_collab_wait_items_render_history.snap index a5b90d0e98..17c08589ad 100644 --- a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__app_server_collab_wait_items_render_history.snap +++ b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__app_server_collab_wait_items_render_history.snap @@ -3,10 +3,10 @@ source: tui_app_server/src/chatwidget/tests.rs expression: combined --- • Waiting for 2 agents - └ 019cff70-2599-75e2-af72-b958ce5dc1cc - 019cff70-2599-75e2-af72-b96db334332d + └ Robie [explorer] + Ada [reviewer] • Finished waiting - └ 019cff70-2599-75e2-af72-b958ce5dc1cc: Completed - Done - 019cff70-2599-75e2-af72-b96db334332d: Running + └ Robie [explorer]: Completed - Done + Ada [reviewer]: Running diff --git a/codex-rs/tui_app_server/src/chatwidget/tests.rs b/codex-rs/tui_app_server/src/chatwidget/tests.rs index 98509303ea..9b54d70351 100644 --- a/codex-rs/tui_app_server/src/chatwidget/tests.rs +++ b/codex-rs/tui_app_server/src/chatwidget/tests.rs @@ -2046,6 +2046,7 @@ async fn make_chatwidget_manual( pending_guardian_review_status: PendingGuardianReviewStatus::default(), last_copyable_output: None, running_commands: HashMap::new(), + collab_agent_metadata: HashMap::new(), pending_collab_spawn_requests: HashMap::new(), suppressed_exec_calls: HashSet::new(), skills_all: Vec::new(), @@ -4677,6 +4678,16 @@ async fn live_app_server_collab_wait_items_render_history() { ThreadId::from_string("019cff70-2599-75e2-af72-b958ce5dc1cc").expect("valid thread id"); let other_receiver_thread_id = ThreadId::from_string("019cff70-2599-75e2-af72-b96db334332d").expect("valid thread id"); + chat.set_collab_agent_metadata( + receiver_thread_id, + Some("Robie".to_string()), + Some("explorer".to_string()), + ); + chat.set_collab_agent_metadata( + other_receiver_thread_id, + Some("Ada".to_string()), + Some("reviewer".to_string()), + ); chat.handle_server_notification( ServerNotification::ItemStarted(ItemStartedNotification {